Skip to content

Commit 70dccce

Browse files
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

‎package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@
9494
"check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test",
9595
"check:pm-label-write": "node scripts/pm/label-write.mjs --self-test",
9696
"check:pm-close-cards": "node scripts/pm/close-cards.mjs --self-test",
97+
"check:pm-fleet-write-validate": "node scripts/pm/fleet-write/validate.mjs --self-test",
98+
"check:pm-fleet-write-execute": "node scripts/pm/fleet-write/execute.mjs --self-test",
99+
"check:pm-fleet-write-dispatch": "node scripts/pm/fleet-write/dispatch.mjs --self-test",
97100
"check:pm-board-snapshot": "node scripts/pm/board-snapshot.mjs --self-test",
98101
"check:pm-changeset-deadline-census": "node scripts/pm/changeset-deadline-census.mjs --self-test",
99102
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",

‎scripts/pm/close-cards.mjs‎

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@
112112
* does not retry — and exits 4 naming the card and exactly which steps landed.
113113
* Continuing would turn one half-state into a page of them.
114114
*
115+
* ## The transport — `OS_FLEET_TRANSPORT` direct | dispatch | auto
116+
*
117+
* A cloud seat container cannot write as the fleet directly, so each of the
118+
* three writes takes the fleet-write relay there: the comment through
119+
* `post-stamped.mjs` (the child resolves its own transport from the same
120+
* environment), the label write through `runLabelWrite` (handed THIS run's
121+
* route, so one decision serves both), and the close as ONE dispatch carrying
122+
* `issue_patch` — then the card is READ BACK, which is the same check the
123+
* direct `PATCH` answer gets. `auto` (the default) takes `dispatch` in a cloud
124+
* seat container and `direct` elsewhere, and says which. A relay outcome that
125+
* is not success on the close is a HALF-WRITE (the comment and the label
126+
* landed): the run stops there, exit 4, naming the run.
127+
*
115128
* ## Exit codes — capture them BEFORE any pipe
116129
*
117130
* 0 every non-skipped card landed all three writes (a run of all-skips too).
@@ -140,6 +153,8 @@ import {
140153
proxyRoute,
141154
resolveSweepRepo,
142155
} from './check-half-states.mjs';
156+
import { fallbackText, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs';
157+
import { refusalText as relayRefusalText } from './fleet-write/validate.mjs';
143158
import { classifyHttp, parseOptions as parseLabelWriteOptions, runLabelWrite } from './label-write.mjs';
144159
import { isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from './write-pace.mjs';
145160
import {
@@ -550,6 +565,8 @@ export async function runCloseCards(options, deps = {}) {
550565
};
551566

552567
const { repo, numbers, commentFile, reason, expectState, skipPrReferenced, dryRun } = options;
568+
const route = deps.route ?? (await resolveRoute(process.env));
569+
const send = deps.send ?? sendFleetWrite;
553570

554571
const postComment =
555572
deps.postComment ??
@@ -573,7 +590,7 @@ export async function runCloseCards(options, deps = {}) {
573590
(async ({ issue, remove }) => {
574591
const parsed = parseLabelWriteOptions(['--repo', repo, '--issue', String(issue), '--remove', remove]);
575592
if (!parsed.ok) return { exit: EXIT_USAGE, detail: parsed.error };
576-
const res = await runLabelWrite(parsed.options, { log: (line) => record(` ${line}`) });
593+
const res = await runLabelWrite(parsed.options, { log: (line) => record(` ${line}`), route, send: deps.send });
577594
return { exit: res.exit, detail: '' };
578595
});
579596

@@ -587,6 +604,12 @@ export async function runCloseCards(options, deps = {}) {
587604
`reason \`${reason}\` · expect-state \`${expectState}\` · open-PR skip ${skipPrReferenced ? 'ON' : 'OFF'}` +
588605
`${skipPrReferenced ? '' : ' (⛔ declared off: a card an open PR references will be closed under it)'}`,
589606
);
607+
if (route.error) {
608+
record(`close-cards: PREREQUISITE NOT MET — ${route.error}`);
609+
record('⛔ NOTHING was read and nothing was written.');
610+
return result(EXIT_PREREQUISITE, { transport: route.transport });
611+
}
612+
record(`close-cards: transport ${route.transport} — ${route.reason}`);
590613

591614
for (const issue of numbers) {
592615
const base = `/repos/${repo}/issues/${issue}`;
@@ -664,8 +687,38 @@ export async function runCloseCards(options, deps = {}) {
664687

665688
// ④ close — and READ THE ANSWER BACK. A 200 whose body does not say
666689
// `closed` is not a close, and a close recorded under another reason is a
667-
// wrong record rather than a near miss.
668-
const patched = await call(base, { method: 'PATCH', body: { state: 'closed', state_reason: reason } });
690+
// wrong record rather than a near miss. Under the relay the close is ONE
691+
// dispatch and the answer read back is the card itself.
692+
let patched = null;
693+
if (route.transport === 'dispatch') {
694+
const packed = packRequest({ repo, session: route.session, actions: [{ op: 'issue_patch', issue, state: 'closed', state_reason: reason }] });
695+
if (!packed.ok) {
696+
record(relayRefusalText(packed.errors));
697+
record(halfWriteText({ issue, repo, landed: ['comment', 'label'], failedStep: 'close', detail: 'the relay payload was refused before any dispatch' }));
698+
return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: ['comment', 'label'], transport: route.transport });
699+
}
700+
record(`#${issue} close (relay) — ONE dispatch, request ${packed.payload.request_id}: issue_patch closed \`${reason}\``);
701+
const sent = await send(packed.payload, { token: TOKEN, log: (line) => record(` ${line}`) });
702+
if (sent.ok) {
703+
const back = await call(base, {});
704+
patched = { ...back, call: `${back.call} (read back after relay run ${sent.run?.url ?? sent.run?.id ?? ''})` };
705+
} else if (route.requested === 'auto' && sent.state === 'no-run') {
706+
record(` ${fallbackText(sent, 'close-cards')}`);
707+
} else {
708+
if (sent.state === 'no-run' || sent.state === 'timeout') record(unconfirmedText(sent, 'close-cards'));
709+
record(
710+
halfWriteText({
711+
issue,
712+
repo,
713+
landed: ['comment', 'label'],
714+
failedStep: 'close',
715+
detail: `relay ${sent.state}${sent.run?.url ? ` ${sent.run.url}` : ''}${sent.detail ? ` — ${sent.detail}` : ''}`,
716+
}),
717+
);
718+
return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: ['comment', 'label'], transport: route.transport, relay: sent });
719+
}
720+
}
721+
if (patched === null) patched = await call(base, { method: 'PATCH', body: { state: 'closed', state_reason: reason } });
669722
const patchVerdict = classifyHttp({ status: patched.status, op: 'card-patch', rateRemaining: patched.rateRemaining });
670723
if (patchVerdict !== 'ok' || patched.json?.state !== 'closed' || patched.json?.state_reason !== reason) {
671724
record(
@@ -723,8 +776,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
723776
'the unreadable card: a verdict taken from nothing is not taken': 7,
724777
'the dry run: a plan that proves it wrote nothing': 7,
725778
'the sibling tools: driven, never re-implemented': 8,
779+
'the relay transport: the close as ONE dispatch read back from the card, the label write on the same route, a relay miss is a half-write': 8,
726780
});
727-
const SELF_TEST_BATTERY_FLOOR = 11;
781+
const SELF_TEST_BATTERY_FLOOR = 12;
728782
const UNATTRIBUTED_BATTERY = '(unattributed)';
729783

730784
const batteryCases = new Map();
@@ -794,11 +848,34 @@ export function fakeApi(initial = {}) {
794848
}
795849

796850
/** Drive the whole run offline: a fake board, a stubbed comment poster and a stubbed label write. */
851+
const DIRECT_ROUTE = Object.freeze({ requested: 'direct', transport: 'direct', reason: 'self-test: direct', error: null, session: null });
852+
853+
/** A fake relay: `success` applies the `issue_patch` to the fake card the run reads back; the other outcomes answer `sendFleetWrite`'s shapes. */
854+
function fakeRelay(api, outcome, sent) {
855+
const RUN = { id: 42, url: 'https://github.test/run/42', status: 'completed', conclusion: 'success' };
856+
return async (payload) => {
857+
sent.push(payload);
858+
const base = { requestId: payload.request_id, startMs: 1, ceilingMs: 2, status: 204, verdict: 'ok', detail: '' };
859+
if (outcome === 'no-run') return { ...base, state: 'no-run', ok: false, run: null, detail: 'no run appeared' };
860+
if (outcome === 'timeout') return { ...base, state: 'timeout', ok: false, run: { ...RUN, status: 'in_progress', conclusion: null } };
861+
if (outcome === 'failure') return { ...base, state: 'failure', ok: false, run: { ...RUN, conclusion: 'failure' }, detail: 'conclusion failure' };
862+
for (const a of payload.actions) {
863+
const card = api.cards.get(a.issue);
864+
if (card && a.op === 'issue_patch') {
865+
if ('state' in a) card.state = a.state;
866+
if ('state_reason' in a) card.state_reason = a.state_reason;
867+
}
868+
}
869+
return { ...base, state: 'success', ok: true, run: RUN, detail: 'conclusion success' };
870+
};
871+
}
872+
797873
async function driveOffline(options, initial = {}, stubs = {}) {
798874
const api = fakeApi(initial);
799875
const posted = [];
800876
const labelled = [];
801877
const out = [];
878+
const sent = [];
802879
const commentExits = [...(stubs.commentExits ?? [])];
803880
const labelExits = [...(stubs.labelExits ?? [])];
804881
const res = await runCloseCards(
@@ -814,6 +891,8 @@ async function driveOffline(options, initial = {}, stubs = {}) {
814891
{
815892
call: api.call,
816893
log: (line) => out.push(line),
894+
route: stubs.route ?? DIRECT_ROUTE,
895+
send: stubs.relay ? fakeRelay(api, stubs.relay, sent) : undefined,
817896
postComment: async ({ issue }) => {
818897
const exit = commentExits.length ? commentExits.shift() : 0;
819898
posted.push({ issue, exit });
@@ -826,7 +905,7 @@ async function driveOffline(options, initial = {}, stubs = {}) {
826905
},
827906
},
828907
);
829-
return { res, api, posted, labelled, out, text: out.join('\n') };
908+
return { res, api, posted, labelled, out, sent, text: out.join('\n') };
830909
}
831910

832911
const QUEUED = (number, extra = {}) => ({ [number]: { state: 'open', labels: ['pm:queue', 'tooling'], assignees: [], timeline: [], ...extra } });
@@ -1006,6 +1085,27 @@ export async function selfTest() {
10061085
// The floor runs BEFORE the verdict, so a success line can only be printed by
10071086
// a run in which every declared battery registered its cases.
10081087
const floorProblems = [];
1088+
// ── the relay transport ──────────────────────────────────────────────────
1089+
battery('the relay transport: the close as ONE dispatch read back from the card, the label write on the same route, a relay miss is a half-write');
1090+
{
1091+
const SESSION = 'session_01ABCDEFGHJKMNPQRSTVWXYZ';
1092+
const dispatchRoute = (requested = 'dispatch') => ({ requested, transport: 'dispatch', reason: 'self-test: dispatch', error: null, session: SESSION });
1093+
const ok = await driveOffline({ numbers: [61] }, { cards: QUEUED(61) }, { route: dispatchRoute(), relay: 'success' });
1094+
t('under dispatch the close lands through ONE relay dispatch and the card reads back closed, exit 0', [ok.res.exit, ok.res.counts.closed, ok.api.cards.get(61).state, ok.api.cards.get(61).state_reason], [EXIT_OK, 1, 'closed', 'not_planned']);
1095+
t('…the payload carries issue_patch closed with the reason, the session and the target', [ok.sent.length, ok.sent[0].session, ok.sent[0].repo, ok.sent[0].actions], [1, SESSION, 'objectstack-ai/objectstack', [{ op: 'issue_patch', issue: 61, state: 'closed', state_reason: 'not_planned' }]]);
1096+
t('…and no PATCH left this process — the reads and a read-back only', ok.api.calls.map((c) => c.kind), ['read', 'timeline', 'read']);
1097+
t('the transport is printed on every run, direct included', ok.text.includes('transport dispatch') && (await driveOffline({ numbers: [61] }, { cards: QUEUED(61) })).text.includes('transport direct'));
1098+
const timedOut = await driveOffline({ numbers: [62, 63] }, { cards: { ...QUEUED(62), ...QUEUED(63) } }, { route: dispatchRoute(), relay: 'timeout' });
1099+
t('a close whose run did not complete is UNCONFIRMED and a HALF-WRITE: exit 4 naming the run, the next card never read', [timedOut.res.exit, timedOut.text.includes('UNCONFIRMED') && timedOut.text.includes('https://github.test/run/42'), timedOut.res.counts.read], [EXIT_HALF_WRITE, true, 1]);
1100+
const noRunAuto = await driveOffline({ numbers: [64] }, { cards: QUEUED(64) }, { route: dispatchRoute('auto'), relay: 'no-run' });
1101+
t('under AUTO, no run falls back to the direct PATCH — said out loud — and the card closes', [noRunAuto.res.exit, noRunAuto.text.includes('Falling back to DIRECT'), noRunAuto.api.calls.some((c) => c.kind === 'patch'), noRunAuto.api.cards.get(64).state], [EXIT_OK, true, true, 'closed']);
1102+
const failed = await driveOffline({ numbers: [65] }, { cards: QUEUED(65) }, { route: dispatchRoute('auto'), relay: 'failure' });
1103+
t('⛔ a run that FAILED is never fallen back from, even under auto: half-write, no PATCH', [failed.res.exit, failed.api.calls.some((c) => c.kind === 'patch')], [EXIT_HALF_WRITE, false]);
1104+
const badRoute = await driveOffline({ numbers: [66] }, { cards: QUEUED(66) }, { route: { requested: 'dispatch', transport: 'dispatch', reason: '', error: 'OS_FLEET_SESSION is absent', session: null } });
1105+
t('a route with an error is exit 3 before the first card is read', [badRoute.res.exit, badRoute.api.calls.length], [EXIT_PREREQUISITE, 0]);
1106+
t('structural: the label write is handed THIS run\'s route, so one decision serves both writes', readFileSync(SELF_PATH, 'utf8').includes('runLabelWrite(parsed.options, { log: (line) => record(` ${line}`), route, send: deps.send })'));
1107+
}
1108+
10091109
const declared = Object.keys(SELF_TEST_BATTERIES);
10101110
if (declared.length < SELF_TEST_BATTERY_FLOOR) {
10111111
floorProblems.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`);

‎scripts/pm/fleet-token.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,7 @@ export async function selfTest() {
931931
{
932932
const { WIRED_WRITE_TOOLS } = await import('./write-pace.mjs');
933933
const own = readFileSync(SELF_PATH, 'utf8');
934-
t('write-pace lists this file among the wired write tools', WIRED_WRITE_TOOLS.includes('fleet-token.mjs'));
934+
t('write-pace lists this file among the wired write tools', WIRED_WRITE_TOOLS.includes('scripts/pm/fleet-token.mjs'));
935935
t('this file calls both halves around its POST', own.includes('paceWrite(') && own.includes('noteResponse(') && own.includes('isWriteMethod('));
936936
const records = readFileSync(paceFile, 'utf8');
937937
t('the mints above were recorded by the throttle under the App key, ⛔ not under any token', records.includes('fleet-token POST') && !records.includes(TOKEN));

0 commit comments

Comments
 (0)