@relayflows/sdk 2.0.21 → 2.0.23

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 (247) hide show
  1. package/dist/agent-artifacts.d.ts +18 -9
  2. package/dist/agent-artifacts.d.ts.map +1 -1
  3. package/dist/agent-artifacts.js +27 -11
  4. package/dist/agent-artifacts.js.map +1 -1
  5. package/dist/agent-transcript.d.ts +186 -0
  6. package/dist/agent-transcript.d.ts.map +1 -0
  7. package/dist/agent-transcript.js +585 -0
  8. package/dist/agent-transcript.js.map +1 -0
  9. package/dist/authored-flow-error.d.ts +33 -1
  10. package/dist/authored-flow-error.d.ts.map +1 -1
  11. package/dist/authored-flow-error.js +20 -1
  12. package/dist/authored-flow-error.js.map +1 -1
  13. package/dist/authored-flow-executor.d.ts.map +1 -1
  14. package/dist/authored-flow-executor.js +4 -1
  15. package/dist/authored-flow-executor.js.map +1 -1
  16. package/dist/authored-node-entry.js +5 -1
  17. package/dist/authored-node-entry.js.map +1 -1
  18. package/dist/authored-node-runner.d.ts +11 -0
  19. package/dist/authored-node-runner.d.ts.map +1 -1
  20. package/dist/authored-node-runner.js +30 -1
  21. package/dist/authored-node-runner.js.map +1 -1
  22. package/dist/authored-root.js +3 -1
  23. package/dist/authored-root.js.map +1 -1
  24. package/dist/authored-source-authority.js +13 -0
  25. package/dist/authored-source-authority.js.map +1 -1
  26. package/dist/authored-step-index.d.ts +82 -0
  27. package/dist/authored-step-index.d.ts.map +1 -0
  28. package/dist/authored-step-index.js +120 -0
  29. package/dist/authored-step-index.js.map +1 -0
  30. package/dist/authored-step-output.d.ts +23 -2
  31. package/dist/authored-step-output.d.ts.map +1 -1
  32. package/dist/authored-step-output.js +47 -5
  33. package/dist/authored-step-output.js.map +1 -1
  34. package/dist/authored-worker-step.d.ts +10 -2
  35. package/dist/authored-worker-step.d.ts.map +1 -1
  36. package/dist/authored-worker-step.js +81 -7
  37. package/dist/authored-worker-step.js.map +1 -1
  38. package/dist/cli/check.d.ts.map +1 -1
  39. package/dist/cli/check.js +67 -12
  40. package/dist/cli/check.js.map +1 -1
  41. package/dist/cli/cloud-read.d.ts +31 -0
  42. package/dist/cli/cloud-read.d.ts.map +1 -0
  43. package/dist/cli/cloud-read.js +482 -0
  44. package/dist/cli/cloud-read.js.map +1 -0
  45. package/dist/cli/run.d.ts +2 -0
  46. package/dist/cli/run.d.ts.map +1 -1
  47. package/dist/cli/run.js +53 -24
  48. package/dist/cli/run.js.map +1 -1
  49. package/dist/cli/status.d.ts +43 -0
  50. package/dist/cli/status.d.ts.map +1 -0
  51. package/dist/cli/status.js +321 -0
  52. package/dist/cli/status.js.map +1 -0
  53. package/dist/cli/step-failure.d.ts +26 -0
  54. package/dist/cli/step-failure.d.ts.map +1 -1
  55. package/dist/cli/step-failure.js +99 -10
  56. package/dist/cli/step-failure.js.map +1 -1
  57. package/dist/cli-commands.d.ts +41 -0
  58. package/dist/cli-commands.d.ts.map +1 -1
  59. package/dist/cli-commands.js +33 -0
  60. package/dist/cli-commands.js.map +1 -1
  61. package/dist/cli.d.ts +4 -2
  62. package/dist/cli.d.ts.map +1 -1
  63. package/dist/cli.js +26 -0
  64. package/dist/cli.js.map +1 -1
  65. package/dist/cloud-http.d.ts +12 -0
  66. package/dist/cloud-http.d.ts.map +1 -1
  67. package/dist/cloud-http.js +14 -6
  68. package/dist/cloud-http.js.map +1 -1
  69. package/dist/cloud-read.d.ts +154 -0
  70. package/dist/cloud-read.d.ts.map +1 -0
  71. package/dist/cloud-read.js +373 -0
  72. package/dist/cloud-read.js.map +1 -0
  73. package/dist/cloud-transcript.d.ts +94 -0
  74. package/dist/cloud-transcript.d.ts.map +1 -0
  75. package/dist/cloud-transcript.js +342 -0
  76. package/dist/cloud-transcript.js.map +1 -0
  77. package/dist/communication/environment.d.ts +3 -0
  78. package/dist/communication/environment.d.ts.map +1 -0
  79. package/dist/communication/environment.js +34 -0
  80. package/dist/communication/environment.js.map +1 -0
  81. package/dist/communication/history.d.ts +6 -0
  82. package/dist/communication/history.d.ts.map +1 -0
  83. package/dist/communication/history.js +38 -0
  84. package/dist/communication/history.js.map +1 -0
  85. package/dist/communication/local.d.ts +6 -0
  86. package/dist/communication/local.d.ts.map +1 -0
  87. package/dist/communication/local.js +56 -0
  88. package/dist/communication/local.js.map +1 -0
  89. package/dist/communication/preflight.d.ts +6 -0
  90. package/dist/communication/preflight.d.ts.map +1 -0
  91. package/dist/communication/preflight.js +37 -0
  92. package/dist/communication/preflight.js.map +1 -0
  93. package/dist/communication/projection.d.ts +8 -0
  94. package/dist/communication/projection.d.ts.map +1 -0
  95. package/dist/communication/projection.js +23 -0
  96. package/dist/communication/projection.js.map +1 -0
  97. package/dist/communication/relay.d.ts +78 -0
  98. package/dist/communication/relay.d.ts.map +1 -0
  99. package/dist/communication/relay.js +89 -0
  100. package/dist/communication/relay.js.map +1 -0
  101. package/dist/communication/session.d.ts +24 -0
  102. package/dist/communication/session.d.ts.map +1 -0
  103. package/dist/communication/session.js +100 -0
  104. package/dist/communication/session.js.map +1 -0
  105. package/dist/communication/spec.d.ts +20 -0
  106. package/dist/communication/spec.d.ts.map +1 -0
  107. package/dist/communication/spec.js +113 -0
  108. package/dist/communication/spec.js.map +1 -0
  109. package/dist/communication/tool.d.ts +3 -0
  110. package/dist/communication/tool.d.ts.map +1 -0
  111. package/dist/communication/tool.js +23 -0
  112. package/dist/communication/tool.js.map +1 -0
  113. package/dist/communication/tools.d.ts +11 -0
  114. package/dist/communication/tools.d.ts.map +1 -0
  115. package/dist/communication/tools.js +65 -0
  116. package/dist/communication/tools.js.map +1 -0
  117. package/dist/communication/worker.d.ts +6 -0
  118. package/dist/communication/worker.d.ts.map +1 -0
  119. package/dist/communication/worker.js +120 -0
  120. package/dist/communication/worker.js.map +1 -0
  121. package/dist/compile.d.ts.map +1 -1
  122. package/dist/compile.js +8 -1
  123. package/dist/compile.js.map +1 -1
  124. package/dist/failure-kinds.d.ts +30 -2
  125. package/dist/failure-kinds.d.ts.map +1 -1
  126. package/dist/failure-kinds.js +9 -0
  127. package/dist/failure-kinds.js.map +1 -1
  128. package/dist/index.d.ts +3 -1
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +2 -0
  131. package/dist/index.js.map +1 -1
  132. package/dist/journal-client.d.ts +4 -1
  133. package/dist/journal-client.d.ts.map +1 -1
  134. package/dist/journal-client.js +11 -2
  135. package/dist/journal-client.js.map +1 -1
  136. package/dist/journal-reader.d.ts +1 -1
  137. package/dist/journal-reader.d.ts.map +1 -1
  138. package/dist/journal-reader.js +3 -1
  139. package/dist/journal-reader.js.map +1 -1
  140. package/dist/llm-worker.d.ts.map +1 -1
  141. package/dist/llm-worker.js +12 -1
  142. package/dist/llm-worker.js.map +1 -1
  143. package/dist/permissions-preflight.d.ts +22 -0
  144. package/dist/permissions-preflight.d.ts.map +1 -0
  145. package/dist/permissions-preflight.js +49 -0
  146. package/dist/permissions-preflight.js.map +1 -0
  147. package/dist/preflight.d.ts +13 -2
  148. package/dist/preflight.d.ts.map +1 -1
  149. package/dist/preflight.js +29 -6
  150. package/dist/preflight.js.map +1 -1
  151. package/dist/protocol.d.ts +39 -1
  152. package/dist/protocol.d.ts.map +1 -1
  153. package/dist/pty-sidechannel.d.ts +7 -0
  154. package/dist/pty-sidechannel.d.ts.map +1 -1
  155. package/dist/pty-sidechannel.js.map +1 -1
  156. package/dist/redact.d.ts +16 -0
  157. package/dist/redact.d.ts.map +1 -0
  158. package/dist/redact.js +104 -0
  159. package/dist/redact.js.map +1 -0
  160. package/dist/run-state.d.ts +124 -0
  161. package/dist/run-state.d.ts.map +1 -0
  162. package/dist/run-state.js +368 -0
  163. package/dist/run-state.js.map +1 -0
  164. package/dist/spec.d.ts +24 -1
  165. package/dist/spec.d.ts.map +1 -1
  166. package/dist/spec.js.map +1 -1
  167. package/dist/step-env.d.ts +25 -0
  168. package/dist/step-env.d.ts.map +1 -0
  169. package/dist/step-env.js +34 -0
  170. package/dist/step-env.js.map +1 -0
  171. package/dist/transcript-tail.d.ts +54 -0
  172. package/dist/transcript-tail.d.ts.map +1 -0
  173. package/dist/transcript-tail.js +176 -0
  174. package/dist/transcript-tail.js.map +1 -0
  175. package/dist/worker-cli.d.ts +10 -0
  176. package/dist/worker-cli.d.ts.map +1 -1
  177. package/dist/worker-cli.js +138 -17
  178. package/dist/worker-cli.js.map +1 -1
  179. package/dist/worker-usage.d.ts +7 -2
  180. package/dist/worker-usage.d.ts.map +1 -1
  181. package/dist/worker-usage.js +21 -3
  182. package/dist/worker-usage.js.map +1 -1
  183. package/dist/worker.d.ts +1 -0
  184. package/dist/worker.d.ts.map +1 -1
  185. package/dist/worker.js +41 -7
  186. package/dist/worker.js.map +1 -1
  187. package/dist/wrapper-runtime.d.ts +7 -1
  188. package/dist/wrapper-runtime.d.ts.map +1 -1
  189. package/dist/wrapper-runtime.js +7 -1
  190. package/dist/wrapper-runtime.js.map +1 -1
  191. package/dist/wrapper-session.d.ts +8 -0
  192. package/dist/wrapper-session.d.ts.map +1 -1
  193. package/dist/wrapper-session.js +156 -23
  194. package/dist/wrapper-session.js.map +1 -1
  195. package/package.json +14 -2
  196. package/src/agent-artifacts.ts +27 -11
  197. package/src/agent-transcript.ts +651 -0
  198. package/src/authored-flow-error.ts +17 -0
  199. package/src/authored-flow-executor.ts +4 -1
  200. package/src/authored-node-entry.ts +5 -1
  201. package/src/authored-node-runner.ts +33 -1
  202. package/src/authored-root.ts +2 -1
  203. package/src/authored-source-authority.ts +13 -0
  204. package/src/authored-step-index.ts +157 -0
  205. package/src/authored-step-output.ts +66 -14
  206. package/src/authored-worker-step.ts +88 -15
  207. package/src/cli/check.ts +69 -11
  208. package/src/cli/cloud-read.ts +518 -0
  209. package/src/cli/run.ts +43 -24
  210. package/src/cli/status.ts +349 -0
  211. package/src/cli/step-failure.ts +100 -10
  212. package/src/cli-commands.ts +33 -0
  213. package/src/cli.ts +27 -0
  214. package/src/cloud-http.ts +32 -6
  215. package/src/cloud-read.ts +515 -0
  216. package/src/cloud-transcript.ts +422 -0
  217. package/src/communication/environment.ts +33 -0
  218. package/src/communication/history.ts +38 -0
  219. package/src/communication/local.ts +41 -0
  220. package/src/communication/preflight.ts +34 -0
  221. package/src/communication/projection.ts +23 -0
  222. package/src/communication/relay.ts +102 -0
  223. package/src/communication/session.ts +84 -0
  224. package/src/communication/spec.ts +106 -0
  225. package/src/communication/tool.ts +22 -0
  226. package/src/communication/tools.ts +50 -0
  227. package/src/communication/worker.ts +95 -0
  228. package/src/compile.ts +4 -1
  229. package/src/failure-kinds.ts +31 -1
  230. package/src/index.ts +10 -1
  231. package/src/journal-client.ts +12 -2
  232. package/src/journal-reader.ts +4 -2
  233. package/src/llm-worker.ts +12 -1
  234. package/src/permissions-preflight.ts +53 -0
  235. package/src/preflight.ts +42 -7
  236. package/src/protocol.ts +15 -0
  237. package/src/pty-sidechannel.ts +7 -0
  238. package/src/redact.ts +106 -0
  239. package/src/run-state.ts +468 -0
  240. package/src/spec.ts +18 -1
  241. package/src/step-env.ts +47 -0
  242. package/src/transcript-tail.ts +193 -0
  243. package/src/worker-cli.ts +139 -18
  244. package/src/worker-usage.ts +26 -3
  245. package/src/worker.ts +42 -6
  246. package/src/wrapper-runtime.ts +7 -1
  247. package/src/wrapper-session.ts +159 -24
package/src/cli/run.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { communicationInstruction } from '../communication/spec.js';
2
+ import { checkCommunicationEnvironment, CommunicationEnvironmentError } from '../communication/preflight.js';
1
3
  import { parseHumanRecipient } from '../human-to.js';
2
4
  import { parseDigestReference } from '../bundle-transport.js';
3
5
  import { prepareDigestRun } from './run-digest.js';
@@ -13,7 +15,7 @@ import { ensureDaemon, type EnsureDaemonOptions } from '../daemon-lifecycle.js';
13
15
  import { isAuthoredFlowPath } from '../direct-input.js';
14
16
  import { daemonRefusal } from './daemon-refusal.js';
15
17
  import type { RunFailureKind, RunWarningKind, StepFailedDetails } from '../failure-kinds.js';
16
- import { inspectionHint, stepFailureDetails } from './step-failure.js';
18
+ import { inspectionHint, renderInspection, renderStepEvidence, stepFailureDetails } from './step-failure.js';
17
19
  import { JournalClient, JournalProtocolError } from '../journal-client.js';
18
20
  import { attachLocalAgent } from '../local-agent.js';
19
21
  import { LlmWorker } from '../llm-worker.js';
@@ -49,6 +51,8 @@ export interface RunReport {
49
51
  command: RunCommand;
50
52
  path?: string;
51
53
  runId?: string;
54
+ /** Authored root to resume/collect when runId identifies a failed child. */
55
+ rootRunId?: string;
52
56
  socketPath?: string;
53
57
  status?: RunStatus;
54
58
  completionReason?: RunCompletionReason;
@@ -129,16 +133,26 @@ async function executeCheckedFlow(
129
133
  // Carry the preflight's diagnostics as a RunReport from here on, so the
130
134
  // attach step has one accumulator to append to (see `connect`).
131
135
  const base = fromCheckReport('run', checked.report);
136
+ if (options.localAgent) {
137
+ try { checkCommunicationEnvironment(checked.flow!); }
138
+ catch (error) { return { exitCode: 2, report: { ...base, diagnostics: [...base.diagnostics,
139
+ { severity: 'refusal', kind: 'probe_failed', message: error instanceof Error ? error.message : 'Communication environment could not be checked.' }] } }; }
140
+ }
132
141
  const client = new JournalClient(socketPath);
133
142
  const connected = await connect(client, 'run', dataDir, base, options);
134
143
  if (connected !== undefined) return connected;
135
144
 
145
+ let communicationWorkers: Awaited<ReturnType<typeof import('../communication/local.js').attachCommunicationWorkers>> | undefined;
136
146
  let localAgent: Awaited<ReturnType<typeof attachLocalAgent>> | undefined;
137
147
  try {
138
148
  const spec = toKernelSpec(checked.flow!);
139
149
  // Use the checked CLI/model and declared surfaces unchanged. The worker
140
150
  // advertises its existing pins; the daemon still owns surface matching.
141
151
  if (options.localAgent) localAgent = await attachLocalAgent(client, dataDir, options.onPtyReady);
152
+ if (options.localAgent && spec.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
153
+ const { attachCommunicationWorkers } = await import('../communication/local.js');
154
+ communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
155
+ }
142
156
  const outcome = await client.runStart(spec, options.reuseFromRunId);
143
157
  const execution = await classifyOutcome(client, 'run', outcome, base, socketPath, { ...options, dataDir });
144
158
  if (options.reuseFromRunId !== undefined) {
@@ -146,6 +160,8 @@ async function executeCheckedFlow(
146
160
  }
147
161
  return execution;
148
162
  } catch (error) {
163
+ if (error instanceof CommunicationEnvironmentError) return { exitCode: 2, report: { ...base,
164
+ diagnostics: [...base.diagnostics, { severity: 'refusal', kind: 'probe_failed', message: error.message }] } };
149
165
  if (error instanceof JournalProtocolError && (
150
166
  error.code === 'reuse_spec_mismatch' || error.code === 'reuse_run_not_found'
151
167
  || error.code === 'reuse_journal_read_failed'
@@ -155,9 +171,9 @@ async function executeCheckedFlow(
155
171
  diagnostics: [...base.diagnostics, { severity: failed ? 'failure' : 'refusal',
156
172
  kind: error.code, message: error.message }] } };
157
173
  }
158
- return protocolFailure('run', base, socketPath, localAgent?.failure ?? error);
174
+ return protocolFailure('run', base, socketPath, communicationWorkers?.failure ?? localAgent?.failure ?? error);
159
175
  } finally {
160
- try { await localAgent?.close(); } finally { client.close(); }
176
+ try { await communicationWorkers?.close(); } finally { try { await localAgent?.close(); } finally { client.close(); } }
161
177
  }
162
178
  }
163
179
 
@@ -172,6 +188,7 @@ export async function resumeFlow(
172
188
  const connected = await connect(client, 'resume', dataDir, base, options);
173
189
  if (connected !== undefined) return connected;
174
190
 
191
+ let communicationWorkers: Awaited<ReturnType<typeof import('../communication/local.js').attachCommunicationWorkers>> | undefined;
175
192
  let authoredAgent: Awaited<ReturnType<typeof attachLocalAgent>> | undefined;
176
193
  let authoredLlm: LlmWorker | undefined;
177
194
  let authoredLlmClient: JournalClient | undefined;
@@ -206,12 +223,23 @@ export async function resumeFlow(
206
223
  // slack effect resume plus every other provider from N's codegen. The
207
224
  // second call the earlier rebase left is a stale reference from before
208
225
  // the helper fanout renamed the API.
226
+ if (options.localAgent) {
227
+ authoredAgent = await attachLocalAgent(client, dataDir, options.onPtyReady);
228
+ const entries = (await client.journalRead(runId, 1)).entries as Array<{ entry_type: string; payload?: { spec?: import('../spec.js').KernelRunSpec } }>;
229
+ const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
230
+ if (spec?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
231
+ const { attachCommunicationWorkers } = await import('../communication/local.js');
232
+ communicationWorkers = await attachCommunicationWorkers(spec, socketPath, dataDir);
233
+ }
234
+ }
209
235
  let outcome = await client.runResume(runId, options.allowHumanInfluenced);
210
236
  if (await resumeHelperEffect(client, runId, dataDir)) {
211
237
  outcome = await client.runResume(runId, options.allowHumanInfluenced);
212
238
  }
213
239
  return await classifyOutcome(client, 'resume', outcome, base, socketPath, { ...options, dataDir });
214
240
  } catch (error) {
241
+ if (error instanceof CommunicationEnvironmentError) return { exitCode: 2, report: { ...base, runId, socketPath,
242
+ diagnostics: [...base.diagnostics, { severity: 'refusal', kind: 'probe_failed', message: error.message }] } };
215
243
  if (error instanceof JournalProtocolError && error.code === 'human_influenced_run') {
216
244
  return { exitCode: 2, report: { ...base, runId, socketPath,
217
245
  diagnostics: [{ severity: 'refusal', kind: 'human_influenced_run', message: error.message.replace(/^human_influenced_run: /, '') }] } };
@@ -255,6 +283,7 @@ export async function resumeFlow(
255
283
  };
256
284
  } finally {
257
285
  try {
286
+ await communicationWorkers?.close();
258
287
  await authoredLlm?.close();
259
288
  authoredLlmClient?.close();
260
289
  await authoredAgent?.close();
@@ -287,16 +316,23 @@ export function authoredStepFailure(
287
316
  // The failing step runs as its own kernel run, so the error's run id is the
288
317
  // one whose journal holds the evidence. The resume target is the fallback.
289
318
  const runId = error.runId ?? fallbackRunId;
319
+ const rootRunId = error.rootRunId ?? fallbackRunId;
290
320
  return {
291
321
  exitCode: 1,
292
322
  report: {
293
323
  ...fromBase(command, base),
294
324
  ok: false,
295
325
  ...(runId === undefined ? {} : { runId }),
326
+ ...(rootRunId === undefined ? {} : { rootRunId }),
296
327
  socketPath,
297
328
  status: 'failed',
298
329
  completionReason: 'step_failed',
299
330
  diagnostics: [...base.diagnostics, {
331
+ // The structured evidence the failing step left in its own journal.
332
+ // `RunDiagnostic extends StepFailedDetails`, so `--json` gains the
333
+ // fields it already declares instead of leaving them to be re-parsed
334
+ // out of the rendered message.
335
+ ...error.details,
300
336
  severity: 'failure',
301
337
  // A predicate gate that judged false is a run failure with its own
302
338
  // name, so the report says which kind of check the body did not pass.
@@ -522,7 +558,9 @@ export async function classifyOutcome(
522
558
  let unclassifiedPolls = 0;
523
559
  while (current.status === 'parked') {
524
560
  const inspection = await inspectOutOfBandStep(client, current.run_id);
525
- if (inspection?.parkedStep !== undefined) {
561
+ // A runnable sibling may only be waiting for capacity held by a live
562
+ // attempt. Keep driving until that lease releases before declaring a park.
563
+ if (inspection?.parkedStep !== undefined && (inspection.needsHuman || inspection.runningStep === undefined)) {
526
564
  parkedStep = inspection.parkedStep;
527
565
  needsHuman = inspection.needsHuman;
528
566
  break;
@@ -603,8 +641,7 @@ export async function classifyOutcome(
603
641
  // must still end with somewhere to go rather than with a dead end.
604
642
  const where = inspectionHint(current.run_id, details?.stepId, options.dataDir);
605
643
  Object.assign(diagnostic, where);
606
- diagnostic.message += `\nInspect: ${where.hint}`
607
- + (where.journalPath === undefined ? '' : `\nJournal: ${where.journalPath}`);
644
+ diagnostic.message += renderInspection(where);
608
645
  }
609
646
  return {
610
647
  exitCode: 1,
@@ -793,24 +830,6 @@ function errorMessage(error: unknown): string {
793
830
  return error instanceof Error ? error.message : 'unknown protocol error';
794
831
  }
795
832
 
796
- /**
797
- * The evidence half of a `step_failed` diagnostic; `inspectionHint` adds the
798
- * rest. Each field is printed only when the journal actually carried it — an
799
- * agent step has no exit code to report, and inventing `exit=undefined` (which
800
- * is what the deterministic-only version printed for one) is worse than
801
- * silence on that field.
802
- */
803
- function renderStepEvidence(details: StepFailedDetails): string {
804
- return ` Step ${JSON.stringify(details.stepId)}`
805
- + (details.stepType === undefined ? '' : ` (${details.stepType})`)
806
- + ` completionReason: ${details.completionReason}`
807
- + (details.exitCode === undefined ? '' : ` exit=${details.exitCode}`)
808
- + '.'
809
- + (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
810
- + (details.stdoutTail ? `\nStdout (last 1,024 bytes):\n${details.stdoutTail}` : '')
811
- + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '');
812
- }
813
-
814
833
  function throwIfCanceled(signal: AbortSignal | undefined, stepId: string): void {
815
834
  if (signal?.aborted === true) throw new Error(`waiting for running step "${stepId}" was canceled`);
816
835
  }
@@ -0,0 +1,349 @@
1
+ // `flows status`: what an agent can see about its own run, from disk.
2
+ //
3
+ // Read-only and offline. Opens exactly one file — the run's journal, through
4
+ // the same copy-then-verify `walkJournal` that `flows replay` uses — and never
5
+ // the registry, `connection.json`, the daemon socket or the network. There is
6
+ // no credential to hold because nothing here is authenticated: the journal is
7
+ // on the same filesystem as the process asking. Everything a hosted run knows
8
+ // about itself is in that file; whatever is not (its Cloud run id, its
9
+ // sandbox, its listener) is Cloud's to answer, and this verb does not guess.
10
+
11
+ import type { CliIo } from '../cli.js';
12
+ import { canonicalize } from '../canonical.js';
13
+ import { DEFAULT_DATA_DIR } from '../daemon-connection.js';
14
+ import { JournalReadError, walkJournal, type JournalEvent } from '../journal-client.js';
15
+ import { redact } from '../redact.js';
16
+ import { foldRunState, RunStateError, type RunView, type StepView } from '../run-state.js';
17
+ import { DATA_DIR_ENV, RUN_ID_ENV, STEP_ID_ENV } from '../step-env.js';
18
+
19
+ export interface StatusArgs {
20
+ command: 'status';
21
+ json: boolean;
22
+ dataDir?: string;
23
+ tail?: number;
24
+ runId?: string;
25
+ /**
26
+ * `--cloud`: read the run from the Cloud API instead of a local journal.
27
+ *
28
+ * Parsed here because the verb is one verb — a reader should not have to
29
+ * know that a hosted run is a different command — but never *handled* here:
30
+ * `runCli` routes a `--cloud` invocation to `cli/cloud-read.ts`, so this
31
+ * module keeps its property of opening one file and no socket.
32
+ */
33
+ cloud?: true;
34
+ }
35
+
36
+ export const DEFAULT_TAIL_LINES = 20;
37
+ /** A gate's `detail` may be 2,000 chars (engine/remote.rs); the view shows the head. */
38
+ const DETAIL_LIMIT = 1024;
39
+ const BUSY_RETRIES = 5;
40
+ const BUSY_RETRY_DELAY_MS = 50;
41
+
42
+ /** One transcript tail on disk, already redacted, as `--tail` renders it. */
43
+ export interface TranscriptTail {
44
+ attempt: number;
45
+ bytes: number;
46
+ lines: string[];
47
+ }
48
+
49
+ export type StepTails = { stdout: TranscriptTail | null; stderr: TranscriptTail | null } | null;
50
+
51
+ /** How the verb finds transcript tails; `null` until the tail writer ships. */
52
+ export interface TailSource {
53
+ read(dataDir: string, runId: string, step: StepView, lines: number): Promise<StepTails>;
54
+ }
55
+
56
+ export interface StatusOptions {
57
+ env?: NodeJS.ProcessEnv;
58
+ now?: () => number;
59
+ tails?: TailSource;
60
+ sleep?: (ms: number) => Promise<void>;
61
+ }
62
+
63
+ export function parseStatusArgs(args: readonly string[]): StatusArgs | undefined {
64
+ let json = false;
65
+ let cloud = false;
66
+ let dataDir: string | undefined;
67
+ let tail: number | undefined;
68
+ const positionals: string[] = [];
69
+ for (let index = 0; index < args.length; index += 1) {
70
+ const argument = args[index]!;
71
+ if (argument === '--json') {
72
+ if (json) return undefined;
73
+ json = true;
74
+ } else if (argument === '--cloud') {
75
+ if (cloud) return undefined;
76
+ cloud = true;
77
+ } else if (argument === '--data-dir' || argument === '--tail') {
78
+ const value = args[++index];
79
+ if (value === undefined || value.length === 0 || value.startsWith('-')) return undefined;
80
+ if (argument === '--data-dir') {
81
+ if (dataDir !== undefined) return undefined;
82
+ dataDir = value;
83
+ } else {
84
+ if (tail !== undefined || !/^\d{1,4}$/.test(value)) return undefined;
85
+ tail = Number(value);
86
+ }
87
+ } else if (argument.startsWith('-')) {
88
+ return undefined;
89
+ } else {
90
+ positionals.push(argument);
91
+ }
92
+ }
93
+ if (positionals.length > 1) return undefined;
94
+ // `--data-dir` and `--tail` name things on this filesystem: a data directory
95
+ // to read a journal out of, and per-attempt tail files beside it. A hosted
96
+ // run has neither, so pairing them with `--cloud` is not a narrower request
97
+ // but a contradiction, and is refused as an invocation rather than silently
98
+ // ignored. A hosted run id is mandatory: there is no ambient one.
99
+ if (cloud && (dataDir !== undefined || tail !== undefined || positionals.length === 0)) return undefined;
100
+ return {
101
+ command: 'status', json,
102
+ ...(cloud ? { cloud: true as const } : {}),
103
+ ...(dataDir === undefined ? {} : { dataDir }),
104
+ ...(tail === undefined ? {} : { tail }),
105
+ ...(positionals[0] === undefined ? {} : { runId: positionals[0] }),
106
+ };
107
+ }
108
+
109
+ interface Snapshot {
110
+ events: JournalEvent[];
111
+ partial: string[];
112
+ failure?: JournalReadError;
113
+ }
114
+
115
+ /**
116
+ * Take one consistent snapshot of the journal. `journal_busy` means a writer
117
+ * was mid-flight, so wait briefly and try again; any other mid-walk failure
118
+ * keeps the events read so far and reports the section as partial.
119
+ */
120
+ async function snapshot(runId: string, dataDir: string, sleep: (ms: number) => Promise<void>): Promise<Snapshot> {
121
+ for (let attempt = 1; ; attempt += 1) {
122
+ const events: JournalEvent[] = [];
123
+ try {
124
+ for await (const event of walkJournal(runId, dataDir)) events.push(event);
125
+ return { events, partial: [] };
126
+ } catch (error) {
127
+ const failure = error instanceof JournalReadError ? error
128
+ : new JournalReadError('journal_read_failed', error instanceof Error ? error.message : String(error));
129
+ if (failure.code === 'journal_busy' && attempt < BUSY_RETRIES) { await sleep(BUSY_RETRY_DELAY_MS); continue; }
130
+ return { events, partial: [failure.code], failure };
131
+ }
132
+ }
133
+ }
134
+
135
+ export async function runStatus(args: StatusArgs, io: CliIo, options: StatusOptions = {}): Promise<0 | 1 | 2> {
136
+ const env = options.env ?? process.env;
137
+ const now = options.now ?? Date.now;
138
+ const sleep = options.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
139
+ // Discovery: an explicit run id wins; otherwise the step's own identity from
140
+ // the environment the worker set. A shared data dir holds other people's
141
+ // runs, so there is no "list everything" mode to fall back to.
142
+ const runId = args.runId ?? env[RUN_ID_ENV];
143
+ if (runId === undefined || runId.length === 0) {
144
+ io.stderr(`REFUSED [run_unknown] pass <run-id> or run inside a step (${RUN_ID_ENV} unset)`);
145
+ return 2;
146
+ }
147
+ const dataDir = args.dataDir ?? env[DATA_DIR_ENV] ?? DEFAULT_DATA_DIR;
148
+ const thisStep = args.runId === undefined ? env[STEP_ID_ENV] ?? null : null;
149
+
150
+ const taken = await snapshot(runId, dataDir, sleep);
151
+ if (taken.events.length === 0) {
152
+ const failure = taken.failure ?? new JournalReadError('journal_read_failed', 'Journal holds no entries.');
153
+ io.stderr(`REFUSED [${failure.code}] ${failure.message}`);
154
+ return 2;
155
+ }
156
+ let view: RunView;
157
+ try {
158
+ view = foldRunState(taken.events, now());
159
+ } catch (error) {
160
+ const message = error instanceof RunStateError ? error.message : error instanceof Error ? error.message : String(error);
161
+ io.stderr(`REFUSED [run_state_invalid] ${message}`);
162
+ return 2;
163
+ }
164
+
165
+ // Tails are read for JSON (bounded, and the schema carries them) and for
166
+ // text only on request; a plain `flows status` opens the journal and nothing else.
167
+ const wantTails = args.json || args.tail !== undefined;
168
+ const tails = new Map<string, StepTails>();
169
+ if (options.tails !== undefined && wantTails) {
170
+ for (const step of view.steps) {
171
+ try {
172
+ tails.set(step.id, await options.tails.read(dataDir, runId, step, args.tail ?? DEFAULT_TAIL_LINES));
173
+ } catch {
174
+ if (!taken.partial.includes('transcript_tail_unreadable')) taken.partial.push('transcript_tail_unreadable');
175
+ tails.set(step.id, null);
176
+ }
177
+ }
178
+ }
179
+
180
+ const presented = present(view, thisStep, tails, env);
181
+ if (args.json) {
182
+ io.stdout(canonicalize({ v: 1, ...presented, partial: taken.partial }));
183
+ } else {
184
+ for (const line of renderText(presented, taken.partial, args.tail !== undefined)) io.stdout(line);
185
+ }
186
+ if (taken.failure !== undefined) io.stderr(`FAILED [${taken.failure.code}] ${taken.failure.message}`);
187
+ return taken.partial.length === 0 ? 0 : 1;
188
+ }
189
+
190
+ type PresentedStep = StepView & { tails: StepTails };
191
+ type Presented = Omit<RunView, 'steps'> & { this_step: string | null; steps: PresentedStep[] };
192
+
193
+ /** Apply the redaction and size bounds the on-disk model does not have. */
194
+ function present(view: RunView, thisStep: string | null, tails: Map<string, StepTails>, env: NodeJS.ProcessEnv): Presented {
195
+ return {
196
+ ...view,
197
+ this_step: thisStep,
198
+ steps: view.steps.map((step) => ({
199
+ ...step,
200
+ // An agent names the files it writes, and it inherits this process's
201
+ // environment, so a path is free text like any other here.
202
+ artifacts: { ...step.artifacts, paths: step.artifacts.paths.map((path) => redact(path, env)) },
203
+ last_attempt: step.last_attempt === null ? null : {
204
+ ...step.last_attempt,
205
+ verification: step.last_attempt.verification === null ? null : {
206
+ ...step.last_attempt.verification,
207
+ detail: redact(step.last_attempt.verification.detail, env).slice(0, DETAIL_LIMIT),
208
+ },
209
+ // The digest's strings were redacted when it was built (flows#491), by
210
+ // a redactor with a different rule set to this one. Redacting again on
211
+ // the way out costs nothing and closes the difference — this module is
212
+ // the one place that decides what reaches an agent-facing page.
213
+ transcript: step.last_attempt.transcript === null ? null : {
214
+ ...step.last_attempt.transcript,
215
+ path: step.last_attempt.transcript.path === null
216
+ ? null : redact(step.last_attempt.transcript.path, env),
217
+ model: step.last_attempt.transcript.model === null
218
+ ? null : redact(step.last_attempt.transcript.model, env),
219
+ failure: step.last_attempt.transcript.failure === null ? null : {
220
+ ...step.last_attempt.transcript.failure,
221
+ excerpt: redact(step.last_attempt.transcript.failure.excerpt, env).slice(0, DETAIL_LIMIT),
222
+ },
223
+ },
224
+ },
225
+ tails: tails.get(step.id) ?? null,
226
+ })),
227
+ };
228
+ }
229
+
230
+ const GLYPH: Record<StepView['state'], string> = {
231
+ pending: '○', runnable: '○', running: '↻', backoff: '↻', waiting: '⏸', needs_human: '⏸', done: '✓',
232
+ };
233
+
234
+ /** Agent-authored names cannot inject terminal control sequences (progress.ts). */
235
+ function safe(name: string): string {
236
+ return name.replace(/[\x00-\x1f\x7f-\x9f]/g, '?');
237
+ }
238
+
239
+ export function formatDuration(ms: number): string {
240
+ const seconds = Math.max(0, ms) / 1000;
241
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
242
+ const minutes = Math.floor(seconds / 60);
243
+ if (minutes < 60) return `${minutes}m${String(Math.floor(seconds % 60)).padStart(2, '0')}s`;
244
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
245
+ }
246
+
247
+ function thousands(value: number): string {
248
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
249
+ }
250
+
251
+ function instant(ms: number): string {
252
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
253
+ }
254
+
255
+ function renderText(view: Presented, partial: string[], wantTails: boolean): string[] {
256
+ const lines: string[] = [];
257
+ const spend = `${thousands(view.spend.tokens_in)} in / ${thousands(view.spend.tokens_out)} out / $${view.spend.dollars}`
258
+ + (view.spend.dollars_unmetered ? ' (unmetered)' : '');
259
+ const finished = view.completion_reason === null ? '' : ` finished ${view.completion_reason}`;
260
+ lines.push(`RUN ${view.run_id} ${safe(view.name)} ${view.status} started ${formatDuration(view.now_ms - view.spawned_at_ms)} ago${finished} spend ${spend}`);
261
+ const counts = (['done', 'running', 'pending', 'backoff', 'waiting', 'needs_human'] as const)
262
+ .filter((key) => view.counts[key] > 0).map((key) => `${view.counts[key]} ${key}`);
263
+ // The denominator is always printed, even for zero steps, so a reader can
264
+ // tell "nothing to show" from "nothing was read".
265
+ lines.push(`steps ${view.counts.total}${counts.length === 0 ? '' : `: ${counts.join(' · ')}`}`);
266
+ if (partial.length > 0) lines.push(`partial: ${partial.join(', ')}`);
267
+ lines.push('');
268
+ const idWidth = Math.max(4, ...view.steps.map((step) => Math.min(24, step.id.length)));
269
+ for (const step of view.steps) lines.push(...renderStep(step, view, idWidth, wantTails));
270
+ return lines;
271
+ }
272
+
273
+ function renderStep(step: PresentedStep, view: Presented, idWidth: number, wantTails: boolean): string[] {
274
+ // `completion_reason`, not `last_attempt`: an epoch summary carries the
275
+ // reason without an attempt record, and reading the latter marked every
276
+ // compacted success as a failure.
277
+ const failed = step.state === 'done' && step.completion_reason !== 'success';
278
+ const glyph = failed ? '✗' : GLYPH[step.state];
279
+ const cells = [safe(step.id).slice(0, 24).padEnd(idWidth), step.type.padEnd(13), step.state.padEnd(11)];
280
+ const iterations = step.max_iterations === null ? '' : `/${step.max_iterations}`;
281
+ // `> 0` matters for a step carried done by an epoch summary: the summary
282
+ // has no attempt count, so the fold leaves it 0 and printing "0 attempts"
283
+ // would state a fact the journal no longer holds. Blank, like every other
284
+ // state does at 0.
285
+ if (step.state === 'done' && step.attempt > 0) cells.push(`${step.attempt} attempt${step.attempt === 1 ? '' : 's'}`);
286
+ else if (step.attempt > 0) cells.push(`attempt ${step.attempt}${iterations}`);
287
+ if (step.elapsed_ms !== null) cells.push(formatDuration(step.elapsed_ms));
288
+ if (step.state === 'running' && step.lease !== null) {
289
+ cells.push(step.lease.overdue_ms > 0
290
+ ? `LEASE OVERDUE by ${formatDuration(step.lease.overdue_ms)}`
291
+ : `lease ok (expires in ${formatDuration(step.lease.deadline_ms - view.now_ms)})`);
292
+ }
293
+ if (step.state === 'backoff' && step.backoff_until_ms !== null) cells.push(`backoff until ${instant(step.backoff_until_ms)}`);
294
+ if (step.wait !== null) cells.push(`awaiting ${step.wait.kind}: ${safe(step.wait.wait_id)}`);
295
+ if (step.state === 'done' && step.completion_reason !== null) {
296
+ // From the step, not the attempt: an epoch-carried step has the reason
297
+ // and no attempt record, and the reason is the thing worth printing.
298
+ cells.push(step.completion_reason);
299
+ const verification = step.last_attempt?.verification ?? null;
300
+ if (verification !== null) cells.push(`gate: ${verification.gate} ${verification.verdict}`);
301
+ }
302
+ if (step.id === view.this_step) cells.push('← this step');
303
+ const lines = [` ${glyph} ${cells.join(' ')}`.trimEnd()];
304
+
305
+ const last = step.last_attempt;
306
+ if (last !== null && step.state !== 'done') {
307
+ lines.push(` last attempt ${last.attempt}: ${last.completion_reason} → ${last.disposition}`);
308
+ }
309
+ if (last?.verification !== null && last?.verification !== undefined && last.verification.verdict !== 'pass') {
310
+ lines.push(` gate: ${last.verification.gate} ${last.verification.verdict.toUpperCase()} — ${JSON.stringify(last.verification.detail)}`);
311
+ }
312
+ const transcript = last?.transcript ?? null;
313
+ if (transcript !== null) {
314
+ // One line of provenance: what ran, how much it cost, and where the full
315
+ // transcript is. `flows status` never prints the transcript itself — that
316
+ // is `--tail`'s job for the live attempt, and the file's for a finished one.
317
+ const facts = [
318
+ transcript.model === null ? null : safe(transcript.model),
319
+ transcript.num_turns === null ? null : `${transcript.num_turns} turn${transcript.num_turns === 1 ? '' : 's'}`,
320
+ transcript.tool_calls === null ? null : `${transcript.tool_calls} tool call${transcript.tool_calls === 1 ? '' : 's'}`,
321
+ transcript.total_cost_usd === null ? null : `$${transcript.total_cost_usd}`,
322
+ ].filter((fact): fact is string => fact !== null);
323
+ const size = transcript.bytes === null ? '' : ` (${transcript.bytes} bytes${transcript.truncated ? ', truncated' : ''})`;
324
+ lines.push(` transcript (attempt ${last!.attempt})${facts.length === 0 ? '' : `: ${facts.join(' · ')}`}`);
325
+ if (transcript.path !== null) lines.push(` ${safe(transcript.path)}${size}`);
326
+ if (transcript.failure !== null && transcript.failure.excerpt.length > 0) {
327
+ lines.push(` failed at ${safe(transcript.failure.kind)}: ${JSON.stringify(transcript.failure.excerpt)}`);
328
+ }
329
+ }
330
+ if (step.type === 'agent' && last !== null) {
331
+ const shown = step.artifacts.paths.slice(0, 10).map(safe).join(', ');
332
+ const more = step.artifacts.paths.length > 10 ? ` +${step.artifacts.paths.length - 10} more` : '';
333
+ lines.push(` artifacts (attempt ${last.attempt}): ${step.artifacts.journaled
334
+ ? step.artifacts.paths.length === 0 ? 'none' : `${shown}${more}`
335
+ : 'none journaled (output was a JSON object)'}`);
336
+ }
337
+ if (wantTails && step.type === 'agent' && step.attempt > 0) {
338
+ for (const stream of ['stdout', 'stderr'] as const) {
339
+ const tail = step.tails?.[stream] ?? null;
340
+ if (tail === null) {
341
+ lines.push(` ${stream} tail: no transcript on disk for attempt ${step.attempt}`);
342
+ continue;
343
+ }
344
+ lines.push(` ${stream} tail (attempt ${tail.attempt}, last ${tail.lines.length} of ${tail.bytes} bytes):`);
345
+ for (const line of tail.lines) lines.push(` ${safe(line)}`);
346
+ }
347
+ }
348
+ return lines;
349
+ }