176176 * and an issue the last re-list still lacks is `unfound` — exit 6,
177177 * UNCONFIRMED, read the board — exactly what it meant before.
178178 *
179- * Why a re-list and not the run's own report of the number it created: the
180- * executor prints `#number url` to the job log and the step summary, and a
181- * seat container reads neither — the job-log endpoint answers 302 to blob
182- * storage that the egress proxy refuses (CONNECT 403), and the job's check run
183- * carries a null `output.summary`. Making the run report it somewhere a seat
184- * CAN read is a change to what the relay emits, not to this read-back.
179+ * The re-list is the FALLBACK. The run knows the number it created, and a
180+ * seat container reads neither the job log (its endpoint answers 302 to blob
181+ * storage the egress proxy refuses, CONNECT 403) nor the step summary (the
182+ * job's check run carries a null `output.summary`) — but it does read the
183+ * check run's ANNOTATIONS, so the run reports the number there (next section).
184+ * An `issue_create` whose action the run's annotation names is read back AT
185+ * that number: one GET, no list, no re-list. Only an action no annotation
186+ * names takes the re-list above.
185187 *
186188 * How the callers read the new outcomes — one exit vocabulary, no caller edited:
187189 * - `failure` + `notStored` keeps the state every caller already reads as "the
196198 * - a stroke carrying no body (labels, assignees, state, a transfer, the
197199 * GraphQL ops) reads nothing back and its outcome is unchanged.
198200 *
201+ * ## The run's annotations — the numbers a seat CAN read
202+ *
203+ * `execute.mjs` prints, for every action whose op is in `ANNOTATED_OPS` (the
204+ * ops that create or move a card) and whose request LANDED, ONE workflow
205+ * command — a `notice` titled `fleet-write <op>` whose message is
206+ * `fleet-write action=<i> op=<op> number=<n> url=<url>`, the number and url
207+ * taken from the platform's ANSWER to that request (the created issue's
208+ * `number` / `html_url`, the `transferIssue` answer's `issue.number` /
209+ * `issue.url`), never from the request. The runner turns it into an
210+ * annotation on the job's check run. After a success run carrying such an
211+ * op, `sendFleetWrite` reads them — `GET /repos/{board}/actions/runs/{id}/jobs`
212+ * (a job's id is its check run's id, measured) then
213+ * `GET /repos/{board}/check-runs/{id}/annotations`, both measured readable
214+ * from a seat container — and hands them on as `result.annotations`; a
215+ * caller that needs a card's number (`issue-create.mjs`, `issue-transfer.mjs`)
216+ * reads it there first. `relayAnnotationMessage` spells the message and
217+ * `parseRelayAnnotation` reads it — ONE spelling for the writer and every
218+ * reader; the executor emits only a message the parser reads back as what it
219+ * meant. A parsed row is used only when the stroke's action at its index has
220+ * its op and its url names the repository that op lands the card on
221+ * (`matchRunAnnotations`); two different rows for one action are neither.
222+ *
223+ * Absent is an ordinary state, never a failure, and always said: a relay
224+ * older than the emission, an annotation read that does not answer, or an
225+ * action past the platform's cap — 10 notice annotations per step and 50 per
226+ * job (actions/toolkit `docs/problem-matchers.md`), the rest dropped without
227+ * a word, so of a stroke carrying more than ten such ops only the first ten
228+ * are named. Each reader then takes its fallback: the re-list here, the
229+ * redirect or the title in `issue-transfer.mjs`, the title in
230+ * `issue-create.mjs`. ⛔ No second read path beyond that fallback, and ⛔ not
231+ * a gate: an annotation only ever replaces a search for a number.
232+ *
199233 * ## Exit codes — capture them BEFORE any pipe
200234 *
201235 * 0 the run completed with conclusion `success`, and every body it wrote
@@ -525,7 +559,7 @@ export const BODY_OPS = Object.freeze(OP_NAMES.filter((op) => [...OPS[op].requir
525559export const READ_BACK_LOCATORS = Object . freeze ( {
526560 issue_patch : Object . freeze ( { found : 'address' , where : 'GET /repos/{repo}/issues/{issue}' } ) ,
527561 comment_edit : Object . freeze ( { found : 'address' , where : 'GET /repos/{repo}/issues/comments/{comment_id}' } ) ,
528- issue_create : Object . freeze ( { found : 'key' , where : ' the newest issue created since the dispatch whose title is the title sent, the list re-read on ISSUE_CREATE_RELIST_DELAYS_MS before unfound' } ) ,
562+ issue_create : Object . freeze ( { found : 'key' , where : "the issue at the number the run's annotation names; absent that, the newest issue created since the dispatch whose title is the title sent, the list re-read on ISSUE_CREATE_RELIST_DELAYS_MS before unfound" } ) ,
529563 pr_create : Object . freeze ( { found : 'key' , where : 'the newest pull request on the head sent, created since the dispatch' } ) ,
530564 comment : Object . freeze ( { found : 'content' , where : 'the newest comment on the issue, created since the dispatch, whose stored body holds the bytes sent' } ) ,
531565} ) ;
@@ -631,6 +665,88 @@ export function unverifiedText(result, tool = 'fleet-write') {
631665 ) ;
632666}
633667
668+ // ---------------------------------------------------------------------------
669+ // The run's annotations — pure halves (the header's annotations section is the authority)
670+ // ---------------------------------------------------------------------------
671+
672+ /**
673+ * The ops whose landing the executor reports as an annotation — every op that
674+ * creates or moves a card, and nothing else — each with the repository it
675+ * lands the card on, which a parsed row's url is held to. `execute.mjs` reads
676+ * a number and url out of each one's answer (`ANNOTATION_ANSWERS`, pinned to
677+ * these keys by its self-test).
678+ */
679+ export const ANNOTATED_OPS = Object . freeze ( {
680+ issue_create : Object . freeze ( { lands : ( action , repo ) => repo } ) ,
681+ transfer : Object . freeze ( { lands : ( action ) => action ?. target_repo } ) ,
682+ } ) ;
683+
684+ /** The word a relay annotation's message starts with — and its title, before the op. */
685+ export const RELAY_ANNOTATION_PREFIX = 'fleet-write' ;
686+
687+ const RELAY_ANNOTATION_SHAPE = new RegExp ( `^${ RELAY_ANNOTATION_PREFIX } action=([1-9][0-9]*) op=([a-z_]+) number=([1-9][0-9]*) url=(https://\\S+)$` ) ;
688+ /** An issue's web url — group 1 its repository, group 2 its number. */
689+ const ISSUE_URL_SHAPE = / ^ h t t p s : \/ \/ [ ^ / \s ] + \/ ( [ ^ / \s ] + \/ [ ^ / \s ] + ) \/ i s s u e s \/ ( [ 1 - 9 ] [ 0 - 9 ] * ) $ / ;
690+
691+ /** The message of the one annotation the executor emits for a landed annotated op. Pure. */
692+ export function relayAnnotationMessage ( { action, op, number, url } ) {
693+ return `${ RELAY_ANNOTATION_PREFIX } action=${ action } op=${ op } number=${ number } url=${ url } ` ;
694+ }
695+
696+ /**
697+ * An annotation's `message` as `{ action, op, number, url, repo }`, or null —
698+ * for anything else on the check run (the runner's own notices, an action's
699+ * deprecation warnings), an op outside `ANNOTATED_OPS`, or a url that is not
700+ * an issue url carrying the same number. Pure; never throws.
701+ */
702+ export function parseRelayAnnotation ( message ) {
703+ const m = RELAY_ANNOTATION_SHAPE . exec ( typeof message === 'string' ? message : '' ) ;
704+ if ( ! m || ! Object . hasOwn ( ANNOTATED_OPS , m [ 2 ] ) ) return null ;
705+ const at = ISSUE_URL_SHAPE . exec ( m [ 4 ] ) ;
706+ if ( ! at || at [ 2 ] !== m [ 3 ] ) return null ;
707+ return { action : Number ( m [ 1 ] ) , op : m [ 2 ] , number : Number ( m [ 3 ] ) , url : m [ 4 ] , repo : at [ 1 ] } ;
708+ }
709+
710+ /**
711+ * The parsed rows a stroke may use: a row whose action index holds an action
712+ * of its op, and whose url names the repository that op lands the card on.
713+ * Anything else is `ignored`, with why — and so are BOTH rows of an action
714+ * two different rows name: a reader never picks between two answers. Pure.
715+ */
716+ export function matchRunAnnotations ( payload , parsed ) {
717+ const actions = Array . isArray ( payload ?. actions ) ? payload . actions : [ ] ;
718+ const ignored = [ ] ;
719+ const byAction = new Map ( ) ;
720+ for ( const row of Array . isArray ( parsed ) ? parsed : [ ] ) {
721+ const action = actions [ row . action - 1 ] ;
722+ if ( ! action || action . op !== row . op ) {
723+ ignored . push ( { ...row , why : `the stroke's action ${ row . action } is ${ action ? action . op : 'absent' } , not ${ row . op } ` } ) ;
724+ continue ;
725+ }
726+ const lands = String ( ANNOTATED_OPS [ row . op ] . lands ( action , payload . repo ) ?? '' ) ;
727+ if ( lands . toLowerCase ( ) !== row . repo . toLowerCase ( ) ) {
728+ ignored . push ( { ...row , why : `its url names ${ row . repo } , and ${ row . op } lands the card on ${ lands || 'no repository' } ` } ) ;
729+ continue ;
730+ }
731+ byAction . set ( row . action , [ ...( byAction . get ( row . action ) ?? [ ] ) , row ] ) ;
732+ }
733+ const rows = [ ] ;
734+ for ( const [ action , list ] of byAction ) {
735+ const distinct = new Set ( list . map ( ( r ) => `${ r . number } ${ r . url } ` ) ) ;
736+ if ( distinct . size > 1 ) ignored . push ( ...list . map ( ( r ) => ( { ...r , why : `${ distinct . size } different annotations name action ${ action } ` } ) ) ) ;
737+ else rows . push ( list [ 0 ] ) ;
738+ }
739+ rows . sort ( ( a , b ) => a . action - b . action ) ;
740+ return { rows, ignored } ;
741+ }
742+
743+ /** A job's check run id — from its `check_run_url`, else its own id (measured equal). Pure. */
744+ export function checkRunIdOf ( job ) {
745+ const m = / \/ c h e c k - r u n s \/ ( [ 1 - 9 ] [ 0 - 9 ] * ) $ / . exec ( String ( job ?. check_run_url ?? '' ) ) ;
746+ if ( m ) return Number ( m [ 1 ] ) ;
747+ return Number . isInteger ( job ?. id ) && job . id > 0 ? job . id : null ;
748+ }
749+
634750// ---------------------------------------------------------------------------
635751// Transport — the one POST, paced; the reads around it are not.
636752// ---------------------------------------------------------------------------
@@ -687,15 +803,66 @@ async function listAll(api, path, t, maxPages = 10) {
687803 return { ok : true , rows } ;
688804}
689805
806+ /**
807+ * The relay annotations on a run's check runs — its jobs, then each job's
808+ * check-run annotations, every message through `parseRelayAnnotation` (the
809+ * rest of the check run's annotations are ignored). Returns `{ state, parsed,
810+ * why }`: `read`, or `unread` naming the call that did not answer, with
811+ * whatever was parsed before it. Reads only — never paced; never throws.
812+ */
813+ export async function readRunAnnotations ( runId , deps = { } ) {
814+ const api = deps . api ?? DEFAULT_API ;
815+ const t = { fetch : deps . fetch , token : deps . token } ;
816+ const said = ( r ) => `${ r . call } -> HTTP ${ r . status } ${ r . detail ? ` (${ r . detail } )` : '' } ` ;
817+ const jobs = await rest ( api , `/repos/${ RELAY_REPO } /actions/runs/${ runId } /jobs?per_page=100` , { } , t ) ;
818+ if ( jobs . status !== 200 || ! Array . isArray ( jobs . json ?. jobs ) ) return { state : 'unread' , parsed : [ ] , why : said ( jobs ) } ;
819+ const parsed = [ ] ;
820+ for ( const job of jobs . json . jobs ) {
821+ const id = checkRunIdOf ( job ) ;
822+ if ( id === null ) continue ;
823+ const r = await rest ( api , `/repos/${ RELAY_REPO } /check-runs/${ id } /annotations?per_page=100` , { } , t ) ;
824+ if ( r . status !== 200 || ! Array . isArray ( r . json ) ) return { state : 'unread' , parsed, why : said ( r ) } ;
825+ for ( const a of r . json ) {
826+ const row = parseRelayAnnotation ( a ?. message ) ;
827+ if ( row ) parsed . push ( row ) ;
828+ }
829+ }
830+ return { state : 'read' , parsed, why : '' } ;
831+ }
832+
833+ /**
834+ * The annotations a stroke's success run carries, matched to its actions and
835+ * said: one line per row, per ignored row, per annotated action no row names,
836+ * and one when the read did not answer. A stroke with no annotated op reads
837+ * nothing (`state: 'none'`). Returns `{ state, rows, ignored, why }`.
838+ */
839+ async function runAnnotationsFor ( payload , run , deps ) {
840+ const annotated = payload . actions . map ( ( a , i ) => ( { action : i + 1 , op : a . op } ) ) . filter ( ( a ) => Object . hasOwn ( ANNOTATED_OPS , a . op ) ) ;
841+ if ( ! annotated . length ) return { state : 'none' , rows : [ ] , ignored : [ ] , why : '' } ;
842+ const read = await readRunAnnotations ( run . id , deps ) ;
843+ const { rows, ignored } = matchRunAnnotations ( payload , read . parsed ) ;
844+ const log = deps . log ;
845+ for ( const r of rows ) log ( `fleet-write: run ${ run . id } 's annotation names action ${ r . action } ${ r . op } → ${ r . repo } #${ r . number } ${ r . url } (the platform's own answer).` ) ;
846+ for ( const r of ignored ) log ( `fleet-write: run ${ run . id } 's annotation for action ${ r . action } ${ r . op } (#${ r . number } ) is ignored — ${ r . why } .` ) ;
847+ if ( read . state === 'unread' ) log ( `fleet-write: run ${ run . id } 's annotations could not be read — ${ read . why } ; a number no annotation above names is read from the board instead.` ) ;
848+ for ( const a of annotated ) {
849+ if ( ! rows . some ( ( r ) => r . action === a . action ) ) log ( `fleet-write: no annotation on run ${ run . id } names action ${ a . action } ${ a . op } — its number is read from the board instead (the fallback).` ) ;
850+ }
851+ return { state : read . state , rows, ignored, why : read . why } ;
852+ }
853+
690854/**
691855 * Read back every body a completed stroke wrote and judge it against the bytes
692856 * sent. Returns `{ state, rows }` — `state` from `strokeReadBackState`. Never
693857 * throws on a status: a read that fails makes its row `unverified`, with the
694858 * call and status in `why`. `taken` keeps two actions of one stroke from being
695- * judged against the same created object. `sleep` and `log` serve the
696- * `issue_create` re-list alone (`ISSUE_CREATE_RELIST_DELAYS_MS`).
859+ * judged against the same created object. `annotations` are the run's matched
860+ * rows (`matchRunAnnotations`): an `issue_create` one of them names is read
861+ * at that number, never listed. `sleep` and `log` serve the `issue_create`
862+ * re-list alone (`ISSUE_CREATE_RELIST_DELAYS_MS`), which only an
863+ * `issue_create` no annotation names takes.
697864 */
698- export async function readBackStroke ( payload , { dispatchedAt } , deps = { } ) {
865+ export async function readBackStroke ( payload , { dispatchedAt, annotations = [ ] } , deps = { } ) {
699866 const targets = readBackTargets ( payload ) ;
700867 if ( ! targets . length ) return { state : 'none' , rows : [ ] } ;
701868 const api = deps . api ?? DEFAULT_API ;
@@ -719,6 +886,18 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
719886 rows . push ( r . status === 200 && r . json ? judged ( where , r . json . body ) : unread ( target , where , r ) ) ;
720887 continue ;
721888 }
889+ // An `issue_create` the run's annotation names is read AT that number — the platform's own answer to the create:
890+ // one GET, no list, no re-list (header). A read that fails is `unread`, as any addressed read is.
891+ const named = target . op === 'issue_create' ? annotations . find ( ( a ) => a . op === target . op && a . action === target . action ) : undefined ;
892+ if ( named ) {
893+ const where = `${ repo } #${ named . number } ` ;
894+ const r = await rest ( api , `/repos/${ repo } /issues/${ named . number } ` , { } , t ) ;
895+ if ( r . status === 200 && r . json ) {
896+ if ( r . json . id !== undefined ) taken . add ( r . json . id ) ;
897+ rows . push ( { ...judged ( where , r . json . body ) , foundBy : 'annotation' } ) ;
898+ } else rows . push ( { ...unread ( target , where , r ) , foundBy : 'annotation' } ) ;
899+ continue ;
900+ }
722901 if ( target . op === 'issue_create' || target . op === 'pr_create' ) {
723902 const isPr = target . op === 'pr_create' ;
724903 const head = isPr ? ( String ( target . head ) . includes ( ':' ) ? String ( target . head ) : `${ repo . split ( '/' ) [ 0 ] } :${ target . head } ` ) : null ;
@@ -751,7 +930,7 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
751930 continue ;
752931 }
753932 taken . add ( hit . id ) ;
754- rows . push ( judged ( `${ repo } #${ hit . number } ` , hit . body ) ) ;
933+ rows . push ( { ... judged ( `${ repo } #${ hit . number } ` , hit . body ) , ... ( isPr ? { } : { foundBy : 'list' } ) } ) ;
755934 continue ;
756935 }
757936 // `comment`: only the body finds a new comment, so a body that matches nothing is unverified, never not-stored.
@@ -783,7 +962,8 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
783962 * `success` (the run succeeded and every body reads back as sent) · `failure` (the run completed otherwise, or — `notStored:
784963 * true` — it succeeded and the read-back measured a body the board does not hold as sent) · `unverified` (it succeeded and a
785964 * body could not be read back) · `no-run` · `timeout` · `refused` (the dispatch itself). `readBack` is `readBackStroke`'s
786- * answer, present once the run succeeded. Never throws on an HTTP status.
965+ * answer, present once the run succeeded, and so is `annotations` — `{ state, rows, ignored, why }`, the run's
966+ * annotations matched to the stroke (`state` `none` when it carries no annotated op). Never throws on an HTTP status.
787967 */
788968export async function sendFleetWrite ( payload , deps = { } ) {
789969 const api = deps . api ?? DEFAULT_API ;
@@ -842,18 +1022,21 @@ export async function sendFleetWrite(payload, deps = {}) {
8421022 const done = { ...base , run, status : 204 , verdict : 'ok' , dispatchedAt } ;
8431023 if ( ! ok ) return { ...done , state : 'failure' , ok : false , detail : `conclusion ${ run . conclusion } ` } ;
8441024
1025+ // ── the run's annotations — the numbers it created or moved, where a seat CAN read them (header) ─────
1026+ const annotations = await runAnnotationsFor ( payload , run , { api, fetch : deps . fetch , token : deps . token , log } ) ;
1027+
8451028 // ── read it back — the run's success is the executor's, not the write's ─────
846- const readBack = await readBackStroke ( payload , { dispatchedAt } , { api, fetch : deps . fetch , token : deps . token , judge : deps . judge , sleep, log } ) ;
1029+ const readBack = await readBackStroke ( payload , { dispatchedAt, annotations : annotations . rows } , { api, fetch : deps . fetch , token : deps . token , judge : deps . judge , sleep, log } ) ;
8471030 for ( const row of readBack . rows ) log ( readBackLine ( row ) ) ;
8481031 if ( readBack . state === 'not-stored' ) {
8491032 const first = readBack . rows . find ( ( r ) => r . verdict === 'not-stored' ) ;
850- return { ...done , state : 'failure' , ok : false , notStored : true , readBack, detail : `conclusion success, but NOT STORED — action ${ first . action } (${ first . op } ${ first . where } ) first differs from the bytes sent at byte ${ first . offset } ` } ;
1033+ return { ...done , state : 'failure' , ok : false , notStored : true , readBack, annotations , detail : `conclusion success, but NOT STORED — action ${ first . action } (${ first . op } ${ first . where } ) first differs from the bytes sent at byte ${ first . offset } ` } ;
8511034 }
8521035 if ( readBack . state === 'unverified' ) {
8531036 const first = readBack . rows . find ( ( r ) => r . verdict === 'unverified' ) ;
854- return { ...done , state : 'unverified' , ok : false , readBack, detail : `conclusion success, but UNVERIFIED — action ${ first . action } (${ first . op } ): ${ first . why ?? first . cls } ` } ;
1037+ return { ...done , state : 'unverified' , ok : false , readBack, annotations , detail : `conclusion success, but UNVERIFIED — action ${ first . action } (${ first . op } ): ${ first . why ?? first . cls } ` } ;
8551038 }
856- return { ...done , state : 'success' , ok : true , readBack, detail : `conclusion ${ run . conclusion } ` } ;
1039+ return { ...done , state : 'success' , ok : true , readBack, annotations , detail : `conclusion ${ run . conclusion } ` } ;
8571040}
8581041
8591042/** The exit a tool takes from a result that is not `success`. */
@@ -1778,7 +1961,8 @@ export async function main(argv, deps = {}) {
17781961 not_stored : result . notStored === true ,
17791962 run : result . run ,
17801963 dispatched_at : new Date ( result . dispatchedAt ) . toISOString ( ) ,
1781- read_back : ( result . readBack ?. rows ?? [ ] ) . map ( ( r ) => ( { action : r . action , op : r . op , where : r . where , verdict : r . verdict , class : r . cls , first_difference_byte : r . offset ?? null , sent_bytes : r . sentBytes ?? null , stored_bytes : r . storedBytes ?? null } ) ) ,
1964+ read_back : ( result . readBack ?. rows ?? [ ] ) . map ( ( r ) => ( { action : r . action , op : r . op , where : r . where , verdict : r . verdict , class : r . cls , first_difference_byte : r . offset ?? null , sent_bytes : r . sentBytes ?? null , stored_bytes : r . storedBytes ?? null , found_by : r . foundBy ?? null } ) ) ,
1965+ annotations : ( result . annotations ?. rows ?? [ ] ) . map ( ( a ) => ( { action : a . action , op : a . op , number : a . number , url : a . url } ) ) ,
17821966 } ) ,
17831967 ) ;
17841968 }
0 commit comments