@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.
- package/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +501 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +677 -0
- package/dist/panel3/run.js +1988 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/show.js +996 -0
- package/dist/panel3/spawn.js +503 -0
- package/dist/panel3/tools.js +1601 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|