@relayflows/sdk 2.0.22 → 2.0.24

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
@@ -46,7 +46,20 @@ function installPinnedSourceLoader(sources: Map<string, string>): void {
46
46
  pinnedSourceLoaderInstalled = true;
47
47
  // Node >=22.14 supports register and stripTypeScriptTypes. A loader thread
48
48
  // receives the captured bytes, never reopening mutable authored paths.
49
+ // The `stripTypeScriptTypes` call below makes Node emit
50
+ // `ExperimentalWarning: stripTypeScriptTypes …` on the child's inherited
51
+ // stderr, where a bootstrap that reads the CLI's stderr takes it for the
52
+ // error text. It is emitted on this loader thread, so a listener on the
53
+ // main thread cannot intercept it, and `--disable-warning` on the child
54
+ // would silence every ExperimentalWarning the authored body raises too.
55
+ // Dropping it by its own text, on this thread only, is the narrow form:
56
+ // Node's own printer still handles every other warning.
49
57
  const loader = `
58
+ const emitWarning = process.emitWarning.bind(process);
59
+ process.emitWarning = (warning, ...rest) => {
60
+ if (rest[0] === 'ExperimentalWarning' && String(warning).startsWith('stripTypeScriptTypes')) return;
61
+ emitWarning(warning, ...rest);
62
+ };
50
63
  import {stripTypeScriptTypes} from 'node:module';
51
64
  let sources;
52
65
  export function initialize(data){sources=new Map(data);}
@@ -0,0 +1,157 @@
1
+ import type { JournalClient } from './journal-client.js';
2
+
3
+ /**
4
+ * The root run's durable index of the child runs an authored body opened.
5
+ *
6
+ * An authored flow is not one kernel run: every `f.run`, `f.agent`, `f.llm`
7
+ * and lowered gate is its own single-step child run with its own
8
+ * `<dataDir>/runs/<runId>.sqlite3`. The only thing that ties a child back to
9
+ * the root id a caller (or Cloud) knows is an index — and until now that index
10
+ * existed only in the root's SUCCESS completion output
11
+ * (`authored-root.ts` `driveRoot`). A failed body took
12
+ * `terminalizeRootFailure`, which journals `{ error }` and nothing else, so a
13
+ * failed run named no child at all. A failed run is exactly when its children
14
+ * are worth reading.
15
+ *
16
+ * The completion output could not be made to carry it either: the root is
17
+ * lowered as `type: 'agent'`, and the kernel nulls a failed agent's `output`
18
+ * (`preserve_failure_output`, relayflowd-core/src/machine.rs), preserving only
19
+ * a 2,000-character render in `verification.detail`. So the index is written
20
+ * where a failure cannot erase it — appended to a stream on the ROOT run as
21
+ * each child is opened, using the same `journal.streamAppend` durability the
22
+ * executor already relies on for predicate verdicts.
23
+ *
24
+ * Each record is bounded on its own rather than the index being truncated as
25
+ * one blob: a hundredth child must not push the first one out of the record.
26
+ */
27
+ export const AUTHORED_STEP_STREAM = 'authored-steps';
28
+
29
+ /** Versioned so a reader can tell this record from a future one. */
30
+ const RECORD_KIND = 'relayflows.authored-step.v1';
31
+
32
+ /**
33
+ * Per-field cap. Step ids and run ids are short by construction; the cap
34
+ * exists so a hostile or buggy id cannot make one record unbounded.
35
+ */
36
+ const FIELD_CHARS = 256;
37
+
38
+ /**
39
+ * `admitted` and `completed` are kept distinct on purpose. `admitted` says
40
+ * only "this child run exists and here is where to read it" — it never
41
+ * carries a `completionReason`, because manufacturing one for an attempt
42
+ * that has not completed would turn an unfinished child into a false fact.
43
+ * `completed` is written only from a terminal `step.completed` the journal
44
+ * actually holds.
45
+ */
46
+ export interface AuthoredStepRecord {
47
+ readonly index: typeof RECORD_KIND;
48
+ readonly step: string;
49
+ readonly runId: string;
50
+ readonly state: 'admitted' | 'completed';
51
+ /** Present only on `completed`. */
52
+ readonly completionReason?: string;
53
+ /**
54
+ * The kernel step the completion came from, when it is not the authored
55
+ * operation's own id. A child spec has two steps once a named gate is
56
+ * lowered, so the terminal failure can be `<step>.verify` rather than
57
+ * `<step>` — saying which is the difference between an index and a guess.
58
+ */
59
+ readonly kernelStep?: string;
60
+ }
61
+
62
+ /**
63
+ * Append one record, or do nothing when there is no root to append to.
64
+ *
65
+ * The undefined-root case is the non-durable executor seam (`executeAuthoredFlow`
66
+ * called without `rootRunId`), which has no root run and therefore nothing to
67
+ * index. It is not a silent fallback: there is no durable place for the record
68
+ * to go, and the caller already knows it opted out of one.
69
+ *
70
+ * Append failures propagate (AGENTS.md rule 4). Callers on a path that is
71
+ * already failing must surface the persistence error ALONGSIDE the original
72
+ * failure rather than letting it replace it — see `alsoRecord`.
73
+ */
74
+ export async function recordAuthoredChild(
75
+ journal: JournalClient,
76
+ rootRunId: string | undefined,
77
+ record: Omit<AuthoredStepRecord, 'index'>,
78
+ ): Promise<void> {
79
+ if (rootRunId === undefined) return;
80
+ await journal.streamAppend(rootRunId, AUTHORED_STEP_STREAM, {
81
+ index: RECORD_KIND,
82
+ step: bound(record.step),
83
+ runId: bound(record.runId),
84
+ state: record.state,
85
+ ...(record.completionReason === undefined ? {} : { completionReason: bound(record.completionReason) }),
86
+ ...(record.kernelStep === undefined || record.kernelStep === record.step
87
+ ? {} : { kernelStep: bound(record.kernelStep) }),
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Record a child on a path that is already throwing, keeping both facts.
93
+ *
94
+ * A failed append must not be relabelled as successful collection, and it must
95
+ * not replace the failure the caller is about to report. It returns the note
96
+ * to append to that failure's message instead.
97
+ */
98
+ export async function alsoRecord(
99
+ journal: JournalClient,
100
+ rootRunId: string | undefined,
101
+ record: Omit<AuthoredStepRecord, 'index'>,
102
+ ): Promise<string> {
103
+ try {
104
+ await recordAuthoredChild(journal, rootRunId, record);
105
+ return '';
106
+ } catch (error) {
107
+ return `\nCould not persist the step index: ${error instanceof Error ? error.message : String(error)}`;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Fold the root's stream back into one record per authored step.
113
+ *
114
+ * A `completed` record supersedes the `admitted` one for the same step; first
115
+ * admission order is preserved so the index reads in the order the body ran.
116
+ * Records this version does not recognise are skipped rather than trusted.
117
+ */
118
+ export async function readAuthoredStepIndex(
119
+ journal: JournalClient,
120
+ rootRunId: string,
121
+ ): Promise<AuthoredStepRecord[]> {
122
+ const index = new Map<string, AuthoredStepRecord>();
123
+ let offset = 0;
124
+ for (;;) {
125
+ const page = await journal.streamRead(rootRunId, AUTHORED_STEP_STREAM, offset, 1000);
126
+ for (const message of page.messages) {
127
+ // `stream.read` returns either the envelope or the bare message,
128
+ // matching how the executor reads predicate verdicts.
129
+ const raw = (message as { message?: unknown }).message ?? message;
130
+ if (!isAuthoredStepRecord(raw)) continue;
131
+ const previous = index.get(raw.step);
132
+ // An `admitted` record arriving after a `completed` one (a resumed body
133
+ // re-admitting the same child under its stable admission key) must not
134
+ // un-complete it.
135
+ if (previous?.state === 'completed' && raw.state === 'admitted') continue;
136
+ index.set(raw.step, raw);
137
+ }
138
+ if (page.messages.length === 0 || page.next_offset <= offset) break;
139
+ offset = page.next_offset;
140
+ }
141
+ return [...index.values()];
142
+ }
143
+
144
+ function isAuthoredStepRecord(value: unknown): value is AuthoredStepRecord {
145
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
146
+ const record = value as Partial<AuthoredStepRecord>;
147
+ return record.index === RECORD_KIND
148
+ && typeof record.step === 'string' && record.step.length > 0
149
+ && typeof record.runId === 'string' && record.runId.length > 0
150
+ && (record.state === 'admitted' || record.state === 'completed')
151
+ && (record.completionReason === undefined || typeof record.completionReason === 'string')
152
+ && (record.kernelStep === undefined || typeof record.kernelStep === 'string');
153
+ }
154
+
155
+ function bound(value: string): string {
156
+ return value.length <= FIELD_CHARS ? value : `${value.slice(0, FIELD_CHARS)}…`;
157
+ }
@@ -3,6 +3,21 @@ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
3
3
  import type { JournalClient } from './journal-client.js';
4
4
  import type { CompletionReason as ProtocolCompletionReason, RunCompletionReason as ProtocolRunCompletionReason, RunOutcome } from './protocol.js';
5
5
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
6
+ import type { StepFailedDetails } from './failure-kinds.js';
7
+ import { inspectionHint, renderInspection, renderStepEvidence, stepFailureDetails } from './cli/step-failure.js';
8
+ import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
9
+
10
+ /**
11
+ * What an authored operation needs in order to leave readable evidence:
12
+ * the root run its child index belongs to, and the data dir whose journals
13
+ * a reader would open. Both optional — the non-durable executor seam has no
14
+ * root, and a caller that did not name a data dir still gets the `flows
15
+ * replay <run-id>` hint, just without the on-disk path.
16
+ */
17
+ export interface AuthoredStepContext {
18
+ readonly rootRunId?: string;
19
+ readonly dataDir?: string;
20
+ }
6
21
 
7
22
  /**
8
23
  * Shared by every `f.*` verb that lowers to one kernel step run in isolation:
@@ -18,12 +33,23 @@ import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
18
33
  * terminal state. Given that, a single read from the start of this run's
19
34
  * (small, single-step) journal is enough — no polling here, and no run
20
35
  * outcome ever needs re-checking.
36
+ *
37
+ * A non-success completion used to throw `journal step "run-1" completed with
38
+ * retries_exhausted` and stop there, discarding the evidence it was holding:
39
+ * the kernel PRESERVES a failed deterministic step's `output`
40
+ * (`preserve_failure_output`, relayflowd-core/src/machine.rs), so
41
+ * `{exit_code, stdout_tail, stderr_tail}` — the answer to "which command
42
+ * failed and what did it print" — was in hand and dropped. Four different
43
+ * causes produced that one identical line. It now reads the same extractor
44
+ * `f.agent`/`f.llm` already reach through `classifyOutcome`, so both step
45
+ * families report one grammar instead of two divergent ones.
21
46
  */
22
47
  export async function readCompletedStepOutput(
23
48
  journal: JournalClient,
24
49
  runId: string,
25
50
  stepId: string,
26
51
  journalSteps: AuthoredFlowJournalStep[],
52
+ context: AuthoredStepContext = {},
27
53
  ): Promise<unknown> {
28
54
  const entries = (await journal.journalRead(runId, 1)).entries;
29
55
  const completed = entries.find((entry) => isStepCompleted(entry, stepId));
@@ -34,13 +60,29 @@ export async function readCompletedStepOutput(
34
60
  const reason = completed.payload.completionReason;
35
61
  journalSteps.push(Object.freeze({ id: stepId, runId, completionReason: reason }));
36
62
  if (reason !== 'success') {
37
- throw new AuthoredFlowExecutionError(
38
- 'step_failed',
39
- `journal step "${stepId}" completed with ${reason}`,
40
- reason,
41
- runId,
42
- );
63
+ let message = `journal step "${stepId}" completed with ${reason}`;
64
+ let details: StepFailedDetails | undefined;
65
+ try {
66
+ details = await stepFailureDetails(journal, runId);
67
+ } catch (error) {
68
+ // Inspection must not erase the already known step failure; the two
69
+ // failures stay separately visible, as in classifyOutcome.
70
+ message += ` Could not inspect the failed step: ${errorMessage(error)}`;
71
+ }
72
+ if (details !== undefined) message += renderStepEvidence(details);
73
+ // Appended whatever inspection found — including nothing. A failure shape
74
+ // this reader does not recognise must still end with somewhere to go.
75
+ const where = inspectionHint(runId, details?.stepId ?? stepId, context.dataDir);
76
+ message += renderInspection(where);
77
+ message += await alsoRecord(journal, context.rootRunId, {
78
+ step: stepId, runId, state: 'completed', completionReason: reason,
79
+ ...(details?.stepId === undefined ? {} : { kernelStep: details.stepId }),
80
+ });
81
+ throw new AuthoredFlowExecutionError('step_failed', message, reason, runId, { ...details, ...where });
43
82
  }
83
+ await recordAuthoredChild(journal, context.rootRunId, {
84
+ step: stepId, runId, state: 'completed', completionReason: reason,
85
+ });
44
86
  return completed.payload.output;
45
87
  }
46
88
 
@@ -49,15 +91,21 @@ export async function readSuccessfulOutput(
49
91
  outcome: RunOutcome,
50
92
  stepId: string,
51
93
  journalSteps: AuthoredFlowJournalStep[],
94
+ context: AuthoredStepContext = {},
52
95
  ): Promise<string> {
53
- const output = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps).catch(error => {
54
- if (error instanceof AuthoredFlowExecutionError
55
- && (error.completionReason === 'timeout' || error.completionReason === 'lease_expired')) {
56
- throw new AuthoredFlowExecutionError('lease_exceeded',
57
- `f.run step "${stepId}" exceeded its command timeout.`, error.completionReason, error.runId);
58
- }
59
- throw error;
60
- });
96
+ const output = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps, context)
97
+ .catch(error => {
98
+ if (error instanceof AuthoredFlowExecutionError
99
+ && (error.completionReason === 'timeout' || error.completionReason === 'lease_expired')) {
100
+ // A timeout is still a failed command, and the evidence extracted for
101
+ // it is the same evidence. Re-labelling the error must not delete it.
102
+ throw new AuthoredFlowExecutionError('lease_exceeded',
103
+ `f.run step "${stepId}" exceeded its command timeout.`
104
+ + ` ${error.message.slice(`${error.code}: `.length)}`,
105
+ error.completionReason, error.runId, error.details);
106
+ }
107
+ throw error;
108
+ });
61
109
  if (!isRecord(output) || typeof output['stdout_tail'] !== 'string') {
62
110
  throw protocolViolation(outcome.run_id, `step "${stepId}" has no string stdout_tail`);
63
111
  }
@@ -102,6 +150,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
102
150
  return typeof value === 'object' && value !== null && !Array.isArray(value);
103
151
  }
104
152
 
153
+ function errorMessage(error: unknown): string {
154
+ return error instanceof Error ? error.message : String(error);
155
+ }
156
+
105
157
  function protocolViolation(runId: string, detail: string): AuthoredFlowExecutionError {
106
158
  return new AuthoredFlowExecutionError(
107
159
  'journal_protocol_violation',
@@ -3,15 +3,17 @@ import { parseBudget } from './budget.js';
3
3
  import type { AgentOptions, AgentResult, LlmOptions, NamedGate } from '@relayflows/surface';
4
4
  import { compileSpec, toKernelSpec } from './compile.js';
5
5
  import { checkAuthoredFlow } from './cli/check.js';
6
- import { classifyOutcome, type RunLifecycleOptions } from './cli/run.js';
6
+ import { classifyOutcome, type RunLifecycleOptions, type RunReport } from './cli/run.js';
7
7
  import type { PreflightDiagnostic } from './preflight.js';
8
8
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
9
9
  import type { JournalClient } from './journal-client.js';
10
10
  import { SPEC_SCHEMA_VERSION, type FlowSpec, type PermissionsSpec, type StepSpec } from './spec.js';
11
- import { isSurfaceCompletionReason, readCompletedStepOutput, readSuccessfulOutput } from './authored-step-output.js';
11
+ import { isSurfaceCompletionReason, readCompletedStepOutput, readSuccessfulOutput, type AuthoredStepContext } from './authored-step-output.js';
12
12
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
13
13
  import { snapshotJsonValue } from './json-value.js';
14
14
  import { authoredChildAdmissionKey } from './authored-admission.js';
15
+ import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
16
+ import type { StepFailedDetails } from './failure-kinds.js';
15
17
 
16
18
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
17
19
 
@@ -22,6 +24,10 @@ export function authoredWorkerRunner(
22
24
  localAgentStream?: string, budget?: AuthoredBudget, headerBudget?: unknown,
23
25
  rootRunId?: string,
24
26
  ) {
27
+ const context: AuthoredStepContext = {
28
+ ...(rootRunId === undefined ? {} : { rootRunId }),
29
+ ...(waitOptions.dataDir === undefined ? {} : { dataDir: waitOptions.dataDir }),
30
+ };
25
31
  async function run(step: StepSpec): Promise<unknown> {
26
32
  const id = step.id;
27
33
  const authoring: FlowSpec = { version: SPEC_SCHEMA_VERSION, name: `${definition.name}/${id}`, steps: [step], ...(headerBudget === undefined ? {} : { budget: parseBudget(headerBudget) }) };
@@ -46,6 +52,10 @@ export function authoredWorkerRunner(
46
52
  }
47
53
  const spec = toKernelSpec(resolved);
48
54
  const consume = async (outcome: import('./protocol.js').RunOutcome) => {
55
+ // Written BEFORE the wait, not after it. An agent runs for as long as its
56
+ // lease allows; if this process dies mid-step, this record is the only
57
+ // thing that still names the child run holding the evidence.
58
+ await recordAuthoredChild(journal, rootRunId, { step: id, runId: outcome.run_id, state: 'admitted' });
49
59
  // Reuse the declarative CLI's own wait/classification (cli/run.ts) rather
50
60
  // than a hand-rolled poll: `step.completed` and the run's own terminal
51
61
  // state are appended as two SEPARATE actions (kernel/relayflowd-core/src/machine.rs
@@ -69,17 +79,38 @@ export function authoredWorkerRunner(
69
79
  );
70
80
  }
71
81
  if (execution.exitCode !== 0) {
82
+ // `execution.report.completionReason` is the RUN's reason (normally
83
+ // `step_failed`), not the step's. The step's terminal facts are the
84
+ // ones `classifyOutcome` already extracted with `stepFailureDetails`
85
+ // and assigned onto this diagnostic — read them from there rather than
86
+ // re-walking the journal or mislabelling the run reason as a step one.
87
+ const diagnostic = execution.report.diagnostics.at(-1);
88
+ const details = stepDetails(diagnostic);
72
89
  const reason = execution.report.completionReason;
90
+ let message = diagnostic?.message
91
+ ?? `flow "${definition.name}" step "${id}" did not complete successfully `
92
+ + `(status: ${execution.report.status ?? 'unknown'})`;
93
+ // Only a reason the journal actually recorded for a step is indexed;
94
+ // an unfinished or unreadable child is left as `admitted`, never given
95
+ // a manufactured completion.
96
+ if (isSurfaceCompletionReason(details?.completionReason)) {
97
+ journalSteps.push(Object.freeze({
98
+ id, runId: outcome.run_id, completionReason: details.completionReason,
99
+ }));
100
+ message += await alsoRecord(journal, rootRunId, {
101
+ step: id, runId: outcome.run_id, state: 'completed',
102
+ completionReason: details.completionReason,
103
+ ...(details.stepId === undefined ? {} : { kernelStep: details.stepId }),
104
+ });
105
+ }
73
106
  throw new AuthoredFlowExecutionError(
74
- 'step_failed',
75
- execution.report.diagnostics.at(-1)?.message
76
- ?? `flow "${definition.name}" step "${id}" did not complete successfully `
77
- + `(status: ${execution.report.status ?? 'unknown'})`,
107
+ 'step_failed', message,
78
108
  isSurfaceCompletionReason(reason) ? reason : undefined,
79
109
  outcome.run_id,
110
+ details,
80
111
  );
81
112
  }
82
- return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps);
113
+ return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, context);
83
114
  };
84
115
  const admissionKey = authoredChildAdmissionKey(rootRunId, id);
85
116
  return budget === undefined
@@ -173,11 +204,19 @@ export function authoredWorkerRunner(
173
204
  };
174
205
  }
175
206
 
176
- /** Deterministic commands execute inline under their per-invocation lease. */
207
+ /**
208
+ * Deterministic commands execute inline under their per-invocation lease.
209
+ *
210
+ * `context` carries the root run (so each child is indexed on it) and the
211
+ * data dir (so a failure can name the journal on disk). It is the same object
212
+ * the worker runner builds; a failed `f.run` and a failed `f.agent` now report
213
+ * through one grammar.
214
+ */
177
215
  export function authoredDeterministicRunner(
178
216
  name: string, journal: JournalClient, journalSteps: AuthoredFlowJournalStep[], budget: AuthoredBudget,
179
- rootRunId?: string,
217
+ context: AuthoredStepContext = {},
180
218
  ) {
219
+ const rootRunId = context.rootRunId;
181
220
  return async (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate): Promise<string> => {
182
221
  const spec = toKernelSpec(compileSpec({
183
222
  version: SPEC_SCHEMA_VERSION,
@@ -189,11 +228,45 @@ export function authoredDeterministicRunner(
189
228
  }],
190
229
  }));
191
230
  const admissionKey = authoredChildAdmissionKey(rootRunId, id);
192
- if (terminal) return readSuccessfulOutput(
193
- journal, await journal.runStart(spec, undefined, admissionKey), id, journalSteps,
194
- );
195
- return budget.execute(
196
- journal, spec, outcome => readSuccessfulOutput(journal, outcome, id, journalSteps), admissionKey,
197
- );
231
+ const consume = async (outcome: import('./protocol.js').RunOutcome): Promise<string> => {
232
+ await recordAuthoredChild(journal, rootRunId, { step: id, runId: outcome.run_id, state: 'admitted' });
233
+ return readSuccessfulOutput(journal, outcome, id, journalSteps, context);
234
+ };
235
+ if (terminal) return consume(await journal.runStart(spec, undefined, admissionKey));
236
+ return budget.execute(journal, spec, consume, admissionKey);
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Lift the step-shaped fields off a run diagnostic.
242
+ *
243
+ * `RunDiagnostic extends StepFailedDetails`, so `classifyOutcome`'s
244
+ * `Object.assign(diagnostic, details)` leaves the extraction right there. The
245
+ * fields are picked explicitly rather than spread, so `severity`, `kind` and
246
+ * `message` — which describe the RUN, not the step — cannot leak into
247
+ * something typed as the step's evidence.
248
+ */
249
+ function stepDetails(
250
+ diagnostic: RunReport['diagnostics'][number] | undefined,
251
+ ): StepFailedDetails | undefined {
252
+ if (diagnostic === undefined) return undefined;
253
+ // `RunDiagnostic` is the only member of this union that extends
254
+ // `StepFailedDetails`; the check-report shapes carry none of these fields,
255
+ // so reading them as optionals yields `undefined` and contributes nothing.
256
+ const found = diagnostic as Partial<StepFailedDetails>;
257
+ const details: StepFailedDetails = {
258
+ ...(found.stepId === undefined ? {} : { stepId: found.stepId }),
259
+ ...(found.stepType === undefined ? {} : { stepType: found.stepType }),
260
+ ...(found.completionReason === undefined ? {} : { completionReason: found.completionReason }),
261
+ ...(found.attempt === undefined ? {} : { attempt: found.attempt }),
262
+ ...(found.maxIterations === undefined ? {} : { maxIterations: found.maxIterations }),
263
+ ...(found.exitCode === undefined ? {} : { exitCode: found.exitCode }),
264
+ ...(found.stdoutTail === undefined ? {} : { stdoutTail: found.stdoutTail }),
265
+ ...(found.stderrTail === undefined ? {} : { stderrTail: found.stderrTail }),
266
+ ...(found.detail === undefined ? {} : { detail: found.detail }),
267
+ ...(found.transcriptPath === undefined ? {} : { transcriptPath: found.transcriptPath }),
268
+ ...(found.hint === undefined ? {} : { hint: found.hint }),
269
+ ...(found.journalPath === undefined ? {} : { journalPath: found.journalPath }),
198
270
  };
271
+ return Object.keys(details).length === 0 ? undefined : details;
199
272
  }
package/src/cli/check.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { communicationInstruction } from '../communication/spec.js';
2
+ import { checkCommunicationEnvironment } from '../communication/preflight.js';
3
+ import { agentEnvironment, brokerEnvironment } from '../communication/environment.js';
1
4
  import { accessSync, constants, readFileSync } from 'node:fs';
2
5
  import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path';
3
6
  import { spawnSync } from 'node:child_process';
@@ -159,6 +162,16 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
159
162
  config.directory,
160
163
  )
161
164
  : undefined;
165
+ if (flow?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
166
+ try { checkCommunicationEnvironment(flow); }
167
+ catch (error) {
168
+ result.ok = false;
169
+ result.diagnostics.push({ severity: 'refusal', kind: 'probe_failed',
170
+ message: error instanceof Error ? error.message : 'Communication environment could not be checked.' });
171
+ }
172
+ result.diagnostics.push({ severity: 'warning', kind: 'budget_unmetered',
173
+ message: 'Managed communication sessions do not report token or dollar usage. Budget ceilings cannot bound their spend; communication.timeoutMs bounds their duration.' });
174
+ }
162
175
  return {
163
176
  report: {
164
177
  ok: result.ok,
@@ -169,7 +182,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
169
182
  diagnostics: result.diagnostics,
170
183
  requirements: safeRequirements(authoring, config.cli),
171
184
  },
172
- ...(flow !== undefined ? { flow } : {}),
185
+ ...(result.ok && flow !== undefined ? { flow } : {}),
173
186
  };
174
187
  } catch (error) {
175
188
  const failure = error instanceof CheckFailure
@@ -375,7 +388,7 @@ function findConfig(start: string): string | undefined {
375
388
  function systemProbes(flowDirectory: string, config: ProjectConfig): PreflightProbes {
376
389
  return {
377
390
  helper: helperReady,
378
- cli: (cli, source, model) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model),
391
+ cli: (cli, source, model, execution) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model, execution),
379
392
  executor: (trigger) => config.executors.includes(trigger.executor),
380
393
  // A deterministic step runs in the daemon's working directory — the
381
394
  // directory `flows run` was invoked from, or Cloud's code mount — not in
@@ -416,12 +429,22 @@ function probeCli(
416
429
  cli: string,
417
430
  directory: string,
418
431
  model?: string,
432
+ execution?: 'managed',
419
433
  ): CliProbeResult {
420
434
  const executable = resolveExecutable(cli, directory);
421
435
  if (executable === undefined) return { exists: false, authenticated: false };
422
436
  const kind = cliAdapterKind(executable);
437
+ // Relay owns interactive CLI launch/injection. Its generic PTY path is not
438
+ // the headless wrapper protocol; do not demand that protocol from Gemini,
439
+ // Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
440
+ if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
441
+ return { exists: true, supported: true, authenticated: 'unverified' };
442
+ }
443
+ const environment = execution === 'managed'
444
+ ? { ...brokerEnvironment(process.env), ...agentEnvironment(executable) } : process.env;
445
+ const probe = (invocation: CliInvocation) => runProbe(executable, directory, invocation, environment);
423
446
  const identification = adapterIdentification(kind);
424
- const identified = runProbe(executable, directory, identification.invocation);
447
+ const identified = probe(identification.invocation);
425
448
  if (
426
449
  identified.status !== 0
427
450
  || (identification.expectedStdout !== undefined
@@ -435,7 +458,7 @@ function probeCli(
435
458
  return {
436
459
  exists: true,
437
460
  supported: true,
438
- authenticated: runProbe(executable, directory, auth).status === 0,
461
+ authenticated: probe(auth).status === 0,
439
462
  authCommand,
440
463
  };
441
464
  }
@@ -445,7 +468,7 @@ function probeCli(
445
468
  // A successful real provider round trip (or identified wrapper probe)
446
469
  // proves both auth and exact-model access. On failure, run the adapter's
447
470
  // actual auth command solely to classify auth vs model access truthfully.
448
- if (runProbe(executable, directory, scoped).status === 0) {
471
+ if (probe(scoped).status === 0) {
449
472
  return {
450
473
  exists: true,
451
474
  supported: true,
@@ -455,35 +478,70 @@ function probeCli(
455
478
  modelCommand,
456
479
  };
457
480
  }
458
- const authStatus = runProbe(executable, directory, auth).status;
481
+ const authProbe = probe(auth);
482
+ const authenticated = authProbe.status === 0;
459
483
  return {
460
484
  exists: true,
461
485
  supported: true,
462
- authenticated: authStatus === 0,
486
+ authenticated,
463
487
  modelAvailable: false,
464
488
  authCommand,
465
489
  modelCommand,
490
+ // Only on failure: on success there is nothing to explain, and the output
491
+ // is the most identity-bearing thing this function touches.
492
+ ...(authenticated
493
+ ? {}
494
+ : {
495
+ authExitCode: authProbe.status,
496
+ authFailureDetail: redactProbeOutput(
497
+ `${authProbe.stderr}${authProbe.stdout}`,
498
+ ).trim().slice(0, 500),
499
+ }),
466
500
  };
467
501
  }
468
502
 
503
+ /**
504
+ * Redact anything that looks like a credential or an account identifier.
505
+ *
506
+ * `auth status` output is diagnostic, but it is also the one place an account
507
+ * email, org id or token fragment can appear. The point of surfacing it is to
508
+ * say WHY a probe failed, which survives redaction; leaking an identity into a
509
+ * refusal message that gets pasted into issues does not.
510
+ */
511
+ function redactProbeOutput(text: string): string {
512
+ return text
513
+ .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '<redacted-email>')
514
+ .replace(/\b(sk|pk|oat|rt)[-_][A-Za-z0-9._-]{8,}/gi, '<redacted-token>')
515
+ .replace(/\b[A-Fa-f0-9]{32,}\b/g, '<redacted-hex>');
516
+ }
517
+
469
518
  function runProbe(
470
519
  executable: string,
471
520
  directory: string,
472
521
  invocation: CliInvocation,
473
- ): { status: number | null; stdout: string } {
474
- const env = { ...process.env };
522
+ environment: NodeJS.ProcessEnv = process.env,
523
+ ): { status: number | null; stdout: string; stderr: string } {
524
+ const env = { ...environment };
475
525
  delete env[MODEL_ENV];
476
526
  if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
477
527
  const result = spawnSync(executable, invocation.args, {
478
528
  cwd: directory,
479
529
  encoding: 'utf8',
480
- stdio: ['ignore', 'pipe', 'ignore'],
530
+ // stderr was 'ignore'. A failing `auth status` writes its reason there, so
531
+ // discarding it made every authentication refusal structurally
532
+ // undiagnosable: the refusal could say a probe exited non-zero and never
533
+ // what it said. Captured, then redacted at the point of use.
534
+ stdio: ['ignore', 'pipe', 'pipe'],
481
535
  timeout: invocation.timeoutMs,
482
536
  env,
483
537
  });
484
538
  const failure = classifySpawnFailure(result.error, result.signal, invocation.timeoutMs);
485
539
  if (failure !== undefined) throw failure;
486
- return { status: result.status, stdout: result.stdout };
540
+ return {
541
+ status: result.status,
542
+ stdout: result.stdout,
543
+ stderr: result.stderr ?? '',
544
+ };
487
545
  }
488
546
 
489
547
  function resolveExecutable(command: string, directory: string): string | undefined {