@relayflows/sdk 2.0.22 → 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 (193) 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/authored-flow-error.d.ts +33 -1
  6. package/dist/authored-flow-error.d.ts.map +1 -1
  7. package/dist/authored-flow-error.js +20 -1
  8. package/dist/authored-flow-error.js.map +1 -1
  9. package/dist/authored-flow-executor.d.ts.map +1 -1
  10. package/dist/authored-flow-executor.js +4 -1
  11. package/dist/authored-flow-executor.js.map +1 -1
  12. package/dist/authored-node-entry.js +5 -1
  13. package/dist/authored-node-entry.js.map +1 -1
  14. package/dist/authored-node-runner.d.ts +11 -0
  15. package/dist/authored-node-runner.d.ts.map +1 -1
  16. package/dist/authored-node-runner.js +30 -1
  17. package/dist/authored-node-runner.js.map +1 -1
  18. package/dist/authored-root.js +3 -1
  19. package/dist/authored-root.js.map +1 -1
  20. package/dist/authored-source-authority.js +13 -0
  21. package/dist/authored-source-authority.js.map +1 -1
  22. package/dist/authored-step-index.d.ts +82 -0
  23. package/dist/authored-step-index.d.ts.map +1 -0
  24. package/dist/authored-step-index.js +120 -0
  25. package/dist/authored-step-index.js.map +1 -0
  26. package/dist/authored-step-output.d.ts +23 -2
  27. package/dist/authored-step-output.d.ts.map +1 -1
  28. package/dist/authored-step-output.js +47 -5
  29. package/dist/authored-step-output.js.map +1 -1
  30. package/dist/authored-worker-step.d.ts +10 -2
  31. package/dist/authored-worker-step.d.ts.map +1 -1
  32. package/dist/authored-worker-step.js +81 -7
  33. package/dist/authored-worker-step.js.map +1 -1
  34. package/dist/cli/check.d.ts.map +1 -1
  35. package/dist/cli/check.js +67 -12
  36. package/dist/cli/check.js.map +1 -1
  37. package/dist/cli/cloud-read.d.ts +31 -0
  38. package/dist/cli/cloud-read.d.ts.map +1 -0
  39. package/dist/cli/cloud-read.js +482 -0
  40. package/dist/cli/cloud-read.js.map +1 -0
  41. package/dist/cli/run.d.ts +2 -0
  42. package/dist/cli/run.d.ts.map +1 -1
  43. package/dist/cli/run.js +53 -25
  44. package/dist/cli/run.js.map +1 -1
  45. package/dist/cli/status.d.ts +9 -0
  46. package/dist/cli/status.d.ts.map +1 -1
  47. package/dist/cli/status.js +14 -0
  48. package/dist/cli/status.js.map +1 -1
  49. package/dist/cli/step-failure.d.ts +26 -0
  50. package/dist/cli/step-failure.d.ts.map +1 -1
  51. package/dist/cli/step-failure.js +68 -3
  52. package/dist/cli/step-failure.js.map +1 -1
  53. package/dist/cli-commands.d.ts +29 -1
  54. package/dist/cli-commands.d.ts.map +1 -1
  55. package/dist/cli-commands.js +23 -1
  56. package/dist/cli-commands.js.map +1 -1
  57. package/dist/cli.d.ts +2 -1
  58. package/dist/cli.d.ts.map +1 -1
  59. package/dist/cli.js +19 -2
  60. package/dist/cli.js.map +1 -1
  61. package/dist/cloud-http.d.ts +12 -0
  62. package/dist/cloud-http.d.ts.map +1 -1
  63. package/dist/cloud-http.js +14 -6
  64. package/dist/cloud-http.js.map +1 -1
  65. package/dist/cloud-read.d.ts +154 -0
  66. package/dist/cloud-read.d.ts.map +1 -0
  67. package/dist/cloud-read.js +373 -0
  68. package/dist/cloud-read.js.map +1 -0
  69. package/dist/cloud-transcript.d.ts +94 -0
  70. package/dist/cloud-transcript.d.ts.map +1 -0
  71. package/dist/cloud-transcript.js +342 -0
  72. package/dist/cloud-transcript.js.map +1 -0
  73. package/dist/communication/environment.d.ts +3 -0
  74. package/dist/communication/environment.d.ts.map +1 -0
  75. package/dist/communication/environment.js +34 -0
  76. package/dist/communication/environment.js.map +1 -0
  77. package/dist/communication/history.d.ts +6 -0
  78. package/dist/communication/history.d.ts.map +1 -0
  79. package/dist/communication/history.js +38 -0
  80. package/dist/communication/history.js.map +1 -0
  81. package/dist/communication/local.d.ts +6 -0
  82. package/dist/communication/local.d.ts.map +1 -0
  83. package/dist/communication/local.js +56 -0
  84. package/dist/communication/local.js.map +1 -0
  85. package/dist/communication/preflight.d.ts +6 -0
  86. package/dist/communication/preflight.d.ts.map +1 -0
  87. package/dist/communication/preflight.js +37 -0
  88. package/dist/communication/preflight.js.map +1 -0
  89. package/dist/communication/projection.d.ts +8 -0
  90. package/dist/communication/projection.d.ts.map +1 -0
  91. package/dist/communication/projection.js +23 -0
  92. package/dist/communication/projection.js.map +1 -0
  93. package/dist/communication/relay.d.ts +78 -0
  94. package/dist/communication/relay.d.ts.map +1 -0
  95. package/dist/communication/relay.js +89 -0
  96. package/dist/communication/relay.js.map +1 -0
  97. package/dist/communication/session.d.ts +24 -0
  98. package/dist/communication/session.d.ts.map +1 -0
  99. package/dist/communication/session.js +100 -0
  100. package/dist/communication/session.js.map +1 -0
  101. package/dist/communication/spec.d.ts +20 -0
  102. package/dist/communication/spec.d.ts.map +1 -0
  103. package/dist/communication/spec.js +113 -0
  104. package/dist/communication/spec.js.map +1 -0
  105. package/dist/communication/tool.d.ts +3 -0
  106. package/dist/communication/tool.d.ts.map +1 -0
  107. package/dist/communication/tool.js +23 -0
  108. package/dist/communication/tool.js.map +1 -0
  109. package/dist/communication/tools.d.ts +11 -0
  110. package/dist/communication/tools.d.ts.map +1 -0
  111. package/dist/communication/tools.js +65 -0
  112. package/dist/communication/tools.js.map +1 -0
  113. package/dist/communication/worker.d.ts +6 -0
  114. package/dist/communication/worker.d.ts.map +1 -0
  115. package/dist/communication/worker.js +120 -0
  116. package/dist/communication/worker.js.map +1 -0
  117. package/dist/compile.d.ts.map +1 -1
  118. package/dist/compile.js +8 -1
  119. package/dist/compile.js.map +1 -1
  120. package/dist/failure-kinds.d.ts +16 -1
  121. package/dist/failure-kinds.d.ts.map +1 -1
  122. package/dist/failure-kinds.js +1 -0
  123. package/dist/failure-kinds.js.map +1 -1
  124. package/dist/index.d.ts +3 -1
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +2 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/journal-client.d.ts +4 -1
  129. package/dist/journal-client.d.ts.map +1 -1
  130. package/dist/journal-client.js +11 -2
  131. package/dist/journal-client.js.map +1 -1
  132. package/dist/preflight.d.ts +13 -2
  133. package/dist/preflight.d.ts.map +1 -1
  134. package/dist/preflight.js +23 -6
  135. package/dist/preflight.js.map +1 -1
  136. package/dist/protocol.d.ts +39 -1
  137. package/dist/protocol.d.ts.map +1 -1
  138. package/dist/spec.d.ts +8 -0
  139. package/dist/spec.d.ts.map +1 -1
  140. package/dist/spec.js.map +1 -1
  141. package/dist/worker-cli.d.ts.map +1 -1
  142. package/dist/worker-cli.js +29 -50
  143. package/dist/worker-cli.js.map +1 -1
  144. package/dist/worker.d.ts +1 -0
  145. package/dist/worker.d.ts.map +1 -1
  146. package/dist/worker.js +10 -1
  147. package/dist/worker.js.map +1 -1
  148. package/dist/wrapper-session.d.ts +8 -0
  149. package/dist/wrapper-session.d.ts.map +1 -1
  150. package/dist/wrapper-session.js +156 -23
  151. package/dist/wrapper-session.js.map +1 -1
  152. package/package.json +14 -2
  153. package/src/agent-artifacts.ts +27 -11
  154. package/src/authored-flow-error.ts +17 -0
  155. package/src/authored-flow-executor.ts +4 -1
  156. package/src/authored-node-entry.ts +5 -1
  157. package/src/authored-node-runner.ts +33 -1
  158. package/src/authored-root.ts +2 -1
  159. package/src/authored-source-authority.ts +13 -0
  160. package/src/authored-step-index.ts +157 -0
  161. package/src/authored-step-output.ts +66 -14
  162. package/src/authored-worker-step.ts +88 -15
  163. package/src/cli/check.ts +69 -11
  164. package/src/cli/cloud-read.ts +518 -0
  165. package/src/cli/run.ts +43 -25
  166. package/src/cli/status.ts +20 -0
  167. package/src/cli/step-failure.ts +68 -3
  168. package/src/cli-commands.ts +23 -1
  169. package/src/cli.ts +20 -1
  170. package/src/cloud-http.ts +32 -6
  171. package/src/cloud-read.ts +515 -0
  172. package/src/cloud-transcript.ts +422 -0
  173. package/src/communication/environment.ts +33 -0
  174. package/src/communication/history.ts +38 -0
  175. package/src/communication/local.ts +41 -0
  176. package/src/communication/preflight.ts +34 -0
  177. package/src/communication/projection.ts +23 -0
  178. package/src/communication/relay.ts +102 -0
  179. package/src/communication/session.ts +84 -0
  180. package/src/communication/spec.ts +106 -0
  181. package/src/communication/tool.ts +22 -0
  182. package/src/communication/tools.ts +50 -0
  183. package/src/communication/worker.ts +95 -0
  184. package/src/compile.ts +4 -1
  185. package/src/failure-kinds.ts +16 -0
  186. package/src/index.ts +10 -1
  187. package/src/journal-client.ts +12 -2
  188. package/src/preflight.ts +36 -7
  189. package/src/protocol.ts +15 -0
  190. package/src/spec.ts +2 -0
  191. package/src/worker-cli.ts +28 -47
  192. package/src/worker.ts +10 -0
  193. 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,25 +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
- + (details.transcriptPath === undefined ? '' : `\nTranscript: ${details.transcriptPath}`);
813
- }
814
-
815
833
  function throwIfCanceled(signal: AbortSignal | undefined, stepId: string): void {
816
834
  if (signal?.aborted === true) throw new Error(`waiting for running step "${stepId}" was canceled`);
817
835
  }
package/src/cli/status.ts CHANGED
@@ -22,6 +22,15 @@ export interface StatusArgs {
22
22
  dataDir?: string;
23
23
  tail?: number;
24
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;
25
34
  }
26
35
 
27
36
  export const DEFAULT_TAIL_LINES = 20;
@@ -53,6 +62,7 @@ export interface StatusOptions {
53
62
 
54
63
  export function parseStatusArgs(args: readonly string[]): StatusArgs | undefined {
55
64
  let json = false;
65
+ let cloud = false;
56
66
  let dataDir: string | undefined;
57
67
  let tail: number | undefined;
58
68
  const positionals: string[] = [];
@@ -61,6 +71,9 @@ export function parseStatusArgs(args: readonly string[]): StatusArgs | undefined
61
71
  if (argument === '--json') {
62
72
  if (json) return undefined;
63
73
  json = true;
74
+ } else if (argument === '--cloud') {
75
+ if (cloud) return undefined;
76
+ cloud = true;
64
77
  } else if (argument === '--data-dir' || argument === '--tail') {
65
78
  const value = args[++index];
66
79
  if (value === undefined || value.length === 0 || value.startsWith('-')) return undefined;
@@ -78,8 +91,15 @@ export function parseStatusArgs(args: readonly string[]): StatusArgs | undefined
78
91
  }
79
92
  }
80
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;
81
100
  return {
82
101
  command: 'status', json,
102
+ ...(cloud ? { cloud: true as const } : {}),
83
103
  ...(dataDir === undefined ? {} : { dataDir }),
84
104
  ...(tail === undefined ? {} : { tail }),
85
105
  ...(positionals[0] === undefined ? {} : { runId: positionals[0] }),
@@ -34,18 +34,33 @@ export async function stepFailureDetails(
34
34
  const snapshot = await client.runGet(runId);
35
35
  let fromSeq = 1;
36
36
  const failures = new Map<string, StepFailedDetails>();
37
+ // `runGet`'s `StepSnapshot` carries only `type`, `state` and an optional
38
+ // lease deadline — neither the attempt number nor the attempt budget. Both
39
+ // are journal facts: the envelope's `attempt` counts the attempt, and
40
+ // `step.attempt.started` records the `max_iterations` the kernel is
41
+ // enforcing (relayflowd-core/src/entry.rs `AttemptStartedPayload`). They are
42
+ // collected on the same walk and reported only when the journal held them.
43
+ const budgets = new Map<string, number>();
37
44
  while (true) {
38
45
  const { entries } = await client.journalRead(runId, fromSeq, 100);
39
46
  if (entries.length === 0) break;
40
47
  for (const raw of entries) {
41
48
  const entry = record(raw);
42
49
  const seq = entry?.['seq'];
43
- if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < fromSeq) {
50
+ if (entry === undefined || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < fromSeq) {
44
51
  throw new Error('invalid journal sequence in step failure inspection');
45
52
  }
46
53
  fromSeq = seq + 1;
47
- const stepId = entry?.['step_id'];
48
- if (entry?.['entry_type'] !== 'step.completed' || typeof stepId !== 'string') continue;
54
+ const stepId = entry['step_id'];
55
+ if (typeof stepId !== 'string') continue;
56
+ if (entry['entry_type'] === 'step.attempt.started') {
57
+ const maxIterations = record(entry['payload'])?.['max_iterations'];
58
+ if (typeof maxIterations === 'number' && Number.isSafeInteger(maxIterations) && maxIterations > 0) {
59
+ budgets.set(stepId, maxIterations);
60
+ }
61
+ continue;
62
+ }
63
+ if (entry['entry_type'] !== 'step.completed') continue;
49
64
  // A later completion supersedes an earlier failed attempt.
50
65
  failures.delete(stepId);
51
66
  const payload = record(entry['payload']);
@@ -59,10 +74,14 @@ export async function stepFailureDetails(
59
74
  if (payload['disposition'] !== 'step_done'
60
75
  || typeof completionReason !== 'string' || completionReason === 'success') continue;
61
76
  const stepType = snapshot.steps[stepId]?.type;
77
+ const attempt = entry['attempt'];
78
+ const maxIterations = budgets.get(stepId);
62
79
  failures.set(stepId, {
63
80
  stepId,
64
81
  completionReason,
65
82
  ...(stepType === undefined ? {} : { stepType }),
83
+ ...(typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt > 0 ? { attempt } : {}),
84
+ ...(maxIterations === undefined ? {} : { maxIterations }),
66
85
  ...evidence(payload),
67
86
  });
68
87
  }
@@ -70,6 +89,52 @@ export async function stepFailureDetails(
70
89
  return [...failures.values()].at(-1);
71
90
  }
72
91
 
92
+ /**
93
+ * The evidence half of a `step_failed` diagnostic; `renderInspection` adds the
94
+ * rest. Each field is printed only when the journal actually carried it — an
95
+ * agent step has no exit code to report, and inventing `exit=undefined` (which
96
+ * is what the deterministic-only version printed for one) is worse than
97
+ * silence on that field.
98
+ *
99
+ * `attempt=N/M` is here for `retries_exhausted`, which is the kernel's terminal
100
+ * reason for "the attempt budget was consumed" (kernel/DESIGN.md) and is
101
+ * accurate kernel vocabulary that reads in English as though retries happened.
102
+ * A deterministic step gets the default budget of one, so a single failed
103
+ * attempt with no retry at all terminates as `retries_exhausted`. The
104
+ * vocabulary does not change (AGENTS.md rule 7); printing the facts beside it
105
+ * stops it being misread. Unknown values are omitted rather than defaulted —
106
+ * an absent budget must not be reported as one attempt.
107
+ *
108
+ * Lives next to `StepFailedDetails`'s extractor rather than in `cli/run.ts` so
109
+ * the authored `f.run` path can render the same grammar without importing the
110
+ * run lifecycle (which would be a cycle).
111
+ */
112
+ export function renderStepEvidence(details: StepFailedDetails): string {
113
+ return ` Step ${JSON.stringify(details.stepId)}`
114
+ + (details.stepType === undefined ? '' : ` (${details.stepType})`)
115
+ + ` completionReason: ${details.completionReason}`
116
+ + renderAttempt(details)
117
+ + (details.exitCode === undefined ? '' : ` exit=${details.exitCode}`)
118
+ + '.'
119
+ + (details.detail === undefined ? '' : `\nDetail: ${details.detail}`)
120
+ + (details.stdoutTail ? `\nStdout (last 1,024 bytes):\n${details.stdoutTail}` : '')
121
+ + (details.stderrTail ? `\nStderr (last 1,024 bytes):\n${details.stderrTail}` : '')
122
+ + (details.transcriptPath === undefined ? '' : `\nTranscript: ${details.transcriptPath}`);
123
+ }
124
+
125
+ function renderAttempt(details: StepFailedDetails): string {
126
+ if (details.attempt === undefined) return '';
127
+ return ` attempt=${details.attempt}`
128
+ + (details.maxIterations === undefined ? '' : `/${details.maxIterations}`);
129
+ }
130
+
131
+ /** The `Inspect:`/`Journal:` tail every `step_failed` message ends with. */
132
+ export function renderInspection(where: { hint?: string; journalPath?: string }): string {
133
+ if (where.hint === undefined) return '';
134
+ return `\nInspect: ${where.hint}`
135
+ + (where.journalPath === undefined ? '' : `\nJournal: ${where.journalPath}`);
136
+ }
137
+
73
138
  /**
74
139
  * Where to look, derived from the run id and data dir ALONE.
75
140
  *
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_DATA_DIR } from './daemon-connection.js';
2
+ import { DEFAULT_RUN_LIMIT } from './cli/cloud-read.js';
2
3
  import type { ParsedArgs } from './cli.js';
3
4
 
4
5
  /**
@@ -181,6 +182,17 @@ export const CLI_VERBS = [
181
182
  ],
182
183
  variants: ['hn-monitor'],
183
184
  },
185
+ {
186
+ name: 'logs',
187
+ description: 'Read a hosted run’s runner log, or one agent step’s transcript, from Cloud',
188
+ args: [{ name: 'run-id', description: 'Hosted run id, as `flows runs` lists it', required: true }],
189
+ options: [
190
+ { flags: '--step <name>', description: 'Show that agent step’s transcript instead of the runner log' },
191
+ { flags: '--raw', description: 'Print the transcript JSONL unrendered (still redacted)' },
192
+ JSON_OPTION,
193
+ ],
194
+ variants: ['logs'],
195
+ },
184
196
  {
185
197
  name: 'observer',
186
198
  description: 'Mint a read-only observer link without running a flow',
@@ -231,6 +243,15 @@ export const CLI_VERBS = [
231
243
  ],
232
244
  variants: ['run', 'cloud-run'],
233
245
  },
246
+ {
247
+ name: 'runs',
248
+ description: 'List the hosted runs this Cloud credential can read, newest first',
249
+ options: [
250
+ { flags: '--limit <n>', description: 'How many runs to show', defaultValue: DEFAULT_RUN_LIMIT },
251
+ JSON_OPTION,
252
+ ],
253
+ variants: ['runs'],
254
+ },
234
255
  {
235
256
  name: 'schedule',
236
257
  description: 'Register a flow to run in Cloud on a cron or interval, or on the one it declares',
@@ -267,12 +288,13 @@ export const CLI_VERBS = [
267
288
  },
268
289
  {
269
290
  name: 'status',
270
- description: 'Show a run’s steps, attempts, leases and last verdicts from its journal; offline, no daemon',
291
+ description: 'Show a run’s steps, attempts, leases and last verdicts from its journal, or from Cloud with --cloud',
271
292
  args: [{ name: 'run-id', description: 'Run to inspect; defaults to RELAYFLOW_RUN_ID inside a step', required: false }],
272
293
  options: [
273
294
  JSON_OPTION,
274
295
  DATA_DIR_OPTION,
275
296
  { flags: '--tail <n>', description: 'Lines of each agent attempt’s transcript tail to show' },
297
+ { flags: '--cloud', description: 'Read the run from Cloud instead of a local journal; needs the run id' },
276
298
  ],
277
299
  variants: ['status'],
278
300
  },
package/src/cli.ts CHANGED
@@ -25,6 +25,10 @@ import { parseWebhookArgs, runServeWebhook } from './cli/serve-webhook.js';
25
25
  import { runDirectFlow } from './cli/direct-run.js';
26
26
  import { parseReplayArgs, replayJournal, type ReplayArgs } from './cli/replay.js';
27
27
  import { parseStatusArgs, runStatus, type StatusArgs } from './cli/status.js';
28
+ import {
29
+ parseLogsArgs, parseRunsArgs, runCloudLogsCli, runCloudRunsCli, runCloudStatusCli,
30
+ type LogsArgs, type RunsArgs,
31
+ } from './cli/cloud-read.js';
28
32
  import { transcriptTailSource } from './transcript-tail.js';
29
33
  import { checkTypeScriptFlow } from './cli/check-typescript.js';
30
34
  import { runCloudCli } from './cli/cloud-run.js';
@@ -77,6 +81,8 @@ export type ParsedArgs =
77
81
  | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
78
82
  | { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
79
83
  | { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
84
+ | RunsArgs
85
+ | LogsArgs
80
86
  | { command: 'observer'; dataDir: string }
81
87
  | { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined }
82
88
  | { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string;
@@ -108,6 +114,9 @@ const USAGE = [
108
114
  'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
109
115
  'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
110
116
  'flows status [--json] [--data-dir <dir>] [--tail <n>] [<run-id>]',
117
+ 'flows status --cloud [--json] <run-id>',
118
+ 'flows runs [--limit <n>] [--json]',
119
+ 'flows logs [--step <name>] [--raw] [--json] <run-id>',
111
120
  'flows observer [--data-dir <dir>]',
112
121
  'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
113
122
  ].join('\n');
@@ -201,7 +210,15 @@ export async function runCli(
201
210
  if (parsed.command === 'replay') return replayJournal(parsed, io);
202
211
  // Daemon-free like `check`: reads one journal file and nothing else, so it
203
212
  // works inside a step of a run whose daemon is gone (kernel/DAEMON-LIFECYCLE.md §4).
204
- if (parsed.command === 'status') return runStatus(parsed, io, { tails: transcriptTailSource() });
213
+ if (parsed.command === 'status') {
214
+ // One verb, two sources. `--cloud` never reaches `runStatus`, so the
215
+ // offline reader stays offline (cli/status.ts).
216
+ return parsed.cloud === true
217
+ ? runCloudStatusCli(parsed, io)
218
+ : runStatus(parsed, io, { tails: transcriptTailSource() });
219
+ }
220
+ if (parsed.command === 'runs') return runCloudRunsCli(parsed, io);
221
+ if (parsed.command === 'logs') return runCloudLogsCli(parsed, io);
205
222
  if (parsed.command === 'answer') {
206
223
  const execution = await answerFlow(parsed.runId, parsed.waitId, parsed.answer, parsed.dataDir, {
207
224
  ...(parsed.note === undefined ? {} : { note: parsed.note }),
@@ -506,6 +523,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
506
523
  if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
507
524
  if (command === 'replay') return parseReplayArgs(args.slice(1));
508
525
  if (command === 'status') return parseStatusArgs(args.slice(1));
526
+ if (command === 'runs') return parseRunsArgs(args.slice(1));
527
+ if (command === 'logs') return parseLogsArgs(args.slice(1));
509
528
  if (command === 'build') return parseBuildArgs(args.slice(1));
510
529
  if (command === 'deploy') {
511
530
  // The positional decides the form: an authored source deploys a hosted
package/src/cloud-http.ts CHANGED
@@ -21,7 +21,26 @@ export interface CloudRefusal {
21
21
  received?: { packageName?: string; version?: string };
22
22
  }
23
23
 
24
+ /**
25
+ * Narrows a `configuration` failure to the thing that was wrong.
26
+ *
27
+ * `configuration` has always covered every local misconfiguration, and its
28
+ * message says which. A caller that must answer with a *code* — the read
29
+ * verbs print `REFUSED [cloud_auth_missing]` and name `agent-relay cloud
30
+ * login` — cannot get that from prose without matching on it. Optional and
31
+ * additive: every existing `catch` on `code === 'configuration'` is unchanged.
32
+ */
33
+ export type CloudConfigurationReason =
34
+ | 'auth_missing'
35
+ | 'auth_expired'
36
+ | 'url_invalid'
37
+ | 'url_mismatch'
38
+ | 'timeout_invalid';
39
+
24
40
  export class CloudFlowError extends Error {
41
+ /** Set only for `configuration`; see {@link CloudConfigurationReason}. */
42
+ reason?: CloudConfigurationReason;
43
+
25
44
  constructor(
26
45
  readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error'
27
46
  | 'transport_error' | 'transient_error' | 'unsupported_storage_backend' | 'sync_too_large' | 'sync_unsupported'
@@ -35,6 +54,13 @@ export class CloudFlowError extends Error {
35
54
  }
36
55
  }
37
56
 
57
+ /** A `configuration` refusal that also carries its {@link CloudConfigurationReason}. */
58
+ function configurationError(reason: CloudConfigurationReason, message: string): CloudFlowError {
59
+ const error = new CloudFlowError('configuration', message);
60
+ error.reason = reason;
61
+ return error;
62
+ }
63
+
38
64
  /**
39
65
  * The `agent-relay cloud login` credential store. Read only when neither the
40
66
  * `token` option nor `FLOWS_CLOUD_TOKEN` is set, so an explicit credential
@@ -79,7 +105,7 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
79
105
  if (login !== undefined) {
80
106
  const expiresAt = login.accessTokenExpiresAt === undefined ? Number.NaN : Date.parse(login.accessTokenExpiresAt);
81
107
  if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
82
- throw new CloudFlowError('configuration',
108
+ throw configurationError('auth_expired',
83
109
  'The agent-relay cloud login has expired. Run `agent-relay cloud login` again, or set FLOWS_CLOUD_TOKEN.');
84
110
  }
85
111
  rawToken = login.accessToken;
@@ -88,7 +114,7 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
88
114
  }
89
115
  const token = rawToken?.trim();
90
116
  if (!token || /[\r\n]/u.test(rawToken!) || /^(?:rk|ot)_live_/u.test(token)) {
91
- throw new CloudFlowError('configuration',
117
+ throw configurationError('auth_missing',
92
118
  'Set FLOWS_CLOUD_TOKEN to a scoped Cloud API token (workflow:invoke:write and workflow:runs:read), '
93
119
  + 'or sign in with `agent-relay cloud login`.');
94
120
  }
@@ -96,11 +122,11 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
96
122
  try {
97
123
  url = new URL(options.apiUrl ?? process.env['FLOWS_CLOUD_URL'] ?? loginApiUrl ?? 'https://agentrelay.com/cloud');
98
124
  } catch {
99
- throw new CloudFlowError('configuration', 'FLOWS_CLOUD_URL must be an absolute Cloud application base URL.');
125
+ throw configurationError('url_invalid', 'FLOWS_CLOUD_URL must be an absolute Cloud application base URL.');
100
126
  }
101
127
  if (url.protocol !== 'https:'
102
128
  || url.username || url.password || url.search || url.hash || !/^\/[A-Za-z0-9/_-]*$/u.test(url.pathname)) {
103
- throw new CloudFlowError('configuration', 'Cloud URL must use HTTPS and a plain base path.');
129
+ throw configurationError('url_invalid', 'Cloud URL must use HTTPS and a plain base path.');
104
130
  }
105
131
  const baseUrl = `${url.origin}${url.pathname.replace(/\/+$/u, '')}`;
106
132
  // A login-store token is bound to the deployment that issued it. An explicit
@@ -113,7 +139,7 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
113
139
  issued = `${login.origin}${login.pathname.replace(/\/+$/u, '')}`;
114
140
  } catch { issued = undefined; }
115
141
  if (issued !== baseUrl) {
116
- throw new CloudFlowError('configuration',
142
+ throw configurationError('url_mismatch',
117
143
  `The agent-relay cloud login was issued for ${loginApiUrl}, not ${baseUrl}. `
118
144
  + 'Set FLOWS_CLOUD_TOKEN for that deployment, or unset FLOWS_CLOUD_URL to use the login.');
119
145
  }
@@ -159,7 +185,7 @@ export async function cloudFetch(
159
185
  const { baseUrl, token } = cloudConnection(options);
160
186
  const timeout = options.requestTimeoutMs ?? 30_000;
161
187
  if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) {
162
- throw new CloudFlowError('configuration', 'requestTimeoutMs must be a positive 32-bit integer.');
188
+ throw configurationError('timeout_invalid', 'requestTimeoutMs must be a positive 32-bit integer.');
163
189
  }
164
190
  const bearer = init.bearerToken ?? token;
165
191
  if (/[\r\n]/u.test(bearer) || !bearer.trim()) {