Repository navigation
Commit 70dccce
feat(pm): fleet-write relay — closed op table, validator, executor, seat-side dispatcher, and the dispatch transport in the five write tools (#19725)
Part of #19701 — the seats' side of the fleet-write relay (PR B of two).
The relay workflow itself is PR A, on
`claude/issue-19701-fleet-write-relay`, which depends on this PR landing
first. Nothing here needs PR A to be live: every tool keeps its `direct`
transport, and `auto` falls back to it, out loud, when no relay run
appears.
## What this adds
A cloud seat container cannot act as `objectstack-fleet[bot]`: its
egress proxy replaces every `Authorization` header and refuses the
`/app/**` mint path (the card's readings). What it can do is `POST
/repos/objectstack-ai/objectstack/dispatches`. So a seat now packs the
same requests it would have sent directly into ONE
`repository_dispatch`, and the relay workflow (PR A) executes them as
the fleet against the repository the payload names.
`scripts/pm/fleet-write/` — four files, one whitelist spelling:
- `ops.mjs` — the frozen op table: per op its required and optional
keys, the endpoint(s) and verb it becomes, and the App permission it
spends. The closed v1 list from the card: `comment`, `comment_edit`,
`labels_add`, `labels_remove`, `assign`, `unassign`, `issue_patch`
(title / body / state / state_reason only), `issue_create`, `pr_create`
(`draft: true` forced), `pr_request_reviewers`, `pr_ready`, `pr_draft`,
`automerge_enable` (`SQUASH`), `automerge_disable`. Merge, review
submission, ref / contents / workflow writes, releases and org endpoints
have no row, so the validator refuses them by construction, and its
self-test pins that no request any op can spell reaches those families
and none issues a whole-set `PUT`.
- `validate.mjs` — `validatePayload(obj) → { ok, errors, payload }`,
hand-written, no dependency, imported by both the seat-side packer and
the runner. Closed envelope `{ request_id ≤ 64, repo:
objectstack-ai/NAME, session: session_…, actions: 1..20 }`; unknown
top-level or per-action keys refused by name; integers must be JSON
numbers; strings capped in bytes (body ≤ 60000, title ≤ 256, label ≤
50); lists capped and de-duplicated; enums closed. The platform's own
`client_payload` ceilings are pinned with their source: at most 10
top-level properties and under 64KB (GitHub's REST OpenAPI description
for "Create a repository dispatch event"). `--from-env --github-output`
is the runner's spelling; a refusal writes every reason to the step
summary with zero writes.
- `execute.mjs` — the runner-side executor. Sender gate first: `GET
/repos/{payload.repo}/collaborators/{sender}/permission`, allowed only
for `write` / `maintain` / `admin` (GitHub folds `maintain` into
`permission: write`; `role_name` is read too), a 404 or a lesser role is
a run failure with zero writes. Then the actions in order, stop at the
first failure (later actions not attempted, and the summary says so); a
404 on a directed label DELETE is idempotent success; the GraphQL ops
read the pull's node id first and treat an `errors` array as failure.
Every write is paced through `write-pace.mjs`. The step summary table:
request id · sender and role · session · target · one row per request
(op, endpoint, HTTP status, id / url). The token arrives only through
`GITHUB_TOKEN`, is never printed or written, and every emitted line is
scrubbed — the self-test feeds a known token through a platform that
echoes it and greps every output.
- `dispatch.mjs` — the seat side. `OS_FLEET_TRANSPORT ∈ direct |
dispatch | auto` (default `auto`). `auto` is `dispatch` only when ALL
THREE hold: ① `CCR_AGENT_PROXY_ENABLED=1` (the variable naming the proxy
whose header replacement is the reason; never the proxy port), ② a
well-formed `OS_FLEET_SESSION=session_…` on the envelope, ③ the relay
live on the board — `GET
/repos/objectstack-ai/objectstack/contents/.github/workflows/fleet-write.yml?ref=main`
answering 200, read once per process and cached, and only reached once ①
and ② hold. Any condition failing ⇒ `direct`, with one printed line
naming it and no start-window wait: a seat that has not opted in, or a
board without the listening workflow, keeps today's behaviour at today's
speed. An explicit `OS_FLEET_TRANSPORT=dispatch` stays strict — a
missing session or a relay that is not live is a prerequisite refusal
(exit 3), never a fall-back. Once `dispatch` is taken: one paced
dispatch per stroke, then the run-poller (`display_title` carrying the
request id, then the run until `completed`); the two ceilings
`OS_FLEET_DISPATCH_START_MS` (90 s) and `OS_FLEET_DISPATCH_TIMEOUT_MS`
(5 min) answer `UNCONFIRMED` (exit 6) with the run URL, never retried,
and under `auto` a live-but-silent relay (no run in the start window)
falls back to `direct` with one line naming the double-write risk.
`with-fleet.sh --via auto` asks the same selector (`dispatch.mjs
--route`). `OS_FLEET_RELAY_LIVE=1|0` overrides ③ for tests only and says
so.
The five write tools take the transport, each keeping its shape:
- `label-write.mjs` — steps ①, ② and ④ unchanged; step ③ packs the adds,
the directed removes and the assignee change of this one write into one
dispatch (`relayActions`, the same verbs in the same order as the direct
plan). The whole-set `PATCH` fallback stays `direct`-only: the relay has
additive verbs only. An idempotent no-op dispatches nothing.
- `post-stamped.mjs` — the stamp is substituted before packing (the
stamp is the seat's act); one `comment` or one `issue_patch { body }`;
afterwards the stored body is read from the board (the newest comment
since the dispatch whose stored body is the one sent, footer-appending
and newline-stripping counted as landed) and judged by the same
`readBackVerdict` as the direct path. Exit register kept apart: a
refused dispatch is 3 (no act), a failed run is 4 (go read), no run or
no completion is 6.
- `issue-create.mjs` — one `issue_create`; the new number is found by
title among issues created since the dispatch, then the ordinary
read-back.
- `close-cards.mjs` — the comment goes through `post-stamped` as before
(the child resolves its own transport from the same environment), the
label write is handed this run's route, the close is one `issue_patch {
state: closed, state_reason }` read back from the card. A relay miss on
the close is a half-write: the run stops there naming the run.
- `with-fleet.sh` — `--via direct | dispatch | auto` (or
`OS_FLEET_TRANSPORT`). `--via dispatch --repo owner/name --actions
actions.json` sends an actions file through the relay (the door for ops
no node tool owns: `pr_ready`, `automerge_enable`, reviewers…); a
command after `--` under `auto` in a cloud container is refused (exit 3)
with that spelling, because the identity cannot be minted there and
running it as the session login instead would be a silent change of
identity. An explicit `--via direct` still wins.
`write-pace.mjs`: the roster of write-verb files gains the two relay
halves (its own self-test demanded it — a write verb in `scripts/pm`
must be on the roster and call both halves), and the roster is now
spelled repo-relative (`scripts/pm/…`). Reason, measured:
`check:pm-dispatch-gates` reads every separator-bearing literal in a
gate's source as a path lead; a bare `fleet-write/dispatch.mjs` resolved
to nothing from the repo root, printed as a dead lead under the
write-pace family, and its live self-test battery miscounted that row (1
of 1906 cases red, reproduced without contention). Repo-relative entries
are live leads, so a card touching any roster file now names the
write-pace gate — the right answer, since that self-test reads those
files. The roster's consumers were respelled to match, which is the one
line this PR touches in `fleet-token.mjs` (outside the card's file
surface; mechanical, same gate family). The same derivation then listed
the three new gate families as unreachable (their only separator-bearing
literals at module scope named no tracked path), so `ops.mjs` declares
`RELAY_FILES` — the relay's four files, repo-relative, checked on disk
by the validator's self-test — which gives each family a live lead
through the import channel; on a tree carrying both PRs the derivation
reports 0 unreachable families. `package.json`:
`check:pm-fleet-write-validate`, `check:pm-fleet-write-execute`,
`check:pm-fleet-write-dispatch`, spelled like `check:pm-post-stamped`;
the `lint.yml` steps that run them are in PR A (a workflow file).
## The permissions the relay token needs (for PR A's mint)
From GitHub's server-to-server permission ledger (`github/docs`,
`src/github-apps/data/fpt-2022-11-28/server-to-server-permissions.json`,
read 2026-09-22, docs.github.com itself being unreachable from this
container): every `/issues/**` write here is `issues: write`; `POST
/pulls`, `POST /pulls/{n}/requested_reviewers` and the pull-request
mutations are `pull-requests: write`; `GET
/repos/{o}/{r}/collaborators/{login}/permission` is `metadata: read`.
`PERMISSIONS` in `ops.mjs` is exactly that set and PR A declares exactly
it on `actions/create-github-app-token@v2` (`permission-issues`,
`permission-pull-requests`, `permission-metadata`). Not measured:
whether `enablePullRequestAutoMerge` needs `contents: write` in addition
— the live probe decides it; the fix would be one more
`permission-contents: write` line in PR A, deliberately not pre-granted.
## Verification
Every self-test offline, no network, run from the pushed tree (`git
rev-parse --short HEAD` = `b656a69`):
```
node scripts/pm/fleet-write/validate.mjs --self-test → 69 cases, 7 batteries
node scripts/pm/fleet-write/execute.mjs --self-test → 48 cases, 9 batteries
node scripts/pm/fleet-write/dispatch.mjs --self-test → 48 cases, 8 batteries
node scripts/pm/label-write.mjs --self-test → 88 cases, 10 batteries (+12 relay)
node scripts/pm/issue-create.mjs --self-test → 42 cases, 6 batteries (+8 relay)
node scripts/pm/post-stamped.mjs --self-test → 574 cases, 20 batteries (+12 relay)
node scripts/pm/close-cards.mjs --self-test → 111 cases, 12 batteries (+8 relay)
bash scripts/pm/with-fleet.sh --self-test → 30 cases (+10 --via)
node scripts/pm/write-pace.mjs --self-test → 109 cases, 12 batteries (the nine-file roster)
node scripts/pm/fleet-token.mjs --self-test → 73 cases, 8 batteries (the respelled roster row)
```
Derived gate families (`node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands`, no paths, off the merge base):
43 families reconciled with `--ran` at `b656a69` — 39 run green, 4 NOT
MEASURED (each exits 3 because it loads built `dist/`, which this diff
never touches and CI builds: `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`), 0 unrun.
`check:pm-dispatch-gates` (run separately, ~15 min on this box):
1906/1906 cases on this head, and 1906/1906 on a throwaway tree carrying
this head plus PR A's two workflow files, where the derivation also
reports 0 unreachable families and `check-self-test-wired` (production
and `--self-test`), `check-self-test-workflow-commands`,
`check:workflow-status-functions`, `check:workflow-step-name-quoting`
and `check-step-collectors` are green.
`pnpm exec eslint --no-inline-config` over the nine changed / new files:
clean.
Not measured here, by design: the live dispatch. A dispatch to the board
without the listening workflow answers 204 and no run (the card's
reading); exercising that path costs a write outside this run's budget,
so the start-window and completion ceilings are defaults, to be tuned
from the first live probe after PR A lands.
## Acceptance notes
- `close-cards` under `dispatch` spends up to three relay runs per card
(comment, label, close). Folding the three into one payload would be
faster and keeps the executor's stop-at-first-failure order, but it
would move the stamp rendering and the label arithmetic out of the tools
that own them; left as three strokes. Carrier: none.
- `scripts/pm/fleet-write/dispatch.mjs` and `label-write.mjs` import
each other (`classifyHttp` one way, the transport the other). Both use
the imported bindings only inside functions, so the ESM cycle is inert;
noted for the next reader.
- `with-fleet.sh --read` under `auto` in a cloud container is refused
like a write: the wrapper's whole job is the fleet identity, which
cannot be minted there; a read that needs no identity does not need the
wrapper.
- The seat-side references (`rest-channel.md`, `platform-readings.md`)
do not yet describe `OS_FLEET_TRANSPORT` / `OS_FLEET_SESSION`; the card
leaves documentation to the seat's own card.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VhatbEZCV5JJBoU579UBCG)_
Transport section rewritten by the dispatching seat (director, summon
#27) from the dev's patch-round report 5777044285 on #19701 (head
704648c): the three-condition `auto` selector ruled in review
5776449328.
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fa29803 commit 70dccce
12 files changed
Lines changed: 3156 additions & 71 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
97 | 100 | | |
98 | 101 | | |
99 | 102 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
115 | 128 | | |
116 | 129 | | |
117 | 130 | | |
| |||
140 | 153 | | |
141 | 154 | | |
142 | 155 | | |
| 156 | + | |
| 157 | + | |
143 | 158 | | |
144 | 159 | | |
145 | 160 | | |
| |||
550 | 565 | | |
551 | 566 | | |
552 | 567 | | |
| 568 | + | |
| 569 | + | |
553 | 570 | | |
554 | 571 | | |
555 | 572 | | |
| |||
573 | 590 | | |
574 | 591 | | |
575 | 592 | | |
576 | | - | |
| 593 | + | |
577 | 594 | | |
578 | 595 | | |
579 | 596 | | |
| |||
587 | 604 | | |
588 | 605 | | |
589 | 606 | | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
590 | 613 | | |
591 | 614 | | |
592 | 615 | | |
| |||
664 | 687 | | |
665 | 688 | | |
666 | 689 | | |
667 | | - | |
668 | | - | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
669 | 722 | | |
670 | 723 | | |
671 | 724 | | |
| |||
723 | 776 | | |
724 | 777 | | |
725 | 778 | | |
| 779 | + | |
726 | 780 | | |
727 | | - | |
| 781 | + | |
728 | 782 | | |
729 | 783 | | |
730 | 784 | | |
| |||
794 | 848 | | |
795 | 849 | | |
796 | 850 | | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
797 | 873 | | |
798 | 874 | | |
799 | 875 | | |
800 | 876 | | |
801 | 877 | | |
| 878 | + | |
802 | 879 | | |
803 | 880 | | |
804 | 881 | | |
| |||
814 | 891 | | |
815 | 892 | | |
816 | 893 | | |
| 894 | + | |
| 895 | + | |
817 | 896 | | |
818 | 897 | | |
819 | 898 | | |
| |||
826 | 905 | | |
827 | 906 | | |
828 | 907 | | |
829 | | - | |
| 908 | + | |
830 | 909 | | |
831 | 910 | | |
832 | 911 | | |
| |||
1006 | 1085 | | |
1007 | 1086 | | |
1008 | 1087 | | |
| 1088 | + | |
| 1089 | + | |
| 1090 | + | |
| 1091 | + | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
| 1104 | + | |
| 1105 | + | |
| 1106 | + | |
| 1107 | + | |
| 1108 | + | |
1009 | 1109 | | |
1010 | 1110 | | |
1011 | 1111 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
931 | 931 | | |
932 | 932 | | |
933 | 933 | | |
934 | | - | |
| 934 | + | |
935 | 935 | | |
936 | 936 | | |
937 | 937 | | |
| |||
0 commit comments