@ctrl-spc/cs 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,996 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: `cs3 show`, the whole record in a terminal. ═══
3
+ *
4
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
+ * import it.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * WHY THIS IS THE FIRST THING BUILT, BEFORE ANYTHING IT SHOWS EXISTS.
9
+ *
10
+ * v3 has no UI at all. Every later task is proved through this command, so if a
11
+ * fact cannot be seen here it cannot be said to work. That is also why the
12
+ * output is written for a person deciding whether the design holds, not for a
13
+ * parser: no ids without the thing they name, no counts without the rows.
14
+ *
15
+ * ---------------------------------------------------------------------------
16
+ * ═══ AN EMPTY RECORD AND A FAILED READ MUST NEVER LOOK ALIKE. ═══
17
+ *
18
+ * A read that comes back empty prints "No cards.". A read that FAILS throws, so
19
+ * `cs3` prints the reason on stderr and exits non-zero. `returned()` in
20
+ * `client.ts` is the one place that distinction is made, for every v3 command,
21
+ * and it also rejects the shape that would otherwise slip through: no error and
22
+ * no data, which is not "nothing is there" but "the client returned something
23
+ * this code does not understand".
24
+ *
25
+ * ---------------------------------------------------------------------------
26
+ * WHAT "LIVE" MEANS HERE, AND WHAT IT DOES NOT.
27
+ *
28
+ * Live is what the record says: state `running` and no end stamped. This
29
+ * command does not go looking for the process, and it must not pretend to —
30
+ * turning a run whose process is gone back into takeable work is Task 4's
31
+ * recovery, and a guess here would be a second, quieter answer to the same
32
+ * question.
33
+ */
34
+ // The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
35
+ // and takes the leading comment with it, so a file whose first statement is
36
+ // `import type` loses its v3 header in the published `dist/`.
37
+ import { at, out, returned, signedInClient } from './client.js';
38
+ import { coordination, elsewhereMismatch, listeningFleet, } from './presence.js';
39
+ import { designatedCoordinator } from './coordinator.js';
40
+ import { listCodebaseLocations, listCodebases } from '../codebases.js';
41
+ /** The columns of `panel3_asks` that carry content, plus the reference that says
42
+ * whether they are the ones to read. */
43
+ export const ASK_CONTENT_COLUMNS = 'decision_id, question, answer, category, context, answer_mode, options, selected_options, '
44
+ + 'answer_note';
45
+ /**
46
+ * The same rows, with a decision-backed question's words filled in from its
47
+ * decision.
48
+ *
49
+ * ONE EXTRA READ, AND ONLY WHEN THERE IS SOMETHING TO READ. `decision_id` is by
50
+ * value with no foreign key (conventions.md), so PostgREST cannot embed it and
51
+ * this is the join. A batch of escalations makes no second request at all.
52
+ *
53
+ * ═══ AND A DECISION THE PERSON CANNOT SEE IS LEFT EMPTY RATHER THAN INVENTED.
54
+ * ═══ The read goes through their own RLS, exactly as every other v3 read
55
+ * does, so a row that comes back without its decision keeps the nulls the ask
56
+ * row really holds instead of being given a placeholder that would read as the
57
+ * question.
58
+ */
59
+ export async function withAskContent(client, asks) {
60
+ const ids = [...new Set(asks.map((a) => a.decision_id).filter((id) => !!id))];
61
+ if (ids.length === 0)
62
+ return asks;
63
+ const decisions = await read(client.from('decisions')
64
+ .select('id, question, answer, category, context, answer_mode, options, selected_options, answer_note')
65
+ .in('id', ids), 'decisions');
66
+ /* THE CONTENT ONLY. The decision's own `id` is dropped here rather than
67
+ spread and overwritten: the ask row's id is what every caller answers,
68
+ shows and re-arms by, and merging the wrong one is the kind of mistake that
69
+ reads correctly right up until somebody uses it. */
70
+ const byId = new Map(decisions.map(({ id, ...content }) => [id, content]));
71
+ return asks.map((ask) => {
72
+ const content = ask.decision_id === null ? undefined : byId.get(ask.decision_id);
73
+ return content === undefined ? ask : { ...ask, ...content };
74
+ });
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // READS. Each one either returns rows or says why it could not, through the one
78
+ // guard in `client.ts` that every v3 command shares.
79
+ const read = (query, what) => returned(query, 'read', what);
80
+ const CARD_COLUMNS = 'id, project_id, title, state, created_at, archived_at';
81
+ const TURN_COLUMNS = 'id, card_id, role, body, created_at, addressed_at, run_id';
82
+ const RUN_COLUMNS = 'id, card_id, codebase_id, parent_run_id, level, state, brief, report, failed_because, activity, machine_id, '
83
+ + 'pid, started_at, resumed_at, read_back_at, ended_at';
84
+ const ASK_COLUMNS = `id, card_id, run_id, pending_run_id, answered_at, delivered_at, created_at, ${ASK_CONTENT_COLUMNS}`;
85
+ const OUTPUT_COLUMNS = 'id, card_id, run_id, kind, ref_id, label, created_at';
86
+ const ATTACHMENT_COLUMNS = 'id, card_id, kind, ref_id, label, created_at';
87
+ function groupByCard(list) {
88
+ const out = new Map();
89
+ for (const row of list) {
90
+ const existing = out.get(row.card_id);
91
+ if (existing)
92
+ existing.push(row);
93
+ else
94
+ out.set(row.card_id, [row]);
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * The four child tables for the given cards, in the order each is read on
100
+ * screen. One loader for both views, so the list and the detail can never
101
+ * disagree about what is on a card.
102
+ */
103
+ async function loadChildren(client, cardIds) {
104
+ const child = (table, columns, order) => read(client.from(table).select(columns).in('card_id', cardIds).order(order), table);
105
+ const [turns, runs, asks, outputs] = await Promise.all([
106
+ child('panel3_turns', TURN_COLUMNS, 'created_at'),
107
+ child('panel3_runs', RUN_COLUMNS, 'started_at'),
108
+ child('panel3_asks', ASK_COLUMNS, 'created_at'),
109
+ child('panel3_outputs', OUTPUT_COLUMNS, 'created_at'),
110
+ ]);
111
+ return {
112
+ turns: groupByCard(turns),
113
+ runs: groupByCard(runs),
114
+ // A question with the person keeps its words in its decision, so they are
115
+ // read before anything renders one. See `withAskContent`.
116
+ asks: groupByCard(await withAskContent(client, asks)),
117
+ outputs: groupByCard(outputs),
118
+ };
119
+ }
120
+ /**
121
+ * Project names for the cards that have one, read from the shared product table
122
+ * through the signed-in user's RLS — which AGENTS.md allows and this panel
123
+ * exists to surface. A project the user cannot see, or one that has been
124
+ * deleted, is simply absent from the map and is rendered as unknown rather than
125
+ * as none: they are different facts.
126
+ */
127
+ async function loadProjectNames(client, cards) {
128
+ const ids = [...new Set(cards.map((c) => c.project_id).filter((id) => !!id))];
129
+ if (ids.length === 0)
130
+ return new Map();
131
+ const projects = await read(client.from('projects').select('id, name').in('id', ids), 'projects');
132
+ return new Map(projects.map((p) => [p.id, p.name]));
133
+ }
134
+ /**
135
+ * ═══ A RECEIPT POINTS AT AN OBJECT, SO WHAT IS PRINTED IS THE OBJECT'S NAME
136
+ * NOW, NOT THE NAME IT WAS GIVEN. ═══
137
+ *
138
+ * ux.md: what a run made is recorded "as a receipt pointing at an object, never
139
+ * as prose describing one". `panel3_outputs.label` is that prose. It is written
140
+ * once, when the thing is made, and the coordinator may rename the thing on the
141
+ * very next turn. Printing the label then makes the only screen v3 has say the
142
+ * card produced something that does not exist under that name.
143
+ *
144
+ * IT IS NOT A HYPOTHETICAL AND IT IS NOT COSMETIC. One measured run created a
145
+ * work item, renamed it, and left a receipt reading "Onboarding checklist
146
+ * forgets itself on sign-out", which is the name of a DIFFERENT, real item that
147
+ * was already in progress. The card claimed a duplicate that had never been
148
+ * made.
149
+ *
150
+ * THE LABEL IS STILL WORTH KEEPING, for the two cases the ref cannot answer:
151
+ * `record_output` takes a kind this map does not know (a commit), and an object
152
+ * that has since been deleted has no name left to read. The second is said out
153
+ * loud rather than papered over with the old label, because a receipt pointing
154
+ * at nothing is exactly the sort of broken record this command exists to expose.
155
+ */
156
+ const NAMED_BY = {
157
+ epic: { table: 'epics', column: 'name' },
158
+ sprint: { table: 'sprints', column: 'name' },
159
+ work_item: { table: 'tasks', column: 'name' },
160
+ artifact: { table: 'artifacts', column: 'title' },
161
+ };
162
+ /** Keyed by kind and id together, because two product tables answering to the
163
+ * same id is a thing this map must not quietly resolve. */
164
+ export async function loadOutputNames(client, outputs) {
165
+ const found = new Map();
166
+ await Promise.all(Object.entries(NAMED_BY).map(async ([kind, source]) => {
167
+ const ids = [...new Set(outputs.filter((o) => o.kind === kind).map((o) => o.ref_id))];
168
+ if (ids.length === 0)
169
+ return;
170
+ const rows = await read(client.from(source.table).select(`id, ${source.column}`).in('id', ids), source.table);
171
+ // A row that is there but unnamed is FOUND, not missing: an artifact may
172
+ // carry a null title, and calling that gone would be the louder lie.
173
+ for (const row of rows)
174
+ found.set(`${kind}:${row.id}`, row[source.column] ?? '(untitled)');
175
+ }));
176
+ return found;
177
+ }
178
+ /**
179
+ * ═══ WHAT WAS ATTACHED TO ONE CARD. ═══
180
+ *
181
+ * One card at a time, unlike `loadChildren`'s batch of every card on the list
182
+ * view: `cs3 show` prints attachments only in the single-card view (the plan's
183
+ * own line: the list view already says one thing per card, and this is
184
+ * per-item detail), and level 1's prompt and a dispatched brief are both about
185
+ * one card's work, never many at once. A batched loader with exactly one
186
+ * caller would be the speculative structure the engineering guide forbids.
187
+ */
188
+ export async function loadAttachments(client, cardId) {
189
+ return read(client.from('panel3_attachments').select(ATTACHMENT_COLUMNS).eq('card_id', cardId)
190
+ .order('created_at'), 'panel3_attachments');
191
+ }
192
+ /**
193
+ * ═══ ONE LINE, EVERYWHERE AN ATTACHMENT IS NAMED. ═══
194
+ *
195
+ * `cs3 show`'s ATTACHMENTS section, level 1's prompt and a dispatched brief all
196
+ * name the same fact about the same row, and ux.md's own warning about the
197
+ * outbound rules applies here too: two copies of "how an attachment reads"
198
+ * would drift, silently, in whichever caller nobody re-read. So there is one
199
+ * function and every caller uses it, formatted plainly enough to read in a
200
+ * terminal and in a prompt alike.
201
+ */
202
+ export const attachmentLine = (a) => `${a.kind} ${a.label} id ${a.ref_id}`;
203
+ /**
204
+ * ═══ THE WHOLE ATTACHMENTS SECTION, EXACTLY AS `cs3 show` PRINTS IT, OR
205
+ * NOTHING AT ALL. ═══
206
+ *
207
+ * Every other section on a card prints its own header and a "none" line at
208
+ * zero, because zero is still an answer to "how many runs does this card
209
+ * have". Whether a card has anything attached is a different question: most
210
+ * never will, since Task 2 offers attaching only when a card is started, and a
211
+ * header over nothing on every card in the record would be noise on every one
212
+ * of them for a fact that is usually absent rather than usually zero.
213
+ *
214
+ * PURE AND EXPORTED for the same reason `nothingWillActOn` is: the "prints
215
+ * nothing when there is nothing" rule is a fact about this function's return
216
+ * value, and a test asserting it should not need a live client or a running
217
+ * `cs3 show` to see it.
218
+ */
219
+ export function attachmentsSection(attachments) {
220
+ if (attachments.length === 0)
221
+ return [];
222
+ return [`ATTACHMENTS ${attachments.length}`, ...attachments.map((a) => ` ${attachmentLine(a)}`)];
223
+ }
224
+ async function codebaseSection(client, card, attachments, runs) {
225
+ const selected = attachments.filter((attachment) => attachment.kind === 'codebase');
226
+ if (selected.length === 0)
227
+ return [];
228
+ const codebases = card.project_id ? await listCodebases(client, card.project_id) : [];
229
+ const locations = await listCodebaseLocations(client, codebases.map((codebase) => codebase.gitRemoteUrl));
230
+ const machineIds = [...new Set(runs.filter((run) => run.codebase_id).map((run) => run.machine_id))];
231
+ const machines = machineIds.length === 0
232
+ ? []
233
+ : await read(client.from('panel3_machines').select('machine_id, name').in('machine_id', machineIds), 'machines that worked on these codebases');
234
+ const duplicateNames = new Set(codebases
235
+ .filter((codebase, index, all) => all.some((other, otherIndex) => (index !== otherIndex && other.name === codebase.name)))
236
+ .map((codebase) => codebase.name));
237
+ return [
238
+ `CODEBASES ${selected.length}`,
239
+ ...selected.map((attachment) => {
240
+ const codebase = codebases.find((candidate) => candidate.id === attachment.ref_id);
241
+ if (!codebase)
242
+ return ` ${attachment.label} no longer registered on this project`;
243
+ const name = duplicateNames.has(codebase.name)
244
+ ? `${codebase.name} (${codebase.gitRemoteUrl})`
245
+ : codebase.name;
246
+ const runMachineIds = [...new Set(runs.filter((run) => run.codebase_id === codebase.id).map((run) => run.machine_id))];
247
+ if (runMachineIds.length === 0)
248
+ return ` ${name}`;
249
+ const availability = runMachineIds.map((machineId) => {
250
+ const machineName = machines.find((machine) => machine.machine_id === machineId)?.name ?? machineId;
251
+ const located = locations.some((location) => (location.machineId === machineId && location.gitRemoteUrl === codebase.gitRemoteUrl));
252
+ return `${located ? 'located on' : 'NOT LOCATED on'} ${machineName}`;
253
+ });
254
+ return ` ${name} ${availability.join(' · ')}`;
255
+ }),
256
+ ];
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // WHAT THE RECORD MEANS.
260
+ const isLive = (run) => run.state === 'running' && !run.ended_at;
261
+ /** The states a run is over in, whichever way it got there. `asked` is not one:
262
+ * that run is coming back. */
263
+ const ENDED = ['finished', 'failed'];
264
+ const isOpen = (ask) => !ask.answered_at;
265
+ const isUntaken = (turn) => turn.role === 'user' && !turn.addressed_at;
266
+ /** Waiting on the PERSON: it has run out of levels above it, so nothing else
267
+ * will settle it. This is what the needs-you state means, and it is read off
268
+ * the same row the re-arm reads. */
269
+ const isForYou = (ask) => !ask.answered_at && !ask.pending_run_id;
270
+ /**
271
+ * ═══ SOMEBODY STILL HAS TO BE STARTED FOR THIS. ═══
272
+ *
273
+ * Unanswered, or answered and not yet carried back to the run that asked. Both
274
+ * are work that is coming back, which is why neither may let a card read `done`
275
+ * — and why `panel3_answer` carries this same predicate. A question is finished
276
+ * with only once it has been both answered and delivered.
277
+ */
278
+ const isOutstanding = (ask) => !ask.answered_at || !ask.delivered_at;
279
+ /**
280
+ * ═══ A QUESTION THAT BELONGS TO WORK THE USER STOPPED. ═══
281
+ *
282
+ * `panel3_ask_was_stopped` restated, and the duplication is `isOwedAReadBack`'s:
283
+ * the rule lives in SQL, where the re-arm, both settlers and `panel3_answer_ask`
284
+ * share it, and this is the screen's copy over rows it has already read. The
285
+ * contract suite asks both about the same rows, because the last copy of a rule
286
+ * that was left behind said an agent was about to be started that never was.
287
+ *
288
+ * WHAT IT CHANGES HERE IS ONE SENTENCE, AND THE SENTENCE MATTERS: a stalled
289
+ * question normally has a move, and this is the one case where it has none.
290
+ * Printing `cs3 answer` at somebody whose answer would start nothing sends them
291
+ * to type a command that cannot do what it says.
292
+ */
293
+ const askWasStopped = (ask, runs) => {
294
+ const asker = runs.find((r) => r.id === ask.run_id);
295
+ if (!asker?.ended_at)
296
+ return false;
297
+ /* THE RUN THAT NEEDS THE ANSWER WAS REPLACED, so nothing will ever be started
298
+ for it. Keyed on that run alone, exactly as the SQL is: being superseded
299
+ says nothing about anybody else's question. */
300
+ if (asker.state === 'superseded')
301
+ return true;
302
+ return runs.some((s) => s.state === 'stopped' && s.ended_at && s.ended_at >= asker.ended_at);
303
+ };
304
+ /**
305
+ * ═══ A QUESTION HANDED TO AN AGENT THAT ENDED WITHOUT TOUCHING IT. ═══
306
+ *
307
+ * The one way the walk up can stop. A run is started once for a question it was
308
+ * handed — `delivered_at` is what makes that once rather than forever — so an
309
+ * agent that exits without answering it or passing it on leaves it where it is,
310
+ * with nothing that will ever pick it up again.
311
+ *
312
+ * IT IS FOUND BY THE SAME THREE FACTS THE RE-ARM USES, in the other direction:
313
+ * open, delivered, and the run it went to is not live. While that run IS live it
314
+ * is being dealt with, and before it was delivered the daemon will deal with it.
315
+ */
316
+ const isStalled = (ask, runs) => isOpen(ask) && !!ask.pending_run_id && !!ask.delivered_at
317
+ && !runs.some((r) => r.id === ask.pending_run_id && isLive(r));
318
+ /**
319
+ * ═══ A RUN THAT IS ABOUT TO BE STARTED AGAIN TO READ ITS WORKERS BACK. ═══
320
+ *
321
+ * ux.md's third re-arm, seen from the screen. It matters here for one reason:
322
+ * between the last worker ending and the next poll, a card can have nothing live
323
+ * on it, no turn waiting and no question outstanding, and still have an answer
324
+ * coming. Without this, the sentence below would call that card stranded — which
325
+ * is the forbidden state announced about a card that is perfectly healthy, and
326
+ * the one lie this function exists to prevent.
327
+ *
328
+ * ═══ IT IS `panel3_owed_read_back` RESTATED, AND THE DUPLICATION IS THE SAME ONE
329
+ * `isOutstanding` ALREADY CARRIES. ═══ The rule lives in SQL, where the take
330
+ * and the two settlers share it; this is the screen's copy of it, over rows it
331
+ * has already read. Its cost is that the two can drift, and the contract suite
332
+ * pins the SQL side, which is the one anything depends on. Reading it back per
333
+ * card would be a round trip per run for a sentence that is usually not printed.
334
+ *
335
+ * ═══ AND THEY DID DRIFT, WHICH IS WHY THIS IS NOW EXPORTED AND TESTED. ═══ The
336
+ * SQL learned about Stop and this did not, so on a stopped card the screen
337
+ * announced that a run was "waiting to be started again" that nothing would ever
338
+ * start, and suppressed the two notes that would have said the card was going
339
+ * nowhere. A comment saying the two agree is not a check.
340
+ */
341
+ export const isOwedAReadBack = (run, runs, asks) => {
342
+ /* ENDED, HOWEVER IT ENDED. A failed parent's workers carry on after it —
343
+ nothing cascades — so it is owed its read-back too, and leaving it out here
344
+ would have the screen call a card healthy that the database is about to
345
+ start work on. */
346
+ if (run.level <= 1 || !ENDED.includes(run.state) || !run.ended_at)
347
+ return false;
348
+ const sent = runs.filter((r) => r.parent_run_id === run.id);
349
+ if (sent.length === 0)
350
+ return false;
351
+ // A failed worker is done; a worker with a question outstanding is coming back.
352
+ const stillToCome = (child) => !child.ended_at || asks.some((a) => a.run_id === child.id && isOutstanding(a));
353
+ if (sent.some(stillToCome))
354
+ return false;
355
+ /* ═══ AND IT ENDED BEFORE THE USER STOPPED THIS CARD, SO IT BELONGS TO THE
356
+ WORK THEY STOPPED. ═══ `panel3_owed_read_back`'s own clause: nothing that had
357
+ ended when the stop landed is started again by it, whether the stop reached
358
+ its workers or something else on the card. Without this the screen says an
359
+ agent is about to be started to read back work the person killed. */
360
+ if (runs.some((s) => s.state === 'stopped' && s.ended_at && run.ended_at
361
+ && s.ended_at > run.ended_at))
362
+ return false;
363
+ /* ═══ AND IT HAS NOT ALREADY BEEN STARTED WITH THEM. ═══ Never started with
364
+ them at all is owed — which is the case a comparison against the run's own
365
+ end could not see, and the one that settled a card on a parent's
366
+ pre-dispatch text. Somebody finishing since is owed again. */
367
+ if (run.read_back_at === null)
368
+ return true;
369
+ return sent.some((child) => !!child.ended_at && child.ended_at > run.read_back_at);
370
+ };
371
+ /**
372
+ * ═══ THE QUESTIONS THAT NEED THE PERSON, WHATEVER ELSE IS TRUE OF THE CARD. ═══
373
+ *
374
+ * Split out of `nothingWillActOn` because it is the one part of that function
375
+ * that does not depend on a machine being up: a question whose agent has ended,
376
+ * and a question asked by work the user stopped, are both waiting on the person
377
+ * and stay waiting whether or not a daemon is polling. Everything else there is
378
+ * about what a machine would have done by now.
379
+ */
380
+ function notesAboutQuestions(asks, runs) {
381
+ const notes = [];
382
+ for (const ask of asks.filter((a) => isStalled(a, runs))) {
383
+ /* ═══ UNLESS ANSWERING IT WOULD DO NOTHING. ═══ A question asked by work the
384
+ user stopped is never delivered again, so `cs3 answer` settles the row and
385
+ starts nobody. Naming the command anyway would be this screen sending
386
+ somebody to type something that cannot do what it says, which is the same
387
+ failure as the command itself claiming an effect it did not have. */
388
+ if (askWasStopped(ask, runs)) {
389
+ notes.push(`question ${ask.id} was asked by work you stopped: answering it starts nothing, and `
390
+ + `nothing is waiting for it. Send a message instead: cs3 say --card ${ask.card_id} `
391
+ + '"<text>".');
392
+ continue;
393
+ }
394
+ /* ═══ AND THE MOVE IS NAMED, BECAUSE THERE IS ONE. ═══ A stalled question is
395
+ the one case where the person may answer something that was never put to
396
+ them: it went somewhere, that somewhere is gone, and nobody else is going
397
+ to. Saying only that nothing will act on it would leave them reading a
398
+ dead end that is not one. */
399
+ notes.push(`question ${ask.id} went to an agent that has ended without answering it or passing it on: `
400
+ + `nothing will act on it until you do, with cs3 answer ${ask.id} "<text>".`);
401
+ }
402
+ return notes;
403
+ }
404
+ /**
405
+ * ═══ THE `'elsewhere'` SENTENCE, NAMING WHICH HALF OF THE DESIGNATION IS
406
+ * ACTUALLY WRONG. ═══
407
+ *
408
+ * `elsewhereMismatch` (`presence.ts`) is the same comparison this sentence is
409
+ * built from, restated for the screen. "A machine is listening but it is not
410
+ * the one designated" is wrong and confusing when the designated MACHINE is
411
+ * exactly the one listening and only the harness differs — the point of this
412
+ * task — so each of the four shapes gets its own wording rather than one
413
+ * sentence covering all of them.
414
+ */
415
+ function elsewhereSentence(designation, fleet) {
416
+ const mismatch = elsewhereMismatch(designation, fleet);
417
+ if (mismatch === 'harness') {
418
+ return `the designated machine (${designation.machineId}) is listening, but running a `
419
+ + `different harness than ${designation.agent}, which is what is designated: nothing will `
420
+ + `act on this until it runs ${designation.agent}, or a different machine or harness is `
421
+ + 'designated to coordinate.';
422
+ }
423
+ if (mismatch === 'machine') {
424
+ return `${designation.agent} is listening, but on a different machine than ${designation.machineId}, `
425
+ + 'which is what is designated: nothing will act on this until the designated machine runs '
426
+ + 'it, or a different machine or harness is designated to coordinate.';
427
+ }
428
+ if (mismatch === 'split') {
429
+ /* ═══ TWO ROWS SUPPLY ONE HALF EACH, SO THE SENTENCE NAMES BOTH. ═══ Review
430
+ finding on this task: the fall-through used to print the generic "not
431
+ the one designated" line here, which is the LEAST specific of the four
432
+ sentences in exactly the case the product knows the MOST — the
433
+ designated machine's OWN row (running the wrong harness) and the
434
+ designated harness's OWN row (running on the wrong machine) are both
435
+ right there in `fleet`. */
436
+ const wrongHarness = fleet.find((m) => m.machine_id === designation.machineId);
437
+ const wrongMachine = fleet.find((m) => m.harness === designation.agent);
438
+ return `the designated machine (${designation.machineId}) is listening, but running `
439
+ + `${wrongHarness?.harness} instead of ${designation.agent}, while ${designation.agent} is `
440
+ + `listening on a different machine (${wrongMachine?.machine_id}): nothing will act on this `
441
+ + `until ${designation.machineId} runs ${designation.agent}, or a different machine or `
442
+ + 'harness is designated to coordinate.';
443
+ }
444
+ return 'a machine is listening, but it is not the one designated to coordinate: nothing will '
445
+ + 'act on this until the designated machine is back, or a different one is designated to '
446
+ + 'coordinate.';
447
+ }
448
+ /**
449
+ * ═══ ANYTHING THAT WILL NEVER BE ACTED ON, SAID PLAINLY. ═══
450
+ *
451
+ * ux.md's forbidden state: "No input may ever be in a state where nothing will
452
+ * act on it and nothing on screen says so." This command is the only screen v3
453
+ * has, so this function is that guarantee, and it is deliberately the ONLY
454
+ * place the judgement is made — the card list and the card detail both call it,
455
+ * so a card cannot look stranded in one view and healthy in the other.
456
+ *
457
+ * AN OPEN ASK IS NOT STRANDED. It is waiting for the user, which is a state the
458
+ * user can see and settle, and calling it abandoned would be the same lie in
459
+ * the other direction. The asks section shows it as open.
460
+ *
461
+ * It reads the record and nothing else. Whether a machine is online is a fact
462
+ * this command cannot establish honestly today: nothing writes a run yet, so
463
+ * "waiting for a machine" would be a sentence with no writer behind it. The
464
+ * task that spawns runs owns that distinction.
465
+ */
466
+ export function nothingWillActOn(card, turns, runs, asks,
467
+ /* ═══ WHETHER THE MACHINE AND HARNESS THAT WOULD ACT ON THIS CARD ARE
468
+ LISTENING. ═══ Every other sentence below is about rows, and rows cannot say
469
+ this: a card with an untaken turn is identical whether the pair that would
470
+ take it is two seconds away or nobody has one running. ux.md draws exactly
471
+ that line ("it must not say working, because nothing is working... the
472
+ distinction that matters is whether anything CAN pick it up"), so the one
473
+ fact the record does not hold is passed in — as `presence.ts`'s
474
+ `coordination()` gives it, not a plain boolean, because plan-coordinator
475
+ Task 4 split "anything is listening" into three cases a person needs told
476
+ apart: `'here'` (in hand, say nothing), `'elsewhere'` (a pair is designated
477
+ and it is not the one listening, though something else is), and `'nobody'`
478
+ (nothing at all is). */
479
+ status,
480
+ /* ═══ AND, ONLY FOR `'elsewhere'`, WHAT WAS DESIGNATED AND WHO ELSE IS
481
+ LISTENING. ═══ plan-coordinator-shared Task 2: the designation now names a
482
+ machine AND a harness, so "not the one designated" can be wrong in two
483
+ different ways, and the sentence below has to say which one when only one
484
+ of the two differs — `elsewhereMismatch` in `presence.ts` is the same
485
+ comparison, restated for the screen. Neither argument is read when `status`
486
+ is `'here'` or `'nobody'`. */
487
+ designation, fleet) {
488
+ const live = runs.filter(isLive);
489
+ const outstanding = asks.filter(isOutstanding);
490
+ const untaken = turns.filter(isUntaken);
491
+ const stalled = asks.filter((a) => isStalled(a, runs));
492
+ /* WORK THAT IS COMING BACK, EXACTLY AS AN OUTSTANDING QUESTION IS. The card
493
+ has nothing live on it and an answer is still on its way, so neither
494
+ sentence below may be printed about it. */
495
+ const reading = runs.filter((r) => isOwedAReadBack(r, runs, asks));
496
+ const notes = [];
497
+ /* ═══ THE CARD IS NOT IN HAND, WHICH OUTRANKS EVERY SENTENCE BELOW. ═══ The
498
+ two notes that follow read the card's rows and say nothing is acting on it;
499
+ both assume the machine that would act exists to have acted. With it not
500
+ listening, the fuller and simpler truth is the only one printed, because two
501
+ notes about one card read as two problems and the second would send
502
+ somebody looking at their turns for a fault that is not there.
503
+
504
+ It is said about a `working` card alone. `done` has nothing left to pick up,
505
+ `needs-you` is waiting on the person rather than on a machine, and `failed`
506
+ already says what happened. */
507
+ if (status !== 'here' && card.state === 'working') {
508
+ return [
509
+ status === 'nobody'
510
+ ? 'no machine is running `cs3 run`, so nothing can pick this up: it is waiting for a '
511
+ + 'machine, not being worked on. Start the CLI on a machine and it is taken from where '
512
+ + 'it is.'
513
+ /* ═══ `'elsewhere'`, WHICH USED TO READ AS `'nobody'` AND CLAIMED NO
514
+ MACHINE WAS ON WHEN THE PERSON COULD SEE ONE WAS. ═══ Task 4's own
515
+ drift: a designated machine that has gone quiet is not the same fact
516
+ as no machine at all, and the sentence has to say which one is true.
517
+ ═══ AND, SINCE plan-coordinator-shared Task 2, WHICH HALF OF THE PAIR
518
+ IS WRONG. ═══ "a machine is listening but it is not the one
519
+ designated" is accurate and confusing the moment the designated
520
+ MACHINE is exactly the one listening and only the harness differs, so
521
+ the three shapes `elsewhereMismatch` can return each get their own
522
+ sentence. `status === 'elsewhere'` only happens with a real
523
+ designation (see `coordination`), so `designation` is never null
524
+ here. */
525
+ : elsewhereSentence(designation, fleet),
526
+ ...notesAboutQuestions(asks, runs),
527
+ ];
528
+ }
529
+ if (card.state !== 'done' && untaken.length > 0 && live.length === 0
530
+ && outstanding.length === 0 && reading.length === 0) {
531
+ /* NOT WHEN THE CARD SAYS `done`, because that is a worse fact about the same
532
+ rows and it has its own sentence below. Two notes about one turn would
533
+ read as two problems. */
534
+ notes.push(`${count(untaken.length, 'user turn')} nobody has taken, and no run is live: `
535
+ + 'nothing is acting on this card.');
536
+ }
537
+ if (untaken.length === 0 && live.length === 0 && outstanding.length === 0 && reading.length === 0
538
+ && card.state === 'working') {
539
+ notes.push('the card says working, but no run is live, no turn is waiting to be taken and no question '
540
+ + 'is outstanding: nothing is working on it.');
541
+ }
542
+ if (card.state === 'needs-you' && asks.filter(isForYou).length === 0) {
543
+ notes.push('the card says needs-you, but no question is waiting on you: there is nothing for you to '
544
+ + 'answer.');
545
+ }
546
+ notes.push(...notesAboutQuestions(asks, runs));
547
+ if (card.state === 'done' && untaken.length > 0) {
548
+ /* ═══ SETTLED OVER SOMETHING THE PERSON SENT AFTERWARDS. ═══ `panel3_answer`
549
+ marks a card `done` on nothing live, nothing outstanding and nobody owed a
550
+ read-back — and NOT on the turns. A message typed while the run was
551
+ finishing is therefore unaddressed under a card that says the work is
552
+ over, which is exactly ux.md's forbidden state: what they sent is real,
553
+ durable and takeable, and the screen says the opposite.
554
+ IT IS A WINDOW AND NOT A DEAD END, so the sentence says which: the next
555
+ poll takes the turn and the card goes back to working. Naming it only
556
+ when it has lasted would mean the screen is honest except in the moment
557
+ somebody is most likely to be looking at it. */
558
+ notes.push(`the card says done while ${count(untaken.length, 'user turn')} `
559
+ + `${untaken.length === 1 ? 'is' : 'are'} still waiting to be taken: what you sent last has `
560
+ + 'not been answered, whatever the state says.');
561
+ }
562
+ /* AND `stopped` IS ONE OF THEM, because it is the newest way for the two to
563
+ disagree and the hardest to notice: a stop that missed a run leaves a live
564
+ agent under a card that says it was ended, and nothing else corrects a
565
+ stopped card. */
566
+ if ((card.state === 'done' || card.state === 'failed' || card.state === 'stopped')
567
+ && live.length > 0) {
568
+ notes.push(`the card says ${card.state} while ${count(live.length, 'run')} `
569
+ + `${live.length === 1 ? 'is' : 'are'} still live: the card and its runs disagree.`);
570
+ }
571
+ if (card.state === 'done' && reading.length > 0) {
572
+ /* THE SAME DISAGREEMENT ONE STEP EARLIER. `panel3_answer` refuses to settle a
573
+ card while somebody on it is owed a read-back, so this can only be printed
574
+ if the screen's copy of that rule and the database's have drifted — which
575
+ is exactly the drift worth being told about, since the answer the person
576
+ is about to get would land on a card they have stopped reading. */
577
+ notes.push(`the card says done while ${count(reading.length, 'run')} `
578
+ + `${reading.length === 1 ? 'has' : 'have'} still to read back what its workers found.`);
579
+ }
580
+ return notes;
581
+ }
582
+ // ---------------------------------------------------------------------------
583
+ // RENDERING. Plain text, aligned, and every id printed in full so it can be
584
+ // pasted straight back into `cs3 show`. `out` and `at` come from `client.ts`,
585
+ // so two commands cannot print the same instant two different ways.
586
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
587
+ /** A body of text under a heading, indented, with its blank lines kept. */
588
+ function block(text, indent) {
589
+ for (const line of text.split('\n'))
590
+ out(`${indent}${line}`);
591
+ }
592
+ function summarise(turns, runs, asks, outputs) {
593
+ const live = runs.filter(isLive).length;
594
+ const open = asks.filter(isOpen).length;
595
+ return [
596
+ count(turns.length, 'turn'),
597
+ `${count(runs.length, 'run')}${live ? ` (${live} live)` : ''}`,
598
+ `${count(asks.length, 'ask')}${open ? ` (${open} open)` : ''}`,
599
+ count(outputs.length, 'output'),
600
+ ].join(', ');
601
+ }
602
+ export function outputOf(output, names) {
603
+ const label = output.label ?? '(no label)';
604
+ if (!(output.kind in NAMED_BY))
605
+ return label;
606
+ return names.get(`${output.kind}:${output.ref_id}`)
607
+ ?? `${label} NO LONGER ON THE RECORD, or no longer yours to see`;
608
+ }
609
+ function projectOf(card, names) {
610
+ if (!card.project_id)
611
+ return 'no project yet';
612
+ return `${names.get(card.project_id) ?? 'unknown project'} (${card.project_id})`;
613
+ }
614
+ /**
615
+ * ═══ `working` IS A CLAIM ABOUT A MACHINE, SO IT IS NOT PRINTED WITHOUT ONE.
616
+ * ═══
617
+ *
618
+ * ux.md: "If no machine is running the CLI, the input is still accepted and
619
+ * durable... But it must not say working, because nothing is working. It says it
620
+ * is waiting for a machine."
621
+ *
622
+ * DERIVED HERE RATHER THAN STORED, which is what Task 1's migration already
623
+ * committed to in writing: "NOT ENUMERATED, DELIBERATELY: waiting for a machine
624
+ * ... it is DERIVED, not stored ... storing it would make it a second fact that
625
+ * drifts from the row that actually governs takeability". So the column keeps
626
+ * saying `working` and the screen says what that means right now.
627
+ */
628
+ export const displayState = (card, status) => card.state === 'working' && status !== 'here' ? 'waiting' : card.state;
629
+ /**
630
+ * ═══ WHICH MACHINE AND HARNESS COORDINATE, AND WHO ELSE IS LISTENING, EACH
631
+ * WITH ITS OWN HARNESS. ═══
632
+ *
633
+ * PURE AND EXPORTED, matching `attachmentsSection`: the fact is checkable
634
+ * without a database, and it is said once here rather than assembled twice at
635
+ * the two render sites that need it — `showAllCards`'s own MACHINES paragraph
636
+ * and `showCard`'s per-card summary — so the two can never print a different
637
+ * answer to "which machine and harness coordinate" than each other.
638
+ *
639
+ * `designation === null` is said out loud rather than left silent, matching
640
+ * the migration's own comment: unset is a state, not an absence. A fleet row
641
+ * is marked COORDINATOR only when BOTH halves match — a listening row that
642
+ * shares only the machine id or only the harness with the designation is not
643
+ * the coordinator, it is exactly the `elsewhere` case `nothingWillActOn` names.
644
+ */
645
+ export function machinesSection(designation, fleet) {
646
+ const lines = [
647
+ `coordinator ${designation ? `${designation.machineId} ${designation.agent}` : 'none designated, any machine may coordinate'}`,
648
+ fleet.length === 0
649
+ ? 'listening none, so nothing can be picked up until a machine runs `cs3 run`'
650
+ : `listening ${count(fleet.length, 'machine')}`,
651
+ ];
652
+ for (const m of fleet) {
653
+ const isCoordinator = designation !== null
654
+ && m.machine_id === designation.machineId && m.harness === designation.agent;
655
+ lines.push(` ${m.machine_id} ${m.name} ${m.harness}${isCoordinator ? ' COORDINATOR' : ''}`);
656
+ }
657
+ return lines;
658
+ }
659
+ function renderNotes(notes) {
660
+ if (notes.length === 0)
661
+ return;
662
+ out();
663
+ out('NOTHING WILL ACT ON THIS');
664
+ for (const note of notes)
665
+ out(` ${note}`);
666
+ }
667
+ // ---------------------------------------------------------------------------
668
+ // THE THREE VIEWS.
669
+ async function showAllCards(client) {
670
+ const cards = await read(client
671
+ .from('panel3_cards')
672
+ .select(CARD_COLUMNS)
673
+ .order('created_at', { ascending: false }), 'panel3_cards');
674
+ // The empty record, stated. Silence here would be indistinguishable from a
675
+ // command that did nothing at all, which is the confusion this whole file is
676
+ // built to avoid.
677
+ if (cards.length === 0) {
678
+ out('No cards.');
679
+ return;
680
+ }
681
+ const [children, names, designation, fleet] = await Promise.all([
682
+ loadChildren(client, cards.map((c) => c.id)),
683
+ loadProjectNames(client, cards),
684
+ designatedCoordinator(client),
685
+ listeningFleet(client),
686
+ ]);
687
+ const status = coordination(designation, fleet);
688
+ /* ═══ THE ANSWER TO "CAN ANYTHING PICK ANY OF THIS UP", ABOVE THE LIST THAT
689
+ DEPENDS ON IT. ═══ Every `waiting` below is this line's consequence, and a
690
+ reader who does not see it here would be left working out from a card why
691
+ the whole board is idle. */
692
+ out('MACHINES');
693
+ for (const line of machinesSection(designation, fleet))
694
+ out(` ${line}`);
695
+ out();
696
+ out(`CARDS ${cards.length}, newest first`);
697
+ for (const card of cards) {
698
+ const turns = children.turns.get(card.id) ?? [];
699
+ const runs = children.runs.get(card.id) ?? [];
700
+ const asks = children.asks.get(card.id) ?? [];
701
+ const outputs = children.outputs.get(card.id) ?? [];
702
+ out();
703
+ out(` ${displayState(card, status).padEnd(10)}${card.title}`);
704
+ out(` card ${card.id}`);
705
+ out(` project ${projectOf(card, names)}`);
706
+ out(` created ${at(card.created_at)}${card.archived_at ? ' ARCHIVED' : ''}`);
707
+ out(` ${summarise(turns, runs, asks, outputs)}`);
708
+ for (const note of nothingWillActOn(card, turns, runs, asks, status, designation, fleet)) {
709
+ out(` ! ${note}`);
710
+ }
711
+ }
712
+ out();
713
+ out(' cs3 show <card-id> for one card in full.');
714
+ }
715
+ async function showCard(client, card) {
716
+ const [children, names, designation, fleet, attachments] = await Promise.all([
717
+ loadChildren(client, [card.id]),
718
+ loadProjectNames(client, [card]),
719
+ designatedCoordinator(client),
720
+ listeningFleet(client),
721
+ loadAttachments(client, card.id),
722
+ ]);
723
+ const status = coordination(designation, fleet);
724
+ const turns = children.turns.get(card.id) ?? [];
725
+ const runs = children.runs.get(card.id) ?? [];
726
+ const asks = children.asks.get(card.id) ?? [];
727
+ const outputs = children.outputs.get(card.id) ?? [];
728
+ /* ═══ EVERY READ FINISHES BEFORE THE FIRST LINE IS PRINTED. ═══ Resolved down
729
+ beside the outputs it names, this read threw after `CARD`, `TURNS` and the
730
+ turns themselves were already on stdout, leaving half a card above a reason
731
+ on stderr. In the one command whose whole job is telling the truth about the
732
+ record, a torn render is the worst place for it: the part that printed looks
733
+ like the whole answer. So it joins the load block, and rendering starts only
734
+ once nothing is left that can fail. */
735
+ const outputNames = await loadOutputNames(client, outputs);
736
+ const codebaseLines = await codebaseSection(client, card, attachments, runs);
737
+ out(`CARD ${card.id}`);
738
+ out(` title ${card.title}`);
739
+ /* THE STATE THE PERSON IS OWED, which is not always the one stored: see
740
+ `displayState`. The stored word is still printed beside it, because this is
741
+ the command that exposes the record rather than dressing it. */
742
+ out(` state ${displayState(card, status)}${displayState(card, status) === card.state ? '' : ` (the row says ${card.state})`}`);
743
+ for (const line of machinesSection(designation, fleet))
744
+ out(` ${line}`);
745
+ out(` project ${projectOf(card, names)}`);
746
+ for (const line of codebaseLines)
747
+ out(` ${line}`);
748
+ out(` created ${at(card.created_at)}`);
749
+ if (card.archived_at)
750
+ out(` archived ${at(card.archived_at)}`);
751
+ out();
752
+ out(`TURNS ${turns.length}, in send order`);
753
+ if (turns.length === 0)
754
+ out(' none');
755
+ turns.forEach((turn, i) => {
756
+ /* ═══ ONLY A USER TURN IS EVER WAITING TO BE ADDRESSED. ═══ `addressed_at`
757
+ is the column the take reads, and the take only ever looks at the user's
758
+ words; an agent turn IS the answer, so printing NOT ADDRESSED beside one
759
+ said that the product's own reply was waiting for somebody. It appeared
760
+ the moment Task 4 wrote the first agent turn, on a card that was complete
761
+ and correct, which is exactly the kind of false alarm this command exists
762
+ not to raise. */
763
+ const addressed = turn.role !== 'user'
764
+ ? ''
765
+ : ` ${turn.addressed_at ? `addressed ${at(turn.addressed_at)}` : 'NOT ADDRESSED'}`;
766
+ /* WHICH LEVEL ANSWERED, read off the runs already loaded for this card
767
+ rather than fetched again. ux.md sends the answer back from whoever did
768
+ the work, so this line is where a reader sees that a code question came
769
+ back from level 2 and a question about the record came back from level 1.
770
+ A turn naming no run says so by saying nothing. */
771
+ const wrote = runs.find(run => run.id === turn.run_id);
772
+ const level = wrote ? ` L${wrote.level}` : '';
773
+ out(` ${String(i + 1).padStart(2)} ${turn.role.padEnd(5)}${level.padEnd(4)} ${at(turn.created_at)}${addressed}`);
774
+ block(turn.body, ' ');
775
+ });
776
+ out();
777
+ out(`RUNS ${runs.length}, nested under the run that dispatched them`);
778
+ if (runs.length === 0)
779
+ out(' none');
780
+ /* WHO IS ABOUT TO BE STARTED AGAIN TO READ ITS WORKERS BACK, worked out once
781
+ and handed over: the tree renderer has the runs and not the asks, and the
782
+ rule needs both. */
783
+ renderRunTree(runs, new Set(runs.filter((r) => isOwedAReadBack(r, runs, asks)).map((r) => r.id)));
784
+ out();
785
+ out(`ASKS ${asks.length}`);
786
+ if (asks.length === 0)
787
+ out(' none');
788
+ for (const ask of asks) {
789
+ /* ═══ WHOSE TURN IT IS, WHICH IS THE WHOLE OF HOW A QUESTION WALKS. ═══ The
790
+ same row says who needs the answer and who has to give it, and the second
791
+ one moves up a level at a time until it runs out, which is the person. */
792
+ out(` ${(isOpen(ask) ? 'OPEN' : 'answered').padEnd(8)} ${ask.id}`);
793
+ out(` asked ${at(ask.created_at)} by ${ask.run_id ?? 'a run no longer on the record'}`);
794
+ out(` with ${isOpen(ask)
795
+ ? (ask.pending_run_id ?? 'YOU')
796
+ : 'nobody, it is answered'}`);
797
+ out(' Q');
798
+ /* NULL ONLY WHEN THE DECISION IT IS COULD NOT BE READ, which `withAskContent`
799
+ leaves empty rather than filling in. Said as what it is: the row is there
800
+ and its words are not, which is a different fact from a blank question. */
801
+ block(ask.question ?? '(this question could not be read)', ' ');
802
+ if (ask.answered_at) {
803
+ out(` A ${at(ask.answered_at)}`);
804
+ block(ask.answer ?? '(answered with nothing)', ' ');
805
+ }
806
+ else {
807
+ out(' A not answered yet');
808
+ }
809
+ /* ═══ THE RE-ARM, ON THE ROW THAT GOVERNS IT. ═══ ux.md: the indicator and
810
+ the takeability are one fact. Undelivered says a run is still to be
811
+ started for this — for the answer, or to consider the question — which is
812
+ exactly what stops the card reading done. */
813
+ out(` carried ${ask.delivered_at
814
+ ? `${at(ask.delivered_at)}, so somebody has been started for it`
815
+ : 'not yet: a run is still to be started for this'}`);
816
+ }
817
+ out();
818
+ out(`OUTPUTS ${outputs.length}`);
819
+ if (outputs.length === 0)
820
+ out(' none');
821
+ for (const output of outputs) {
822
+ out(` ${output.kind} ${outputOf(output, outputNames)}`);
823
+ out(` ref ${output.ref_id}`);
824
+ out(` by ${output.run_id ?? 'a run no longer on the record'} at ${at(output.created_at)}`);
825
+ }
826
+ const attachmentLines = attachmentsSection(attachments);
827
+ if (attachmentLines.length > 0) {
828
+ out();
829
+ for (const line of attachmentLines)
830
+ out(line);
831
+ }
832
+ renderNotes(nothingWillActOn(card, turns, runs, asks, status, designation, fleet));
833
+ if (runs.length > 0) {
834
+ out();
835
+ out(' cs3 show <run-id> for a run\'s brief and report.');
836
+ }
837
+ }
838
+ /**
839
+ * The run tree, indented by dispatch rather than by level, because the question
840
+ * a reader has is who sent this agent, and the level is printed on the row.
841
+ *
842
+ * A run whose parent is not on this card is printed at the root WITH THAT SAID,
843
+ * rather than silently dropped: an orphan is exactly the kind of broken record
844
+ * this command exists to expose.
845
+ *
846
+ * ═══ AND SO IS A PARENT CYCLE, WHICH USED TO PRINT NOTHING AT ALL. ═══
847
+ *
848
+ * Two runs pointing at each other are neither roots nor anybody's rendered
849
+ * child, so the first version of this walked from the roots and left them out
850
+ * of the tree entirely: a `RUNS 3` header over two rows. The schema permits it
851
+ * — Task 1's migration has no self-reference or cycle check — and in the one
852
+ * command whose whole job is exposing a broken record, silently printing fewer
853
+ * rows than the count is the same failure as an empty array standing in for a
854
+ * failed read.
855
+ *
856
+ * So EVERY RUN IS RENDERED, and reachability is a fact about the record rather
857
+ * than a filter on the output: what the roots reach is printed first, then
858
+ * whatever is left over, at the root, saying why it is there. The `rendered`
859
+ * set is also what stops the cycle recursing forever.
860
+ */
861
+ function renderRunTree(runs, readingBack) {
862
+ const onCard = new Set(runs.map((r) => r.id));
863
+ const children = new Map();
864
+ const roots = [];
865
+ for (const run of runs) {
866
+ if (run.parent_run_id && onCard.has(run.parent_run_id)) {
867
+ const siblings = children.get(run.parent_run_id);
868
+ if (siblings)
869
+ siblings.push(run);
870
+ else
871
+ children.set(run.parent_run_id, [run]);
872
+ }
873
+ else {
874
+ roots.push(run);
875
+ }
876
+ }
877
+ const rendered = new Set();
878
+ /** `unreached` is true for the leftover pass: no chain of parents on this card
879
+ * gets from a root to this run, so the row says so instead of looking like an
880
+ * ordinary root. */
881
+ const render = (run, depth, unreached) => {
882
+ rendered.add(run.id);
883
+ const pad = ' '.repeat(depth + 1);
884
+ const liveness = isLive(run) ? 'LIVE' : run.state;
885
+ out(`${pad}L${run.level} ${liveness} ${run.id}`);
886
+ if (run.codebase_id)
887
+ out(`${pad} codebase ${run.codebase_id}`);
888
+ out(`${pad} machine ${run.machine_id}${run.pid ? ` pid ${run.pid}` : ''}`);
889
+ out(`${pad} started ${at(run.started_at)}`
890
+ + (run.ended_at ? ` ended ${at(run.ended_at)}` : ' not ended'));
891
+ /* ═══ STARTED AGAIN AS ITSELF, WHICH IS NOT VISIBLE ANYWHERE ELSE. ═══ Same
892
+ row, same id, same brief, so without this line a run that came back looks
893
+ exactly like one that ran once for a long time — and the difference is the
894
+ whole of whether the work survived.
895
+ WHY IT CAME BACK IS NOT SAID HERE, because the record does not hold it and
896
+ there are now three reasons: its machine went down, its question was
897
+ answered, or somebody it sent needs a decision. The asks below say which,
898
+ and inventing a reason on this line was measured saying "after its process
899
+ was found gone" under a run whose process had exited perfectly. */
900
+ if (run.resumed_at)
901
+ out(`${pad} resumed ${at(run.resumed_at)} started again as itself`);
902
+ /* ═══ WHAT IT SAID IT WAS DOING, AND ONLY WHILE IT COULD STILL BE DOING IT.
903
+ ═══ `activity` is a live line, so printing the last one under a run that
904
+ ended half an hour ago would read as something still in progress. On an
905
+ ended run the record's own answer is its state and its report. */
906
+ if (isLive(run) && run.activity)
907
+ out(`${pad} doing ${run.activity}`);
908
+ /* ═══ WHY THE PRODUCT ENDED IT, WHICH IS NOT THE RUN'S OWN WORDS. ═══ It
909
+ lives in its own column precisely so it cannot overwrite the report a
910
+ respawn is handed, and it is printed here because a run reading `failed`
911
+ with no reason anywhere is the record refusing to say what happened. */
912
+ if (run.failed_because)
913
+ out(`${pad} failed ${run.failed_because}`);
914
+ /* ═══ ENDED, AND STILL COMING BACK. ═══ Between the last worker finishing
915
+ and the next poll this run reads `finished` with nothing live anywhere on
916
+ the card, and an answer is on its way. Without this line the only honest
917
+ reading of that screen is that the work stopped. */
918
+ if (readingBack.has(run.id)) {
919
+ out(`${pad} waiting to be started again, to read back what the ${count((children.get(run.id) ?? []).length, 'agent')} it sent found`);
920
+ }
921
+ if (!run.parent_run_id)
922
+ out(`${pad} parent none, dispatched by the daemon`);
923
+ else if (!onCard.has(run.parent_run_id)) {
924
+ out(`${pad} parent ${run.parent_run_id}, WHICH IS NOT ON THIS CARD`);
925
+ }
926
+ else if (unreached) {
927
+ out(`${pad} parent ${run.parent_run_id}, IN A CYCLE: no chain of parents reaches a root`);
928
+ }
929
+ for (const child of children.get(run.id) ?? []) {
930
+ if (rendered.has(child.id)) {
931
+ out(`${pad} ${child.id} ALREADY SHOWN ABOVE: the parent chain loops back to it`);
932
+ }
933
+ else
934
+ render(child, depth + 1, unreached);
935
+ }
936
+ };
937
+ // What the roots reach, first, which is the tree as it is meant to be.
938
+ for (const root of roots)
939
+ render(root, 0, false);
940
+ // Then everything the roots could not reach, at the root, said out loud.
941
+ for (const run of runs)
942
+ if (!rendered.has(run.id))
943
+ render(run, 0, true);
944
+ }
945
+ /** A named run, which is the only place a brief and a report are printed in
946
+ * full: putting them in the card view would bury the shape of the tree under
947
+ * the text of every prompt. */
948
+ async function showRun(client, run) {
949
+ const cards = await read(client.from('panel3_cards').select(CARD_COLUMNS).eq('id', run.card_id), 'panel3_cards');
950
+ const card = cards[0];
951
+ out(`RUN ${run.id}`);
952
+ out(` card ${run.card_id}${card ? ` ${card.title}` : ' (card not readable)'}`);
953
+ out(` level ${run.level}`);
954
+ out(` state ${run.state}${isLive(run) ? ' (live)' : ''}`);
955
+ out(` parent ${run.parent_run_id ?? 'none, dispatched by the daemon'}`);
956
+ out(` machine ${run.machine_id}${run.pid ? ` pid ${run.pid}` : ' no pid recorded'}`);
957
+ out(` started ${at(run.started_at)}`);
958
+ if (run.resumed_at)
959
+ out(` resumed ${at(run.resumed_at)} started again as itself`);
960
+ out(` ended ${run.ended_at ? at(run.ended_at) : 'not ended'}`);
961
+ if (isLive(run) && run.activity)
962
+ out(` doing ${run.activity}`);
963
+ if (run.failed_because)
964
+ out(` failed ${run.failed_because}`);
965
+ out();
966
+ out('BRIEF what it was sent to do, written once at dispatch');
967
+ block(run.brief, ' ');
968
+ out();
969
+ out('REPORT what it knows now, rewritten by the run');
970
+ // Null and empty are different facts about a run and are said differently.
971
+ if (run.report === null)
972
+ out(' nothing reported yet');
973
+ else if (run.report.trim() === '')
974
+ out(' reported an empty report');
975
+ else
976
+ block(run.report, ' ');
977
+ }
978
+ // ---------------------------------------------------------------------------
979
+ /**
980
+ * `cs3 show` with no id lists every card. With an id it shows that card in
981
+ * full, or that run with its brief and report — one argument, resolved against
982
+ * the record, because a `--run` flag would make the caller say which kind of id
983
+ * they are holding when the record already knows.
984
+ */
985
+ export async function show(id) {
986
+ const client = await signedInClient();
987
+ if (!id)
988
+ return showAllCards(client);
989
+ const cards = await read(client.from('panel3_cards').select(CARD_COLUMNS).eq('id', id), 'panel3_cards');
990
+ if (cards[0])
991
+ return showCard(client, cards[0]);
992
+ const runs = await read(client.from('panel3_runs').select(RUN_COLUMNS).eq('id', id), 'panel3_runs');
993
+ if (runs[0])
994
+ return showRun(client, runs[0]);
995
+ throw new Error(`no card and no run with id ${id}`);
996
+ }