@ctrl-spc/cs 0.6.0 → 0.7.1

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