@itookit/dsht 0.3.8 → 0.5.2

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.
Files changed (166) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +33 -12
  3. package/README.zh.md +35 -14
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +206 -18
  8. package/dist/cli/startup.d.ts +40 -0
  9. package/dist/cli/startup.js +314 -0
  10. package/dist/cli/trace-summary.d.ts +78 -0
  11. package/dist/cli/trace-summary.js +241 -0
  12. package/dist/cli/verifier.d.ts +64 -0
  13. package/dist/cli/verifier.js +265 -0
  14. package/dist/contracts.d.ts +359 -0
  15. package/dist/contracts.js +1 -0
  16. package/dist/controller/commands.d.ts +47 -0
  17. package/dist/controller/commands.js +322 -0
  18. package/dist/controller/connection-streams.d.ts +22 -0
  19. package/dist/controller/connection-streams.js +105 -0
  20. package/dist/controller/connection.d.ts +24 -31
  21. package/dist/controller/connection.js +48 -111
  22. package/dist/controller/controller.d.ts +412 -178
  23. package/dist/controller/controller.js +713 -167
  24. package/dist/controller/foreground.d.ts +44 -0
  25. package/dist/controller/foreground.js +79 -0
  26. package/dist/controller/index.d.ts +8 -1
  27. package/dist/controller/index.js +5 -0
  28. package/dist/controller/loop-contract.d.ts +136 -0
  29. package/dist/controller/loop-contract.js +308 -0
  30. package/dist/controller/loop-coordinator.d.ts +48 -0
  31. package/dist/controller/loop-coordinator.js +647 -0
  32. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  33. package/dist/controller/loop-prompts-schema.js +144 -0
  34. package/dist/controller/loop-prompts.d.ts +55 -0
  35. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  36. package/dist/controller/loop-prompts.generated.js +185 -0
  37. package/dist/controller/loop-prompts.js +104 -0
  38. package/dist/controller/loop-protocols.d.ts +39 -0
  39. package/dist/controller/loop-protocols.js +115 -0
  40. package/dist/controller/loop.d.ts +275 -0
  41. package/dist/controller/loop.js +378 -0
  42. package/dist/controller/prompts.d.ts +54 -0
  43. package/dist/controller/prompts.js +162 -0
  44. package/dist/controller/trace-log.d.ts +45 -0
  45. package/dist/controller/trace-log.js +144 -0
  46. package/dist/controller/verifier.d.ts +130 -0
  47. package/dist/controller/verifier.js +75 -0
  48. package/dist/cost/controller.d.ts +1 -1
  49. package/dist/cost/controller.js +12 -5
  50. package/dist/cost/index.d.ts +1 -1
  51. package/dist/cost/index.js +1 -1
  52. package/dist/cost/ledger.d.ts +0 -1
  53. package/dist/cost/ledger.js +0 -1
  54. package/dist/cost/scanner.js +1 -0
  55. package/dist/json.d.ts +18 -0
  56. package/dist/json.js +19 -0
  57. package/dist/references.d.ts +25 -0
  58. package/dist/references.js +26 -0
  59. package/dist/session/connection-view.d.ts +2 -11
  60. package/dist/session/controller.d.ts +94 -105
  61. package/dist/session/controller.js +262 -536
  62. package/dist/session/history-reader.d.ts +32 -0
  63. package/dist/session/history-reader.js +170 -0
  64. package/dist/session/history.d.ts +6 -18
  65. package/dist/session/history.js +1 -24
  66. package/dist/session/index.d.ts +9 -4
  67. package/dist/session/index.js +7 -3
  68. package/dist/session/info.d.ts +20 -82
  69. package/dist/session/info.js +52 -25
  70. package/dist/session/interactions.d.ts +26 -0
  71. package/dist/session/interactions.js +75 -0
  72. package/dist/session/markdown.js +1 -1
  73. package/dist/session/math.js +1 -1
  74. package/dist/session/mutation-gate.d.ts +51 -0
  75. package/dist/session/mutation-gate.js +73 -0
  76. package/dist/session/navigation.d.ts +2 -89
  77. package/dist/session/navigation.js +2 -129
  78. package/dist/session/navigator.d.ts +47 -0
  79. package/dist/session/navigator.js +158 -0
  80. package/dist/session/peek.d.ts +38 -0
  81. package/dist/session/peek.js +103 -0
  82. package/dist/session/prompt-backfill.d.ts +23 -0
  83. package/dist/session/prompt-backfill.js +88 -0
  84. package/dist/session/references.d.ts +2 -20
  85. package/dist/session/references.js +1 -26
  86. package/dist/session/runtime.d.ts +26 -0
  87. package/dist/session/runtime.js +28 -0
  88. package/dist/session/state.d.ts +20 -0
  89. package/dist/session/state.js +1 -0
  90. package/dist/session/telemetry.d.ts +25 -17
  91. package/dist/session/telemetry.js +66 -60
  92. package/dist/session/transcript.d.ts +5 -7
  93. package/dist/session/transcript.js +2 -15
  94. package/dist/session/types.d.ts +25 -0
  95. package/dist/session/types.js +0 -1
  96. package/dist/session-title.d.ts +9 -0
  97. package/dist/session-title.js +21 -0
  98. package/dist/shell/controller.d.ts +31 -1
  99. package/dist/shell/controller.js +34 -2
  100. package/dist/shell/index.d.ts +3 -3
  101. package/dist/shell/index.js +2 -2
  102. package/dist/shell/runner.d.ts +10 -0
  103. package/dist/shell/runner.js +48 -9
  104. package/dist/slash/index.d.ts +10 -0
  105. package/dist/slash/index.js +7 -0
  106. package/dist/slash/parse.d.ts +42 -0
  107. package/dist/slash/parse.js +259 -0
  108. package/dist/slash/pipeline.d.ts +140 -0
  109. package/dist/slash/pipeline.js +115 -0
  110. package/dist/slash/registry.d.ts +88 -0
  111. package/dist/slash/registry.js +177 -0
  112. package/dist/slash/types.d.ts +126 -0
  113. package/dist/slash/types.js +1 -0
  114. package/dist/state.d.ts +16 -18
  115. package/dist/state.js +4 -3
  116. package/dist/text.d.ts +28 -0
  117. package/dist/text.js +55 -0
  118. package/dist/transport/client.d.ts +4 -3
  119. package/dist/transport/client.js +71 -25
  120. package/dist/transport/events.d.ts +104 -0
  121. package/dist/transport/events.js +149 -0
  122. package/dist/transport/wire.d.ts +9 -17
  123. package/dist/transport/wire.js +2 -27
  124. package/dist/ui/app.js +750 -550
  125. package/dist/ui/chat/header.js +1 -1
  126. package/dist/ui/chat/history-view.d.ts +1 -1
  127. package/dist/ui/chat/loop-status.d.ts +11 -0
  128. package/dist/ui/chat/loop-status.js +28 -0
  129. package/dist/ui/chat/navigation-model.d.ts +86 -0
  130. package/dist/ui/chat/navigation-model.js +107 -0
  131. package/dist/ui/chat/shell-view.d.ts +17 -2
  132. package/dist/ui/chat/shell-view.js +45 -3
  133. package/dist/ui/chat/status.d.ts +47 -3
  134. package/dist/ui/chat/status.js +65 -50
  135. package/dist/ui/chat/use-history-view.d.ts +69 -0
  136. package/dist/ui/chat/use-history-view.js +123 -0
  137. package/dist/ui/chat/viewport.d.ts +1 -1
  138. package/dist/ui/dialogs/cost.d.ts +21 -4
  139. package/dist/ui/dialogs/cost.js +7 -12
  140. package/dist/ui/dialogs/index.d.ts +22 -5
  141. package/dist/ui/dialogs/index.js +19 -3
  142. package/dist/ui/dialogs/loop.d.ts +43 -0
  143. package/dist/ui/dialogs/loop.js +224 -0
  144. package/dist/ui/dialogs/peek.d.ts +25 -0
  145. package/dist/ui/dialogs/peek.js +35 -0
  146. package/dist/ui/dialogs/picker.d.ts +2 -0
  147. package/dist/ui/dialogs/picker.js +4 -2
  148. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  149. package/dist/ui/dialogs/use-panels.js +51 -0
  150. package/dist/ui/input/mouse.d.ts +12 -2
  151. package/dist/ui/input/mouse.js +20 -7
  152. package/dist/ui/input/references.d.ts +1 -1
  153. package/dist/ui/input/use-composer.d.ts +35 -0
  154. package/dist/ui/input/use-composer.js +109 -0
  155. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  156. package/dist/ui/input/use-deferred-lines.js +54 -0
  157. package/dist/ui/input/use-history-recall.d.ts +20 -0
  158. package/dist/ui/input/use-history-recall.js +47 -0
  159. package/dist/ui/status/model.d.ts +7 -0
  160. package/dist/ui/status/model.js +5 -0
  161. package/dist/ui/theme/index.d.ts +1 -1
  162. package/package.json +6 -4
  163. package/dist/ui/commands/parse.d.ts +0 -104
  164. package/dist/ui/commands/parse.js +0 -135
  165. package/dist/ui/commands/registry.d.ts +0 -33
  166. package/dist/ui/commands/registry.js +0 -73
@@ -0,0 +1,314 @@
1
+ /** Startup automation: pick a workspace and a session, then run the requested slash lines.
2
+ *
3
+ * This is the scriptable half of the client — `dsht --ws X --session new --command "…"` — so a
4
+ * review can be launched without typing. It drives the same controller the UI drives, and reads only
5
+ * the outcome of each line: the presentational effects of a command belong to the UI.
6
+ */
7
+ import { runCommand } from "../controller/index.js";
8
+ import { errorText } from "../transport/wire.js";
9
+ import { dirname } from 'node:path';
10
+ import { setTimeout as delay } from 'node:timers/promises';
11
+ import { ensureDirectory, renameFile, writePrivateFile } from "../storage/index.js";
12
+ import { latestAssistantText } from "../controller/loop.js";
13
+ import { parseVerdict } from "../controller/loop-contract.js";
14
+ import { needsHumanLine } from "../controller/verifier.js";
15
+ import { authorize, normalize } from "../slash/index.js";
16
+ const POLL_MS = 250;
17
+ /** How long a connection, session or workspace operation may take. */
18
+ const STEP_TIMEOUT_MS = 30_000;
19
+ /** How long a forked child may wait for the session it was pointed at to stream its snapshot. */
20
+ const PROMPT_SNAPSHOT_TIMEOUT_MS = 180_000;
21
+ /** How long the verdict may take to appear after the turn was reported idle. */
22
+ const VERDICT_GRACE_MS = 5_000;
23
+ /** How often the reply is re-read while that grace lasts. */
24
+ const VERDICT_POLL_MS = 200;
25
+ /** Wait until one condition holds.
26
+ * @param condition - Predicate polled until true.
27
+ * @param what - Human-readable subject for the timeout message.
28
+ * @param timeoutMs - Longest wait.
29
+ */
30
+ async function until(signal, condition, what, timeoutMs = STEP_TIMEOUT_MS) {
31
+ const deadline = Date.now() + timeoutMs;
32
+ for (;;) {
33
+ signal.throwIfAborted();
34
+ if (condition())
35
+ return;
36
+ if (Date.now() >= deadline)
37
+ throw new Error(`Timed out waiting for ${what}`);
38
+ await delay(POLL_MS, undefined, { signal });
39
+ }
40
+ }
41
+ /** Run the plan against a started controller.
42
+ * @param controller - Connected application facade.
43
+ * @param plan - Workspace, session and lines to run.
44
+ * @param log - Progress sink, usually stderr in headless mode.
45
+ * @returns Whether a started loop passed, nothing ran, or the run failed.
46
+ */
47
+ export async function runStartup(controller, plan, log) {
48
+ const signal = controller.connection.signal();
49
+ // `online` alone is too early: the startup picker runs after it and would overwrite a selection
50
+ // made here, which is exactly how a forked verifier used to lose its session.
51
+ await until(signal, () => controller.state.online && controller.queries.connectionSettled, 'the host connection');
52
+ if (plan.workspace !== undefined && !await controller.actions.switchWorkspace(plan.workspace)) {
53
+ throw new Error(`Workspace not found: ${plan.workspace}`);
54
+ }
55
+ // An explicit `new`, or automation with no session named, starts a fresh conversation; the
56
+ // workspace defaults to the directory this client runs in, registered on demand.
57
+ if (plan.session === 'new' || plan.session === undefined && plan.commands.length > 0) {
58
+ await ensureWorkspace(controller, log);
59
+ if (!await controller.actions.createSession())
60
+ throw new Error('Could not create a session');
61
+ }
62
+ else if (plan.session !== undefined) {
63
+ if (!await controller.actions.selectSession(plan.session))
64
+ throw new Error(`Session not found: ${plan.session}`);
65
+ }
66
+ // A selected session is not sendable until its follow snapshot lands.
67
+ if (plan.commands.length) {
68
+ await until(signal, () => controller.state.screen === 'chat' && controller.state.sessionId !== undefined && controller.queries.record.ready, 'the session snapshot');
69
+ }
70
+ // Start-up lines are one-shot action commands; the cancellable port is only used by searches
71
+ // and exports, which have no meaning before the operator is present.
72
+ const port = { run: (_label, operation) => operation(controller.connection.signal()) };
73
+ for (const line of plan.commands) {
74
+ signal.throwIfAborted();
75
+ // The scripted half runs the same two stages the composer does after `interpret`: a headless
76
+ // caller has no menus or screens, but it has every application fact `normalize`/`authorize` read.
77
+ const command = normalize({ kind: 'line', line }, {
78
+ sessionSelected: controller.state.sessionId !== undefined,
79
+ question: controller.state.pending[0]?.kind === 'question',
80
+ pending: controller.state.pending.length > 0,
81
+ });
82
+ if (command.kind === 'error')
83
+ throw new Error(command.message);
84
+ const verdict = await authorizeWhenReady(controller, command);
85
+ if (!verdict.allow)
86
+ throw new Error(verdict.error.message);
87
+ const result = await runCommand(controller, verdict.command, port);
88
+ if (result === undefined) {
89
+ const reason = controller.state.lastFailure;
90
+ throw new Error(`Command was not accepted: ${line}${reason ? ` (${reason})` : ''}`);
91
+ }
92
+ if (result.outcome !== 'ok') {
93
+ const failure = result.effects.find(effect => effect.kind === 'error');
94
+ throw new Error(failure?.kind === 'error' ? failure.text : `Command failed (${result.outcome}): ${line}`);
95
+ }
96
+ if (result.disposition === 'retain')
97
+ throw new Error(`Command did not settle: ${line}`);
98
+ for (const effect of result.effects)
99
+ if (effect.kind === 'notice')
100
+ log(effect.text);
101
+ }
102
+ // Both counts are taken before the prompt, because a turn can start and finish between two polls.
103
+ const finishedBefore = controller.queries.turnsCompleted;
104
+ const repliesBefore = controller.queries.record.messages.length;
105
+ if (plan.prompt !== undefined) {
106
+ // Sending a prompt needs the follow snapshot, and a verifier starts the instant a busy turn ends,
107
+ // so this wait is longer than a normal startup step: the host can be slow to open the new stream.
108
+ try {
109
+ await until(signal, () => controller.state.sessionId !== undefined && controller.queries.record.ready, 'the verifier session snapshot', PROMPT_SNAPSHOT_TIMEOUT_MS);
110
+ }
111
+ catch (error) {
112
+ signal.throwIfAborted();
113
+ // Which of the three conditions failed is the whole diagnosis, so it travels with the error.
114
+ throw new Error(`${errorText(error)} (screen=${controller.state.screen}, session=${controller.state.sessionId ?? 'none'},`
115
+ + ` snapshot=${String(controller.queries.record.ready)}, online=${String(controller.state.online)})`);
116
+ }
117
+ if (!await controller.actions.prompt(plan.prompt)) {
118
+ signal.throwIfAborted();
119
+ const reason = controller.state.lastFailure;
120
+ throw new Error(`Prompt was not accepted${reason ? ` (${reason})` : ''}`);
121
+ }
122
+ }
123
+ if (controller.queries.loop !== undefined)
124
+ return await waitForLoop(controller, plan.timeoutSeconds * 1000, log);
125
+ if (plan.wait) {
126
+ const outcome = await waitForTurn(controller, finishedBefore, plan.timeoutSeconds * 1000, log);
127
+ if (outcome === 'idle' && plan.verdict !== undefined)
128
+ await writeVerdict(controller, plan.verdict, repliesBefore, log);
129
+ return outcome;
130
+ }
131
+ return 'idle';
132
+ }
133
+ /** Authorize one startup line, waiting out a fact the policy queues behind.
134
+ *
135
+ * There is no composer here to hold a line, and failing a scripted run because the client happened to
136
+ * be mid-turn would make `--command` unusable in exactly the automation it exists for; so the scripted
137
+ * caller waits for the same fact the UI queue waits for, bounded by the step timeout.
138
+ * @param controller - Connected facade whose facts decide.
139
+ * @param command - Normalized line to run.
140
+ * @returns The verdict once the line may run, or the refusal it will never outlive.
141
+ */
142
+ async function authorizeWhenReady(controller, command) {
143
+ const signal = controller.connection.signal();
144
+ const deadline = Date.now() + STEP_TIMEOUT_MS;
145
+ for (;;) {
146
+ signal.throwIfAborted();
147
+ const verdict = authorize(command, {
148
+ sessionSelected: controller.state.sessionId !== undefined,
149
+ pending: controller.state.pending.length > 0,
150
+ during: controller.queries.loop?.active === true ? 'loop' : controller.queries.running ? 'turn' : 'idle',
151
+ foreground: controller.queries.foreground !== undefined,
152
+ });
153
+ if (!verdict.allow || verdict.defer === undefined)
154
+ return verdict;
155
+ if (Date.now() >= deadline)
156
+ throw new Error(`Timed out waiting for the client to be free: ${command.kind}`);
157
+ await delay(POLL_MS, undefined, { signal });
158
+ }
159
+ }
160
+ /** Persist the verdict this session's reply just produced.
161
+ *
162
+ * The agent judges; this client owns the protocol file: the reply is parsed here and written through
163
+ * a temporary file that is renamed into place, so a reader never sees a half-written verdict.
164
+ * A reply with no usable verdict changes nothing — the parent validates whatever is there.
165
+ * @param controller - Connected facade whose transcript holds the reply.
166
+ * @param target - File to write and the identity the verdict must declare.
167
+ * @param repliesBefore - Messages on the transcript before the prompt was sent: only a reply that
168
+ * arrived after it can be this turn's, so an idle from an earlier or replayed turn cannot be read
169
+ * as the verdict.
170
+ * @param log - Progress sink.
171
+ */
172
+ async function writeVerdict(controller, target, repliesBefore, log) {
173
+ const signal = controller.connection.signal();
174
+ signal.throwIfAborted();
175
+ const [, kind = '', step = '', attempt = ''] = target.identity.split('/');
176
+ const expect = { verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt) };
177
+ // The host reports the turn idle just before that reply is committed, so an immediate parse can
178
+ // find nothing; the verdict is only missing once it has had time to arrive. Waiting on an
179
+ // assistant reply that did not exist when the prompt was sent is what ties the verdict to this
180
+ // prompt: the prompt itself only adds a user message, and an earlier turn's reply is behind the
181
+ // baseline.
182
+ const replyArrived = () => controller.queries.record.messages
183
+ .slice(repliesBefore).some(message => message.role === 'Assistant');
184
+ let parsed = replyArrived() ? parseVerdict(latestAssistantText(controller.queries.record.messages), expect) : undefined;
185
+ const deadline = Date.now() + VERDICT_GRACE_MS;
186
+ while (parsed === undefined && Date.now() < deadline) {
187
+ await delay(VERDICT_POLL_MS, undefined, { signal });
188
+ if (replyArrived())
189
+ parsed = parseVerdict(latestAssistantText(controller.queries.record.messages), expect);
190
+ }
191
+ if (parsed === undefined) {
192
+ // Say what actually happened: "no reply yet", "no JSON at all" and "JSON that is not this round"
193
+ // are different faults, and one line here is what tells them apart without another live run.
194
+ if (!replyArrived()) {
195
+ log('Turn ended but no reply was committed after the prompt; leaving the verdict file untouched');
196
+ return;
197
+ }
198
+ const tail = latestAssistantText(controller.queries.record.messages).slice(-240).replace(/\s+/g, ' ');
199
+ log(`No parsable verdict in the reply; leaving the verdict file untouched (reply tail: ${tail})`);
200
+ return;
201
+ }
202
+ const body = JSON.stringify({
203
+ verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt),
204
+ ...(parsed.score === undefined ? {} : { score: parsed.score }),
205
+ ...(parsed.status === undefined ? {} : { status: parsed.status }),
206
+ ...(parsed.blocked === true ? { blocked: true } : {}),
207
+ ...(parsed.evidence === undefined ? {} : { evidence: parsed.evidence }),
208
+ ...(parsed.findings === undefined ? {} : { top_findings: parsed.findings }),
209
+ });
210
+ await ensureDirectory(dirname(target.file));
211
+ const temporary = `${target.file}.part`;
212
+ await writePrivateFile(temporary, body);
213
+ await renameFile(temporary, target.file);
214
+ log(`Verdict written to ${target.file}`);
215
+ }
216
+ /** Follow a sent prompt's turn to its end, which is what lets a forked child exit by itself.
217
+ *
218
+ * The host's idle event is the same signal the scored loop trusts: it cannot be missed by polling,
219
+ * and a turn that starts and ends between two polls still counts.
220
+ * @param controller - Connected application facade.
221
+ * @param finishedBefore - Turns completed before the prompt was sent.
222
+ * @param timeoutMs - Longest wait.
223
+ * @param log - Progress sink.
224
+ * @returns `idle` when the turn ended, `failed` on timeout.
225
+ */
226
+ async function waitForTurn(controller, finishedBefore, timeoutMs, log) {
227
+ const signal = controller.connection.signal();
228
+ const deadline = Date.now() + timeoutMs;
229
+ for (;;) {
230
+ signal.throwIfAborted();
231
+ if (controller.queries.turnsCompleted > finishedBefore) {
232
+ log('Turn finished');
233
+ return 'idle';
234
+ }
235
+ // A headless verifier cannot answer an approval or a question: stop and say what is needed,
236
+ // instead of waiting for the deadline and being retried as an infrastructure failure.
237
+ const request = humanRequest(controller);
238
+ if (request !== undefined) {
239
+ log(needsHumanLine(request));
240
+ return 'needs-human';
241
+ }
242
+ if (Date.now() >= deadline) {
243
+ log('Turn timed out');
244
+ return 'failed';
245
+ }
246
+ await delay(POLL_MS, undefined, { signal });
247
+ }
248
+ }
249
+ /** The host interaction this client cannot answer itself, when one is pending.
250
+ * @param controller - Connected facade whose selected session is the verifier's session.
251
+ * @returns The request to report, or undefined when nothing is pending.
252
+ */
253
+ function humanRequest(controller) {
254
+ const [pending] = controller.state.pending;
255
+ if (pending === undefined)
256
+ return undefined;
257
+ if (pending.kind === 'approval') {
258
+ return { kind: 'approval', text: pending.description === '' ? 'a tool approval is required' : pending.description };
259
+ }
260
+ return { kind: 'question', text: pending.questions[0]?.question ?? 'a question was asked' };
261
+ }
262
+ /** Select the directory this client runs in, registering it when the host does not know it yet. */
263
+ async function ensureWorkspace(controller, log) {
264
+ if (controller.state.workspaceId !== undefined)
265
+ return;
266
+ if (await controller.actions.switchWorkspace(controller.localDirectory))
267
+ return;
268
+ log(`Registering ${controller.localDirectory} as a workspace`);
269
+ if (!await controller.actions.createWorkspace(controller.localDirectory)) {
270
+ throw new Error(`Could not use ${controller.localDirectory} as a workspace`);
271
+ }
272
+ }
273
+ /** Follow a running loop to its terminal phase, reporting progress changes.
274
+ * @returns `passed` when the loop met its threshold, `failed` otherwise.
275
+ */
276
+ async function waitForLoop(controller, timeoutMs, log) {
277
+ const signal = controller.connection.signal();
278
+ const deadline = Date.now() + timeoutMs;
279
+ let previous = '';
280
+ for (;;) {
281
+ signal.throwIfAborted();
282
+ const progress = controller.queries.loop;
283
+ if (progress === undefined || progress.phase !== 'running') {
284
+ // A cancelled loop usually means the connection ended; say so, or the exit code is a mystery.
285
+ const why = controller.state.lastFailure || controller.state.status;
286
+ const interaction = progress?.interaction;
287
+ // `passed` only claims the rounds that ran, so a headless reader is told which ones.
288
+ const scope = progress?.phase === 'passed' ? ` · ${progress.scope}` : '';
289
+ log(`Loop ${progress?.phase ?? 'gone'}${scope}${why ? ` · ${why}` : ''}`
290
+ + `${interaction === undefined ? '' : ` · ${interaction.kind}: ${interaction.text}`}`
291
+ + `${progress?.note === undefined ? '' : ` · ${progress.note}`}`);
292
+ if (progress?.phase === 'passed')
293
+ return 'passed';
294
+ // A run stopped on a host request is not a failure: it is waiting for a person, and phase 1
295
+ // deliberately has no in-process answer path.
296
+ return progress?.phase === 'needs-human' ? 'needs-human' : 'failed';
297
+ }
298
+ const label = progress.stepLabel === undefined ? '' : ` · ${progress.stepLabel}`;
299
+ // The note carries why an attempt was decided the way it was — including why verification was
300
+ // unavailable — so a headless run is diagnosable without a renderer.
301
+ const note = progress.note === undefined ? '' : ` · ${progress.note}`;
302
+ const line = `${progress.title}${label} · step ${progress.step}/${progress.to} · attempt ${progress.attempt}/${progress.tries} · best ${progress.best}/${progress.score}${note}`;
303
+ if (line !== previous) {
304
+ previous = line;
305
+ log(line);
306
+ }
307
+ if (Date.now() >= deadline) {
308
+ controller.actions.stopLoop();
309
+ log('Loop timed out and was stopped');
310
+ return 'failed';
311
+ }
312
+ await delay(POLL_MS, undefined, { signal });
313
+ }
314
+ }
@@ -0,0 +1,78 @@
1
+ /** Reading a trace back as a few lines: what ran, what was refused, and what never finished.
2
+ *
3
+ * The trace is written for a machine (`slash.md` §7) and a reader only ever asks a handful of
4
+ * questions of it: which commands ran and how they ended, which session writes were serialized, what
5
+ * owned the client and for how long, how the loops and verifications went, and whether any span is
6
+ * still open. This module answers exactly those, from the lines alone, so it can be tested without a
7
+ * file and reused by a future `--json` consumer.
8
+ *
9
+ * It never re-derives a fact the trace did not record: an event it does not know is counted as
10
+ * skipped rather than guessed at.
11
+ */
12
+ /** One loop run as the trace describes it. */
13
+ export interface TraceLoopRun {
14
+ readonly runId: string;
15
+ readonly kind: string;
16
+ /** Steps the run sent, counted from its `sent` events. */
17
+ readonly sent: number;
18
+ /** Phase the run ended in, when it reached one. */
19
+ readonly result?: string;
20
+ /** Why it ended, when the end recorded a reason. */
21
+ readonly reason?: string;
22
+ }
23
+ /** Everything `dsht trace` reports about one trace file. */
24
+ export interface TraceSummary {
25
+ readonly path: string;
26
+ /** Events read, and lines that were not events (unparsable or foreign). */
27
+ readonly events: number;
28
+ readonly skipped: number;
29
+ /** First and last event timestamps, when the trace has any. */
30
+ readonly from?: string;
31
+ readonly to?: string;
32
+ /** Submissions: how they ended and which command kinds they were. */
33
+ readonly commands: {
34
+ readonly total: number;
35
+ readonly outcomes: Record<string, number>;
36
+ readonly kinds: Record<string, number>;
37
+ };
38
+ /** Session writes: total, by lane and by target session. */
39
+ readonly mutations: {
40
+ readonly total: number;
41
+ readonly lanes: Record<string, number>;
42
+ readonly sessions: Record<string, number>;
43
+ };
44
+ /** Foreground slots: total, cancelled, the longest one, and any left open. */
45
+ readonly operations: {
46
+ readonly total: number;
47
+ readonly cancelled: number;
48
+ readonly longestMs?: number;
49
+ readonly longest?: string;
50
+ readonly open: number;
51
+ };
52
+ readonly loops: readonly TraceLoopRun[];
53
+ /** Verification tasks: how many started, and how they concluded.
54
+ *
55
+ * `begin` is not derivable from the outcomes — a task whose process died mid-flight has neither —
56
+ * so both are reported and the formatter points out the gap.
57
+ */
58
+ readonly verifiers: {
59
+ readonly begin: number;
60
+ readonly verified: number;
61
+ readonly unavailable: number;
62
+ readonly cancelled: number;
63
+ readonly byClass: Record<string, number>;
64
+ };
65
+ /** Spans that began and never ended, oldest first. */
66
+ readonly anomalies: readonly string[];
67
+ }
68
+ /** Summarize one trace file's lines.
69
+ * @param lines - Raw trace lines, oldest first, header and blanks included.
70
+ * @param path - File the lines came from, for the report.
71
+ * @returns The summary a reader wants.
72
+ */
73
+ export declare function summarizeTrace(lines: readonly string[], path?: string): TraceSummary;
74
+ /** Render a summary as the lines `dsht trace` prints.
75
+ * @param summary - Result of `summarizeTrace`.
76
+ * @returns Plain lines, one fact per line, without a trailing newline.
77
+ */
78
+ export declare function formatTraceSummary(summary: TraceSummary): string[];
@@ -0,0 +1,241 @@
1
+ /** Reading a trace back as a few lines: what ran, what was refused, and what never finished.
2
+ *
3
+ * The trace is written for a machine (`slash.md` §7) and a reader only ever asks a handful of
4
+ * questions of it: which commands ran and how they ended, which session writes were serialized, what
5
+ * owned the client and for how long, how the loops and verifications went, and whether any span is
6
+ * still open. This module answers exactly those, from the lines alone, so it can be tested without a
7
+ * file and reused by a future `--json` consumer.
8
+ *
9
+ * It never re-derives a fact the trace did not record: an event it does not know is counted as
10
+ * skipped rather than guessed at.
11
+ */
12
+ /** Count one key in a tally. */
13
+ function count(tally, key) {
14
+ tally[key] = (tally[key] ?? 0) + 1;
15
+ }
16
+ /** Name one loop run in a report; a pre-`runId` trace has no identity to show. */
17
+ function label(runId) { return runId === '' ? '<no-run-id>' : runId; }
18
+ /** Read the identifier a span pairs on, or undefined when the event carries none. */
19
+ function spanId(record) {
20
+ for (const field of ['commandId', 'id', 'runId']) {
21
+ const value = record[field];
22
+ if (typeof value === 'string' || typeof value === 'number')
23
+ return String(value);
24
+ }
25
+ return undefined;
26
+ }
27
+ /** Summarize one trace file's lines.
28
+ * @param lines - Raw trace lines, oldest first, header and blanks included.
29
+ * @param path - File the lines came from, for the report.
30
+ * @returns The summary a reader wants.
31
+ */
32
+ export function summarizeTrace(lines, path = '') {
33
+ const outcomes = {};
34
+ const kinds = {};
35
+ const lanes = {};
36
+ const sessions = {};
37
+ const byClass = {};
38
+ const runs = new Map();
39
+ const openCommands = new Map();
40
+ const openOperations = new Map();
41
+ const openLoops = new Map();
42
+ const orphans = [];
43
+ const anomalies = [];
44
+ let events = 0;
45
+ let skipped = 0;
46
+ let from;
47
+ let to;
48
+ let commandEnds = 0;
49
+ let operations = 0;
50
+ let cancelled = 0;
51
+ let longestMs;
52
+ let longest;
53
+ let verifierBegins = 0;
54
+ let verified = 0;
55
+ let unavailable = 0;
56
+ let verifierCancelled = 0;
57
+ for (const line of lines) {
58
+ if (line === '' || line.startsWith('#'))
59
+ continue;
60
+ let record;
61
+ try {
62
+ record = JSON.parse(line);
63
+ }
64
+ catch {
65
+ skipped++;
66
+ continue;
67
+ }
68
+ if (typeof record.event !== 'string') {
69
+ skipped++;
70
+ continue;
71
+ }
72
+ events++;
73
+ const time = typeof record.time === 'string' ? record.time : undefined;
74
+ if (time !== undefined) {
75
+ from ??= time;
76
+ to = time;
77
+ }
78
+ const id = spanId(record);
79
+ switch (record.event) {
80
+ case 'command': {
81
+ if (record.phase === 'begin') {
82
+ if (id !== undefined)
83
+ openCommands.set(id, String(record.kind ?? 'unknown'));
84
+ break;
85
+ }
86
+ // A held line was accepted but not run; only a `begin`/`end` pair is an execution.
87
+ if (record.phase === 'queued')
88
+ break;
89
+ commandEnds++;
90
+ const paired = id !== undefined && openCommands.delete(id);
91
+ // A trace written before `command begin/end` has one phaseless event per line, whose fate is
92
+ // the old `accepted` flag; reading it as an end keeps an existing file useful, and a phaseless
93
+ // line is never reported as an orphan end.
94
+ if (record.phase === 'end' && id !== undefined && !paired) {
95
+ orphans.push(`command ${id} (${String(record.kind ?? 'unknown')}): end without begin`);
96
+ }
97
+ const outcome = typeof record.outcome === 'string' ? record.outcome : record.accepted === true ? 'ok' : 'rejected';
98
+ count(outcomes, outcome);
99
+ count(kinds, String(record.kind ?? 'unknown'));
100
+ break;
101
+ }
102
+ case 'mutation':
103
+ count(lanes, String(record.lane ?? 'unknown'));
104
+ count(sessions, String(record.session ?? 'unknown'));
105
+ break;
106
+ case 'foreground': {
107
+ if (record.phase === 'begin') {
108
+ operations++;
109
+ if (id !== undefined)
110
+ openOperations.set(id, { label: String(record.label ?? record.kind ?? ''), startedAt: time === undefined ? 0 : Date.parse(time) });
111
+ break;
112
+ }
113
+ if (id === undefined)
114
+ break;
115
+ const started = openOperations.get(id);
116
+ openOperations.delete(id);
117
+ if (started === undefined)
118
+ orphans.push(`foreground ${id}: end without begin`);
119
+ if (record.cancelled === true)
120
+ cancelled++;
121
+ if (started !== undefined && time !== undefined) {
122
+ const ms = Date.parse(time) - started.startedAt;
123
+ if (Number.isFinite(ms) && (longestMs === undefined || ms > longestMs)) {
124
+ longestMs = ms;
125
+ longest = started.label;
126
+ }
127
+ }
128
+ break;
129
+ }
130
+ case 'loop': {
131
+ // Events that describe a submission rather than a run (`command`, `form`, `rejected`) are not
132
+ // a run and must not invent one. A trace written before `runId` existed has one key for the
133
+ // run events it did record; they still pair, they just cannot be told apart per run.
134
+ const runId = typeof record.runId === 'string' ? record.runId : undefined;
135
+ const describesRun = runId !== undefined || record.phase === 'begin' || record.phase === 'sent' || record.phase === 'end';
136
+ if (!describesRun)
137
+ break;
138
+ const key = runId ?? '';
139
+ const run = runs.get(key) ?? { kind: 'unknown', sent: 0 };
140
+ if (typeof record.kind === 'string')
141
+ run.kind = record.kind;
142
+ if (record.phase === 'begin')
143
+ openLoops.set(key, run.kind);
144
+ if (record.phase === 'sent')
145
+ run.sent++;
146
+ if (record.phase === 'end') {
147
+ if (!openLoops.delete(key))
148
+ orphans.push(`loop ${label(key)} (${run.kind}): end without begin`);
149
+ run.result = String(record.result ?? 'unknown');
150
+ if (record.reason !== undefined)
151
+ run.reason = String(record.reason);
152
+ }
153
+ runs.set(key, run);
154
+ break;
155
+ }
156
+ case 'verify': {
157
+ if (record.phase === 'begin') {
158
+ verifierBegins++;
159
+ break;
160
+ }
161
+ if (record.phase === 'verified') {
162
+ verified++;
163
+ break;
164
+ }
165
+ if (record.phase === 'cancelled' || record.phase === 'abandoned') {
166
+ verifierCancelled++;
167
+ break;
168
+ }
169
+ if (record.phase === 'unavailable') {
170
+ unavailable++;
171
+ const match = /stderrClass (\w+)/.exec(String(record.reason ?? ''));
172
+ if (match?.[1] !== undefined)
173
+ count(byClass, match[1]);
174
+ }
175
+ break;
176
+ }
177
+ default: break;
178
+ }
179
+ }
180
+ for (const [id, kind] of openCommands)
181
+ anomalies.push(`command ${id} (${kind}): begin without end`);
182
+ for (const id of openOperations.keys())
183
+ anomalies.push(`foreground ${id}: begin without end`);
184
+ for (const [runId, kind] of openLoops)
185
+ anomalies.push(`loop ${label(runId)} (${kind}): begin without end`);
186
+ // An end whose begin is missing means the window dropped it or the writer is broken; either way the
187
+ // reader has to see it, because the pairing is what makes the rest of the file trustworthy.
188
+ anomalies.push(...orphans);
189
+ return {
190
+ path, events, skipped,
191
+ ...(from === undefined ? {} : { from }),
192
+ ...(to === undefined ? {} : { to }),
193
+ commands: { total: commandEnds, outcomes, kinds },
194
+ mutations: { total: Object.values(lanes).reduce((sum, value) => sum + value, 0), lanes, sessions },
195
+ operations: { total: operations, cancelled, ...(longestMs === undefined ? {} : { longestMs, longest }), open: openOperations.size },
196
+ loops: [...runs.entries()].map(([runId, run]) => ({ runId, ...run })),
197
+ verifiers: { begin: verifierBegins, verified, unavailable, cancelled: verifierCancelled, byClass },
198
+ anomalies,
199
+ };
200
+ }
201
+ /** Render one tally as `key value · key value`, widest count first. */
202
+ function tally(text) {
203
+ return Object.entries(text).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
204
+ .map(([key, value]) => `${key} ${value}`).join(' · ');
205
+ }
206
+ /** Render a summary as the lines `dsht trace` prints.
207
+ * @param summary - Result of `summarizeTrace`.
208
+ * @returns Plain lines, one fact per line, without a trailing newline.
209
+ */
210
+ export function formatTraceSummary(summary) {
211
+ const lines = [`trace · ${summary.path}`, `events ${summary.events}${summary.skipped === 0 ? '' : ` (+${summary.skipped} unreadable)`}`
212
+ + `${summary.from === undefined ? '' : ` · ${summary.from} → ${summary.to}`}`];
213
+ if (summary.commands.total > 0) {
214
+ lines.push(`commands ${summary.commands.total}: ${tally(summary.commands.outcomes)}`);
215
+ lines.push(` kinds: ${tally(summary.commands.kinds)}`);
216
+ }
217
+ if (summary.mutations.total > 0) {
218
+ lines.push(`session writes ${summary.mutations.total}: ${tally(summary.mutations.lanes)}`);
219
+ lines.push(` by session: ${tally(summary.mutations.sessions)}`);
220
+ }
221
+ const { longestMs, longest } = summary.operations;
222
+ if (summary.operations.total > 0) {
223
+ const slowest = longestMs === undefined ? '' : ` · longest ${(longestMs / 1000).toFixed(1)}s (${longest})`;
224
+ lines.push(`foreground ${summary.operations.total}: cancelled ${summary.operations.cancelled}${slowest}`);
225
+ }
226
+ for (const run of summary.loops) {
227
+ const end = run.result === undefined ? 'unfinished' : `${run.result}${run.reason === undefined ? '' : ` (${run.reason})`}`;
228
+ lines.push(`loop ${label(run.runId)} · ${run.kind} · sent ${run.sent} → ${end}`);
229
+ }
230
+ const { begin, verified, unavailable, cancelled: verifierCancelled, byClass } = summary.verifiers;
231
+ if (begin > 0 || verified + unavailable + verifierCancelled > 0) {
232
+ const classes = Object.keys(byClass).length === 0 ? '' : ` (${tally(byClass)})`;
233
+ const concluded = verified + unavailable + verifierCancelled;
234
+ lines.push(`verifiers: begin ${begin} · verified ${verified} · unavailable ${unavailable}${classes}`
235
+ + ` · cancelled ${verifierCancelled}`
236
+ + (begin > concluded ? ` · ${begin - concluded} without a conclusion` : ''));
237
+ }
238
+ for (const anomaly of summary.anomalies)
239
+ lines.push(`anomaly · ${anomaly}`);
240
+ return lines;
241
+ }