@relayflows/sdk 2.0.16 → 2.0.18

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 (184) hide show
  1. package/dist/authored-flow-error.d.ts +21 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js +14 -0
  4. package/dist/authored-flow-error.js.map +1 -1
  5. package/dist/authored-flow-executor.d.ts.map +1 -1
  6. package/dist/authored-flow-executor.js +133 -3
  7. package/dist/authored-flow-executor.js.map +1 -1
  8. package/dist/authored-flow-lifecycle.d.ts +10 -0
  9. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  10. package/dist/authored-flow-lifecycle.js +7 -0
  11. package/dist/authored-flow-lifecycle.js.map +1 -1
  12. package/dist/authored-flow-operation.d.ts +11 -0
  13. package/dist/authored-flow-operation.d.ts.map +1 -1
  14. package/dist/authored-flow-operation.js +32 -7
  15. package/dist/authored-flow-operation.js.map +1 -1
  16. package/dist/authored-human.d.ts +49 -0
  17. package/dist/authored-human.d.ts.map +1 -0
  18. package/dist/authored-human.js +97 -0
  19. package/dist/authored-human.js.map +1 -0
  20. package/dist/authored-node-entry.js +3 -2
  21. package/dist/authored-node-entry.js.map +1 -1
  22. package/dist/authored-node-runner.d.ts.map +1 -1
  23. package/dist/authored-node-runner.js +52 -18
  24. package/dist/authored-node-runner.js.map +1 -1
  25. package/dist/authored-root.d.ts.map +1 -1
  26. package/dist/authored-root.js +29 -1
  27. package/dist/authored-root.js.map +1 -1
  28. package/dist/authored-worker-step.d.ts.map +1 -1
  29. package/dist/authored-worker-step.js +14 -25
  30. package/dist/authored-worker-step.js.map +1 -1
  31. package/dist/cli/answer.d.ts +17 -0
  32. package/dist/cli/answer.d.ts.map +1 -0
  33. package/dist/cli/answer.js +89 -0
  34. package/dist/cli/answer.js.map +1 -0
  35. package/dist/cli/build.js +1 -1
  36. package/dist/cli/build.js.map +1 -1
  37. package/dist/cli/check-triggers.d.ts.map +1 -1
  38. package/dist/cli/check-triggers.js +12 -0
  39. package/dist/cli/check-triggers.js.map +1 -1
  40. package/dist/cli/check.d.ts +25 -0
  41. package/dist/cli/check.d.ts.map +1 -1
  42. package/dist/cli/check.js +14 -2
  43. package/dist/cli/check.js.map +1 -1
  44. package/dist/cli/cloud-connect-cli.d.ts +54 -0
  45. package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
  46. package/dist/cli/cloud-connect-cli.js +109 -0
  47. package/dist/cli/cloud-connect-cli.js.map +1 -0
  48. package/dist/cli/cloud-deploy.d.ts +3 -1
  49. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  50. package/dist/cli/cloud-deploy.js +44 -6
  51. package/dist/cli/cloud-deploy.js.map +1 -1
  52. package/dist/cli/cloud-run.d.ts +2 -1
  53. package/dist/cli/cloud-run.d.ts.map +1 -1
  54. package/dist/cli/cloud-run.js +17 -3
  55. package/dist/cli/cloud-run.js.map +1 -1
  56. package/dist/cli/cloud-schedule.d.ts +31 -0
  57. package/dist/cli/cloud-schedule.d.ts.map +1 -0
  58. package/dist/cli/cloud-schedule.js +156 -0
  59. package/dist/cli/cloud-schedule.js.map +1 -0
  60. package/dist/cli/direct-run.d.ts.map +1 -1
  61. package/dist/cli/direct-run.js +6 -2
  62. package/dist/cli/direct-run.js.map +1 -1
  63. package/dist/cli/run.d.ts +22 -2
  64. package/dist/cli/run.d.ts.map +1 -1
  65. package/dist/cli/run.js +39 -4
  66. package/dist/cli/run.js.map +1 -1
  67. package/dist/cli.d.ts.map +1 -1
  68. package/dist/cli.js +148 -8
  69. package/dist/cli.js.map +1 -1
  70. package/dist/cloud-connect.d.ts +52 -0
  71. package/dist/cloud-connect.d.ts.map +1 -0
  72. package/dist/cloud-connect.js +138 -0
  73. package/dist/cloud-connect.js.map +1 -0
  74. package/dist/cloud-deploy.d.ts +19 -1
  75. package/dist/cloud-deploy.d.ts.map +1 -1
  76. package/dist/cloud-deploy.js +52 -2
  77. package/dist/cloud-deploy.js.map +1 -1
  78. package/dist/cloud-http.d.ts +17 -2
  79. package/dist/cloud-http.d.ts.map +1 -1
  80. package/dist/cloud-http.js +44 -8
  81. package/dist/cloud-http.js.map +1 -1
  82. package/dist/cloud-run.d.ts +30 -0
  83. package/dist/cloud-run.d.ts.map +1 -1
  84. package/dist/cloud-run.js +70 -27
  85. package/dist/cloud-run.js.map +1 -1
  86. package/dist/cloud-schedule.d.ts +55 -0
  87. package/dist/cloud-schedule.d.ts.map +1 -0
  88. package/dist/cloud-schedule.js +146 -0
  89. package/dist/cloud-schedule.js.map +1 -0
  90. package/dist/failure-kinds.d.ts +3 -3
  91. package/dist/failure-kinds.d.ts.map +1 -1
  92. package/dist/failure-kinds.js +4 -0
  93. package/dist/failure-kinds.js.map +1 -1
  94. package/dist/flow-requirements.d.ts +68 -0
  95. package/dist/flow-requirements.d.ts.map +1 -0
  96. package/dist/flow-requirements.js +152 -0
  97. package/dist/flow-requirements.js.map +1 -0
  98. package/dist/index.d.ts +5 -0
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +5 -0
  101. package/dist/index.js.map +1 -1
  102. package/dist/journal-client.d.ts +11 -0
  103. package/dist/journal-client.d.ts.map +1 -1
  104. package/dist/journal-client.js +13 -0
  105. package/dist/journal-client.js.map +1 -1
  106. package/dist/named-gate-lowering.js +9 -0
  107. package/dist/named-gate-lowering.js.map +1 -1
  108. package/dist/named-gates.d.ts +1 -1
  109. package/dist/named-gates.d.ts.map +1 -1
  110. package/dist/named-gates.js +11 -1
  111. package/dist/named-gates.js.map +1 -1
  112. package/dist/preflight.d.ts.map +1 -1
  113. package/dist/preflight.js +155 -9
  114. package/dist/preflight.js.map +1 -1
  115. package/dist/progress.d.ts +2 -1
  116. package/dist/progress.d.ts.map +1 -1
  117. package/dist/progress.js +9 -3
  118. package/dist/progress.js.map +1 -1
  119. package/dist/protocol.d.ts +25 -1
  120. package/dist/protocol.d.ts.map +1 -1
  121. package/dist/schedule-trigger.d.ts +35 -0
  122. package/dist/schedule-trigger.d.ts.map +1 -0
  123. package/dist/schedule-trigger.js +53 -0
  124. package/dist/schedule-trigger.js.map +1 -0
  125. package/dist/spec.d.ts +11 -1
  126. package/dist/spec.d.ts.map +1 -1
  127. package/dist/spec.js.map +1 -1
  128. package/dist/trigger-executor.js +1 -1
  129. package/dist/trigger-executor.js.map +1 -1
  130. package/dist/validate.js +1 -1
  131. package/dist/validate.js.map +1 -1
  132. package/dist/worker-cli.d.ts +10 -0
  133. package/dist/worker-cli.d.ts.map +1 -1
  134. package/dist/worker-cli.js +51 -22
  135. package/dist/worker-cli.js.map +1 -1
  136. package/dist/worker.d.ts.map +1 -1
  137. package/dist/worker.js +9 -0
  138. package/dist/worker.js.map +1 -1
  139. package/dist/wrapper-session.d.ts +3 -1
  140. package/dist/wrapper-session.d.ts.map +1 -1
  141. package/dist/wrapper-session.js +6 -3
  142. package/dist/wrapper-session.js.map +1 -1
  143. package/package.json +3 -3
  144. package/src/authored-flow-error.ts +30 -0
  145. package/src/authored-flow-executor.ts +141 -2
  146. package/src/authored-flow-lifecycle.ts +7 -0
  147. package/src/authored-flow-operation.ts +35 -10
  148. package/src/authored-human.ts +150 -0
  149. package/src/authored-node-entry.ts +3 -2
  150. package/src/authored-node-runner.ts +55 -18
  151. package/src/authored-root.ts +28 -1
  152. package/src/authored-worker-step.ts +15 -26
  153. package/src/cli/answer.ts +98 -0
  154. package/src/cli/build.ts +1 -1
  155. package/src/cli/check-triggers.ts +11 -0
  156. package/src/cli/check.ts +39 -2
  157. package/src/cli/cloud-connect-cli.ts +130 -0
  158. package/src/cli/cloud-deploy.ts +40 -6
  159. package/src/cli/cloud-run.ts +16 -4
  160. package/src/cli/cloud-schedule.ts +160 -0
  161. package/src/cli/direct-run.ts +6 -1
  162. package/src/cli/run.ts +53 -5
  163. package/src/cli.ts +134 -9
  164. package/src/cloud-connect.ts +187 -0
  165. package/src/cloud-deploy.ts +69 -3
  166. package/src/cloud-http.ts +52 -8
  167. package/src/cloud-run.ts +99 -26
  168. package/src/cloud-schedule.ts +175 -0
  169. package/src/failure-kinds.ts +4 -0
  170. package/src/flow-requirements.ts +201 -0
  171. package/src/index.ts +14 -0
  172. package/src/journal-client.ts +20 -0
  173. package/src/named-gate-lowering.ts +9 -0
  174. package/src/named-gates.ts +11 -1
  175. package/src/preflight.ts +132 -9
  176. package/src/progress.ts +11 -4
  177. package/src/protocol.ts +23 -0
  178. package/src/schedule-trigger.ts +73 -0
  179. package/src/spec.ts +12 -1
  180. package/src/trigger-executor.ts +1 -1
  181. package/src/validate.ts +1 -1
  182. package/src/worker-cli.ts +41 -0
  183. package/src/worker.ts +9 -0
  184. package/src/wrapper-session.ts +5 -1
@@ -32,8 +32,10 @@ import type { GetFlowDefinition } from './authored-flow-loader.js';
32
32
  import type { RunLifecycleOptions } from './cli/run.js';
33
33
  import {
34
34
  AuthoredFlowExecutionError,
35
+ AuthoredHumanParked,
35
36
  type AuthoredFlowExecutionErrorCode,
36
37
  } from './authored-flow-error.js';
38
+ import { readHumanAnswer } from './authored-human.js';
37
39
  import {
38
40
  AuthoredFlowOperation,
39
41
  stopAuthoredOperations,
@@ -211,6 +213,93 @@ export async function executeAuthoredFlow<Input = undefined>(
211
213
  localAgentStream, budget, definition.header.budget, options.rootRunId,
212
214
  );
213
215
 
216
+ /**
217
+ * Predicate gates (docs/SURFACE.md §6). The closure runs here, once, on the
218
+ * value the journal handed back; the VERDICT is then journaled as a lowered
219
+ * `<id>.gate` deterministic step that succeeds or fails, so a resume or
220
+ * replay reads the recorded verdict and never re-runs author code. A false
221
+ * verdict fails the step as `gate_failed`, carrying the author's reason.
222
+ *
223
+ * Durability across a resume: the verdict is appended to the root run's
224
+ * `predicate-gates` stream BEFORE the gate run is opened. A resumed body
225
+ * re-executes and reaches the same gate; it finds the recorded verdict and
226
+ * reuses it, so the gate run's spec (which embeds the verdict) is identical
227
+ * under its admission key and the closure is never re-run. Without a root
228
+ * run there is nothing to resume, and the closure simply runs.
229
+ */
230
+ const PREDICATE_STREAM = 'predicate-gates';
231
+ interface PredicateRecord { gate: 'predicate'; step: string; verdict: 'pass' | 'fail'; because?: string; threw?: string }
232
+ // The stream is read once per execution; concurrent gates (Promise.all)
233
+ // share the single in-flight load, so none of them can observe an empty
234
+ // map while the read is still pending and re-run a closure whose verdict
235
+ // was already recorded.
236
+ let recordedVerdicts: Promise<Map<string, PredicateRecord>> | undefined;
237
+ async function loadRecordedVerdicts(rootRunId: string): Promise<Map<string, PredicateRecord>> {
238
+ const verdicts = new Map<string, PredicateRecord>();
239
+ let offset = 0;
240
+ for (;;) {
241
+ const page = await journal.streamRead(rootRunId, PREDICATE_STREAM, offset, 1000);
242
+ for (const message of page.messages) {
243
+ const record = (message as { message?: unknown }).message ?? message;
244
+ if (typeof record === 'object' && record !== null && (record as PredicateRecord).gate === 'predicate'
245
+ && typeof (record as PredicateRecord).step === 'string'
246
+ && ((record as PredicateRecord).verdict === 'pass' || (record as PredicateRecord).verdict === 'fail')) {
247
+ verdicts.set((record as PredicateRecord).step, record as PredicateRecord);
248
+ }
249
+ }
250
+ if (page.messages.length === 0 || page.next_offset <= offset) break;
251
+ offset = page.next_offset;
252
+ }
253
+ return verdicts;
254
+ }
255
+ async function recordedVerdict(id: string): Promise<PredicateRecord | undefined> {
256
+ if (options.rootRunId === undefined) return undefined;
257
+ recordedVerdicts ??= loadRecordedVerdicts(options.rootRunId);
258
+ return (await recordedVerdicts).get(id);
259
+ }
260
+ async function applyPredicateGate<T>(operation: { id: string; predicateGate: unknown }, value: T): Promise<T> {
261
+ const gate = operation.predicateGate as { predicate: (value: T) => boolean; because?: string } | undefined;
262
+ if (gate === undefined) return value;
263
+ const id = operation.id;
264
+ let record = await recordedVerdict(id);
265
+ if (record === undefined) {
266
+ let verdict: boolean;
267
+ let detail: string | undefined;
268
+ try {
269
+ verdict = gate.predicate(value) === true;
270
+ } catch (error) {
271
+ verdict = false;
272
+ detail = error instanceof Error ? error.message : String(error);
273
+ }
274
+ record = {
275
+ gate: 'predicate', step: id, verdict: verdict ? 'pass' : 'fail',
276
+ ...(gate.because === undefined ? {} : { because: gate.because }),
277
+ ...(detail === undefined ? {} : { threw: detail }),
278
+ };
279
+ if (options.rootRunId !== undefined) {
280
+ await journal.streamAppend(options.rootRunId, PREDICATE_STREAM, record);
281
+ (await recordedVerdicts)?.set(id, record);
282
+ }
283
+ }
284
+ const literal = `'${JSON.stringify(record).replaceAll("'", "'\\''")}'`;
285
+ const command = record.verdict === 'pass' ? `printf '%s' ${literal}` : `printf '%s' ${literal} >&2; exit 1`;
286
+ try {
287
+ await observeStep(`${id}.gate`, 'deterministic', () => lowerDeterministic(`${id}.gate`, command, false), options.onProgress);
288
+ } catch (error) {
289
+ if (record.verdict === 'pass') throw error;
290
+ throw new AuthoredFlowExecutionError(
291
+ 'gate_failed',
292
+ `step "${id}" failed its predicate gate`
293
+ + (record.because === undefined ? '' : `: ${record.because}`)
294
+ + (record.threw === undefined ? '' : ` (predicate threw: ${record.threw})`),
295
+ 'verification_failed',
296
+ error instanceof AuthoredFlowExecutionError ? error.runId : undefined,
297
+ );
298
+ }
299
+ return value;
300
+ }
301
+ lifecycle.applyPredicateGate = applyPredicateGate;
302
+
214
303
  function llmOperation(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
215
304
  function llmOperation(prompt: string, options: LlmOptions): Step<unknown>;
216
305
  function llmOperation(prompt: string | TemplateStringsArray, ...values: unknown[]): Step<unknown> {
@@ -319,9 +408,59 @@ export async function executeAuthoredFlow<Input = undefined>(
319
408
  );
320
409
  return trackStep(authoredSteps, agentOp);
321
410
  },
322
- human() {
411
+ /**
412
+ * `f.human` (docs/SURFACE.md §1, §7). The question is not a child run: it
413
+ * is the ROOT attempt parking on the kernel's `wait.human`. The body
414
+ * cannot park itself — it holds no lease — so when no answer is journaled
415
+ * it throws `AuthoredHumanParked`, the durable root turns that into
416
+ * `step.wait`, and the CLI reports the question with exit 3.
417
+ *
418
+ * The wait id is the operation's ordinal (`human-N`), so a resumed body
419
+ * re-executes to the same call and finds the recorded
420
+ * `wait.completed{human_responded}`. The ANSWER is then lowered as a
421
+ * `human-N` deterministic step carrying `{"answer":…}` on stdout: the
422
+ * boolean the author's code branches on is a journaled, memoized fact in
423
+ * the same shape as every other authored step, so the IPC verifier and a
424
+ * later replay hold it to the same evidence.
425
+ */
426
+ human(question, humanOptions) {
323
427
  assertOperationAllowed('human', definition.name, requestedCompletion);
324
- throw unsupportedVerb('human');
428
+ if (typeof question !== 'string' || question.trim() === '') {
429
+ throw new AuthoredFlowExecutionError('human_answer_invalid', 'f.human requires a non-empty question');
430
+ }
431
+ if (typeof humanOptions?.to !== 'string' || humanOptions.to.trim() === '') {
432
+ throw new AuthoredFlowExecutionError('human_answer_invalid', 'f.human requires { to } naming who answers');
433
+ }
434
+ const id = `human-${nextStep++}`;
435
+ const to = humanOptions.to;
436
+ // Hoisted like `llmOp`/`runOp`: the start closure reads the caller's
437
+ // `.gate(config)` at spec-build time, so a named gate on the answer is
438
+ // lowered into the `human-N` step's `verification` like any other step's.
439
+ let humanOp!: AuthoredFlowOperation<boolean>;
440
+ humanOp = new AuthoredFlowOperation<boolean>(
441
+ id, 'human',
442
+ () => assertOperationAllowed('human', definition.name, requestedCompletion),
443
+ () => observeStep(id, 'deterministic', async () => {
444
+ const rootRunId = options.rootRunId;
445
+ if (rootRunId === undefined) {
446
+ throw new AuthoredFlowExecutionError(
447
+ 'unsupported_verb',
448
+ `f.human needs a durable root to park in; "${id}" has no run to wait on. Run the flow with flows run.`,
449
+ );
450
+ }
451
+ const recorded = await readHumanAnswer(journal, rootRunId, id);
452
+ if (recorded === undefined) throw new AuthoredHumanParked({ waitId: id, question, to }, rootRunId);
453
+ const record = { human: id, to, answer: recorded.answer,
454
+ ...(recorded.note === undefined ? {} : { note: recorded.note }),
455
+ answeredBy: recorded.answeredBy,
456
+ ...(recorded.atMs === undefined ? {} : { at: new Date(recorded.atMs).toISOString() }) };
457
+ const literal = `'${JSON.stringify(record).replaceAll("'", "'\\''")}'`;
458
+ await lowerDeterministic(id, `printf '%s' ${literal}`, false, undefined, humanOp.namedGate);
459
+ return recorded.answer;
460
+ }, options.onProgress),
461
+ lifecycle,
462
+ );
463
+ return trackStep(authoredSteps, humanOp);
325
464
  },
326
465
  dispatch<T>() {
327
466
  assertOperationAllowed('dispatch', definition.name, requestedCompletion);
@@ -109,6 +109,13 @@ function collectMembers(values: Iterable<unknown>): unknown[] | undefined {
109
109
  * called the operation's then method.
110
110
  */
111
111
  export class AuthoredFlowLifecycle {
112
+ /**
113
+ * Installed by the executor: runs an operation's predicate gate (if any) on
114
+ * its resolved value before the operation fulfills. Lives on the lifecycle
115
+ * so EVERY authored operation — core steps, helpers, MCP, plugins — passes
116
+ * through it; a gate accepted on a Step must never be silently ignored.
117
+ */
118
+ applyPredicateGate: (<T>(operation: { id: string; predicateGate: unknown }, value: T) => Promise<T>) | undefined = undefined;
112
119
  private readonly graph: AuthoredPromiseGraph;
113
120
  private readonly activeResolverProbes: ResolverProbe[] = [];
114
121
  private readonly invocations = new Map<OperationToken, AuthoredOperationInvocation[]>();
@@ -8,7 +8,7 @@ import {
8
8
 
9
9
  /** Slice-P kinds the surface `.gate(config)` accepts and the SDK lowers. */
10
10
  const NAMED_GATE_KINDS = new Set([
11
- 'references_input', 'subprocess_gate', 'word_count_bounds', 'regex_match',
11
+ 'references_input', 'subprocess_gate', 'word_count_bounds', 'regex_match', 'artifact_exists',
12
12
  ]);
13
13
 
14
14
  function isNamedGateConfig(candidate: unknown): candidate is NamedGate {
@@ -31,6 +31,14 @@ export class AuthoredFlowOperation<T> {
31
31
  * still throws `unsupported_gate` and never sets this field.
32
32
  */
33
33
  namedGate: NamedGate | undefined = undefined;
34
+ /**
35
+ * Predicate gate attached via `.gate(fn, because?)`. Author code: it runs
36
+ * in this process after the step completes, and its verdict is journaled as
37
+ * a lowered `<id>.gate` deterministic step (docs/SURFACE.md §6), so replay
38
+ * and resume see the recorded verdict and never re-run the closure. The
39
+ * function itself is never serialized. `flows check` cannot prove it.
40
+ */
41
+ predicateGate: { predicate: (value: T) => boolean; because?: string } | undefined = undefined;
34
42
  private state: OperationState = 'created';
35
43
  private thenInvoked = false;
36
44
  private rootFailureRecorded = false;
@@ -61,20 +69,29 @@ export class AuthoredFlowOperation<T> {
61
69
  const operation = this;
62
70
  const step: Step<T> = {
63
71
  gate(configOrPredicate: NamedGate | ((value: T) => boolean), _because?: string): Step<T> {
72
+ if (operation.namedGate !== undefined || operation.predicateGate !== undefined) {
73
+ throw new AuthoredFlowExecutionError('unsupported_gate', 'a step takes one .gate().');
74
+ }
64
75
  if (isNamedGateConfig(configOrPredicate)) {
65
76
  // Config-object gate: lowers into the compiled StepSpec's
66
77
  // `verification:` field via slice-P named-gate lowering.
67
78
  operation.namedGate = configOrPredicate;
68
79
  return step;
69
80
  }
70
- // Predicate gate: closures cannot be journaled (covenant 1
71
- // journal-as-truth). Refuseauthors should use a config-object
72
- // gate or the declarative `verification:` block.
73
- throw new AuthoredFlowExecutionError(
74
- 'unsupported_gate',
75
- 'postfix .gate(predicate) closures cannot be journaled; '
76
- + 'use .gate({type: "…", …}) with a slice-P named gate instead.',
77
- );
81
+ // Predicate gate: runtime control flow. The closure cannot be
82
+ // journaled, but its VERDICT can the executor runs it once after
83
+ // the step completes and records pass/fail as a `<id>.gate` step.
84
+ if (typeof configOrPredicate !== 'function') {
85
+ throw new AuthoredFlowExecutionError(
86
+ 'unsupported_gate',
87
+ '.gate() takes a named gate config ({type: "…", …}) or a predicate function.',
88
+ );
89
+ }
90
+ if (_because !== undefined && typeof _because !== 'string') {
91
+ throw new AuthoredFlowExecutionError('unsupported_gate', '.gate(predicate, because) takes a string reason.');
92
+ }
93
+ operation.predicateGate = { predicate: configOrPredicate, ...(_because === undefined ? {} : { because: _because }) };
94
+ return step;
78
95
  },
79
96
  then<TResult1 = T, TResult2 = never>(
80
97
  onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
@@ -144,7 +161,15 @@ export class AuthoredFlowOperation<T> {
144
161
  try {
145
162
  this.assertCanStart();
146
163
  this.state = 'running';
147
- const value = await this.start();
164
+ const started = await this.start();
165
+ // The predicate gate, when present, is applied here for every kind of
166
+ // operation, so a helper or plugin step cannot carry a gate that never
167
+ // runs. The executor installs the applier; without one, a predicate
168
+ // gate is refused rather than skipped.
169
+ const value = this.predicateGate === undefined ? started
170
+ : this.scope.applyPredicateGate === undefined
171
+ ? (() => { throw new AuthoredFlowExecutionError('unsupported_gate', 'this runtime cannot apply predicate gates'); })()
172
+ : await this.scope.applyPredicateGate(this as unknown as { id: string; predicateGate: unknown }, started);
148
173
  this.state = 'fulfilled';
149
174
  this.resolve(value);
150
175
  } catch (error) {
@@ -0,0 +1,150 @@
1
+ import { AuthoredFlowExecutionError, type AuthoredHumanWait } from './authored-flow-error.js';
2
+ import type { JournalClient } from './journal-client.js';
3
+
4
+ /**
5
+ * The answer contract for a parked `f.human`.
6
+ *
7
+ * The CLIENT sends `{ answer, note?, answeredBy }` as the `payload` of the
8
+ * `event.emit` that closes the wait (kernel DESIGN.md §5: `event_key` is the
9
+ * `wait_id`); `answeredBy` is required — the kernel refuses an unattributed
10
+ * answer. The KERNEL journals it as the `result` of
11
+ * `wait.completed{human_responded}` with two fields of its own: `at_ms`, the
12
+ * entry's clock (any client `at` is dropped — the journal says when), and
13
+ * `attribution: "client_asserted"`, because the daemon socket, not the kernel,
14
+ * is what authenticated whoever said `answeredBy`. `flows answer` and Cloud's
15
+ * resumed sandbox both produce the client half; the body reads the kernel half.
16
+ */
17
+ export interface HumanAnswer {
18
+ readonly answer: boolean;
19
+ readonly note?: string;
20
+ readonly answeredBy: string;
21
+ /** Kernel clock at the journaled `wait.completed`, epoch milliseconds. */
22
+ readonly atMs?: number;
23
+ /** How `answeredBy` was established. The kernel writes `client_asserted`. */
24
+ readonly attribution?: string;
25
+ }
26
+
27
+ export type HumanAnswerPayload = Pick<HumanAnswer, 'answer' | 'note' | 'answeredBy'>;
28
+
29
+ /** An open `wait.human` on a run: asked, not yet answered. */
30
+ export interface OpenHumanWait extends AuthoredHumanWait {
31
+ readonly stepId: string;
32
+ readonly attempt: number;
33
+ }
34
+
35
+ export const HUMAN_WAIT_ID = /^human-[1-9][0-9]*$/;
36
+
37
+ export function humanAnswerPayload(answer: boolean, extra: { note?: string; answeredBy: string }): HumanAnswerPayload {
38
+ if (extra.answeredBy.trim() === '') {
39
+ throw new AuthoredFlowExecutionError('human_answer_invalid', 'an answer must say who gave it (answeredBy)');
40
+ }
41
+ return {
42
+ answer,
43
+ ...(extra.note === undefined || extra.note === '' ? {} : { note: extra.note }),
44
+ answeredBy: extra.answeredBy,
45
+ };
46
+ }
47
+
48
+ /** Narrow an untrusted journal `result` to the answer contract, or refuse it. */
49
+ export function parseHumanAnswer(value: unknown, waitId: string): HumanAnswer {
50
+ const record = value as { answer?: unknown; note?: unknown; answeredBy?: unknown; at_ms?: unknown; attribution?: unknown } | null;
51
+ if (typeof record !== 'object' || record === null || typeof record.answer !== 'boolean'
52
+ || (record.note !== undefined && typeof record.note !== 'string')
53
+ || typeof record.answeredBy !== 'string' || record.answeredBy.trim() === ''
54
+ || (record.at_ms !== undefined && !Number.isSafeInteger(record.at_ms))
55
+ || (record.attribution !== undefined && typeof record.attribution !== 'string')) {
56
+ throw new AuthoredFlowExecutionError(
57
+ 'human_answer_invalid',
58
+ `the recorded answer to ${waitId} is not { answer: boolean, answeredBy: string }; answer it again with flows answer`,
59
+ );
60
+ }
61
+ return {
62
+ answer: record.answer,
63
+ ...(record.note === undefined ? {} : { note: record.note }),
64
+ answeredBy: record.answeredBy,
65
+ ...(record.at_ms === undefined ? {} : { atMs: record.at_ms as number }),
66
+ ...(record.attribution === undefined ? {} : { attribution: record.attribution }),
67
+ };
68
+ }
69
+
70
+ interface WaitEntry {
71
+ entry_type?: string;
72
+ step_id?: string | null;
73
+ attempt?: number | null;
74
+ payload?: {
75
+ wait_id?: unknown; prompt?: unknown; requested_of?: unknown;
76
+ completionReason?: unknown; result?: unknown;
77
+ };
78
+ }
79
+
80
+ async function readWaitEntries(journal: JournalClient, runId: string): Promise<WaitEntry[]> {
81
+ const waits: WaitEntry[] = [];
82
+ let fromSeq = 1;
83
+ for (;;) {
84
+ const page = (await journal.journalRead(runId, fromSeq, 1000)).entries as Array<WaitEntry & { seq?: number }>;
85
+ if (page.length === 0) break;
86
+ for (const entry of page) {
87
+ if (typeof entry.seq !== 'number' || entry.seq < fromSeq) {
88
+ throw new AuthoredFlowExecutionError('journal_protocol_violation', `journal.read for ${runId} returned out-of-order entries`);
89
+ }
90
+ fromSeq = entry.seq + 1;
91
+ if (entry.entry_type === 'wait.human' || entry.entry_type === 'wait.completed') waits.push(entry);
92
+ }
93
+ }
94
+ return waits;
95
+ }
96
+
97
+ /**
98
+ * The recorded answer to `waitId`, or `undefined` while the question is open
99
+ * or not yet asked. The first `human_responded` completion wins: the kernel
100
+ * closes a wait once, so a second answer never reaches the journal.
101
+ */
102
+ export async function readHumanAnswer(
103
+ journal: JournalClient,
104
+ runId: string,
105
+ waitId: string,
106
+ ): Promise<HumanAnswer | undefined> {
107
+ for (const entry of await readWaitEntries(journal, runId)) {
108
+ if (entry.entry_type === 'wait.completed' && entry.payload?.wait_id === waitId
109
+ && entry.payload.completionReason === 'human_responded') {
110
+ return parseHumanAnswer(entry.payload.result, waitId);
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** Every `wait.human` on the run that no `wait.completed` has closed. */
117
+ export async function readOpenHumanWaits(journal: JournalClient, runId: string): Promise<OpenHumanWait[]> {
118
+ const open = new Map<string, OpenHumanWait>();
119
+ for (const entry of await readWaitEntries(journal, runId)) {
120
+ const waitId = entry.payload?.wait_id;
121
+ if (typeof waitId !== 'string') continue;
122
+ if (entry.entry_type === 'wait.human') {
123
+ open.set(waitId, {
124
+ waitId,
125
+ question: typeof entry.payload?.prompt === 'string' ? entry.payload.prompt : '',
126
+ to: typeof entry.payload?.requested_of === 'string' ? entry.payload.requested_of : '',
127
+ stepId: typeof entry.step_id === 'string' ? entry.step_id : '',
128
+ attempt: typeof entry.attempt === 'number' ? entry.attempt : 0,
129
+ });
130
+ } else {
131
+ open.delete(waitId);
132
+ }
133
+ }
134
+ return [...open.values()];
135
+ }
136
+
137
+ /** How to answer a parked question from a shell, with the run's own data dir. */
138
+ export function answerCommand(runId: string, waitId: string, dataDir?: string): string {
139
+ const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
140
+ return `flows answer${dir} ${runId} ${waitId} yes|no`;
141
+ }
142
+
143
+ export function resumeCommand(runId: string, dataDir?: string, localAgent = false): string {
144
+ const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
145
+ return `flows resume${dir}${localAgent ? ' --local-agent' : ''} ${runId}`;
146
+ }
147
+
148
+ function shellWord(value: string): string {
149
+ return /^[A-Za-z0-9_./=:@%+,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
150
+ }
@@ -5,7 +5,7 @@ import { JournalClient } from './journal-client.js';
5
5
  import { executeAuthoredFlow } from './authored-flow-executor.js';
6
6
  import { loadPinnedAuthoredSource } from './authored-source-authority.js';
7
7
  import { assertAuthoredNodeVersion, parseAuthoredParentPid } from './authored-runtime-capability.js';
8
- import { AuthoredFlowExecutionError } from './authored-flow-error.js';
8
+ import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
9
9
  import type { AuthoredRootMetadata } from './authored-root.js';
10
10
 
11
11
  let channelKey: string | undefined, sequence = 0;
@@ -82,7 +82,8 @@ try {
82
82
  const prefix = error instanceof AuthoredFlowExecutionError ? `${error.code}: ` : '';
83
83
  send({ type: 'error', message: prefix && message.startsWith(prefix) ? message.slice(prefix.length) : message,
84
84
  ...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
85
- completionReason: error.completionReason, runId: error.runId } : {}) });
85
+ completionReason: error.completionReason, runId: error.runId } : {}),
86
+ ...(error instanceof AuthoredHumanParked ? { wait: error.wait } : {}) });
86
87
  process.exitCode = 1;
87
88
  } finally {
88
89
  finished = true; await watchdog.terminate(); client?.close(); process.stdin.destroy();
@@ -9,7 +9,11 @@ import type { Readable } from 'node:stream';
9
9
  import type { AuthoredRootMetadata } from './authored-root.js';
10
10
  import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js';
11
11
  import { completionMarker, isLoweredCompletion } from './authored-flow-executor.js';
12
- import { AuthoredFlowExecutionError, type AuthoredFlowExecutionErrorCode } from './authored-flow-error.js';
12
+ import {
13
+ AuthoredFlowExecutionError, AuthoredHumanParked,
14
+ type AuthoredFlowExecutionErrorCode, type AuthoredHumanWait,
15
+ } from './authored-flow-error.js';
16
+ import { HUMAN_WAIT_ID } from './authored-human.js';
13
17
  import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
14
18
 
15
19
  let embeddedSource: string | undefined;
@@ -131,10 +135,12 @@ export async function runAuthoredInNode(
131
135
  else if (message.type === 'wait') options.onWait?.(message.event);
132
136
  else if (message.type === 'result') result = { ...message.result, executionRuntime: runtime };
133
137
  else if (message.type === 'error') {
134
- failure = typeof message.code === 'string'
135
- ? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
136
- message.message, message.completionReason, message.runId)
137
- : new Error(message.message);
138
+ failure = message.code === 'human_parked' && isHumanWaitFrame(message.wait) && message.runId === rootRunId
139
+ ? new AuthoredHumanParked(message.wait, rootRunId)
140
+ : typeof message.code === 'string'
141
+ ? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
142
+ message.message, message.completionReason, message.runId)
143
+ : new Error(message.message);
138
144
  } else throw new Error('unknown authored runtime message');
139
145
  } catch (error) { stop(error instanceof Error ? error : new Error('invalid authored runtime message')); }
140
146
  }
@@ -156,35 +162,58 @@ export async function runAuthoredInNode(
156
162
  } finally { await rm(directory, { recursive: true, force: true }); }
157
163
  }
158
164
 
165
+ /** A park signal from the child names the question the parent must journal. */
166
+ function isHumanWaitFrame(value: unknown): value is AuthoredHumanWait {
167
+ const wait = value as Partial<AuthoredHumanWait> | null;
168
+ return typeof wait === 'object' && wait !== null
169
+ && typeof wait.waitId === 'string' && HUMAN_WAIT_ID.test(wait.waitId)
170
+ && typeof wait.question === 'string' && wait.question !== ''
171
+ && typeof wait.to === 'string' && wait.to !== '';
172
+ }
173
+
159
174
  /** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
160
175
  export async function verifyAuthoredNodeResult(
161
176
  result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
162
177
  rootRunId: string, socketPath: string,
163
178
  ): Promise<void> {
164
- const invalid = (): never => { throw new Error('authored runtime result has no matching durable completion'); };
179
+ const invalid = (why = ''): never => { throw new Error(`authored runtime result has no matching durable completion${process.env['FLOWS_VERIFIER_DEBUG'] && why ? ` (${why})` : ''}`); };
165
180
  if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
166
181
  || !isLoweredCompletion(result.completionReason)
167
- || !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid();
182
+ || !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid('frame');
168
183
  const terminal = result.journalSteps.at(-1)!;
169
- if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid();
184
+ if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid('terminal');
170
185
  const count = Number(terminal.id.slice('complete-'.length));
171
- if (!Number.isSafeInteger(count) || result.journalSteps.length !== count) invalid();
186
+ // A predicate gate is journaled as `<step>.gate`: a child run subordinate
187
+ // to the authored step it judges, in the same `<step>.gate` shape a named
188
+ // gate lowers to inside its step's own spec. Neither consumes an ordinal —
189
+ // `complete-N` counts the operations the author wrote (SURFACE.md §6) —
190
+ // so gates are set aside from the count and the contiguity check, and
191
+ // verified separately: every gate must name a claimed parent, and is then
192
+ // held to the same durable-completion evidence as any other child run.
193
+ const isGate = (id: string): boolean => /\.gate$/.test(id);
194
+ const authored = result.journalSteps.filter(step => typeof step?.id === 'string' && !isGate(step.id));
195
+ const gates = result.journalSteps.filter(step => typeof step?.id === 'string' && isGate(step.id));
196
+ if (!Number.isSafeInteger(count) || authored.length !== count || isGate(terminal.id)) invalid(`count ${authored.length}!=${count}`);
172
197
  const ordinal = (id: string): number => Number(/-([1-9][0-9]*)$/.exec(id)?.[1]);
173
198
  // Parallel awaits may finish in either order; validate a copy in declaration order.
174
- const ordered = [...result.journalSteps].sort((a,b)=>ordinal(a.id)-ordinal(b.id));
199
+ const ordered = [...authored].sort((a,b)=>ordinal(a.id)-ordinal(b.id));
200
+ const authoredIds = new Set(ordered.map(step => step.id));
201
+ for (const gate of gates) {
202
+ if (!authoredIds.has(gate.id.slice(0, -'.gate'.length))) invalid(`orphan ${gate.id}`);
203
+ }
175
204
  const runs = new Set<string>();
176
205
  const journal = new JournalClient(socketPath);
177
206
  await journal.connect();
178
207
  try {
179
208
  await journal.hello('flows-authored-result-verifier');
180
- for (const [index, claimed] of ordered.entries()) {
209
+ for (const [index, claimed] of [...ordered, ...gates].entries()) {
181
210
  if (!claimed || typeof claimed.id !== 'string' || typeof claimed.runId !== 'string'
182
- || ordinal(claimed.id) !== index+1 || claimed.completionReason !== 'success'
183
- || runs.has(claimed.runId)) invalid();
211
+ || (index < ordered.length && ordinal(claimed.id) !== index+1) || claimed.completionReason !== 'success'
212
+ || runs.has(claimed.runId)) invalid(`claim ${claimed?.id}`);
184
213
  runs.add(claimed.runId);
185
214
  const state = await journal.runGet(claimed.runId);
186
215
  if (state.run_id !== claimed.runId || state.status !== 'completed'
187
- || state.steps[claimed.id]?.state !== 'done') invalid();
216
+ || state.steps[claimed.id]?.state !== 'done') invalid(`state ${claimed.id} ${state.status} ${state.steps[claimed.id]?.state}`);
188
217
  const entries: Array<{
189
218
  seq: number; entry_type: string; step_id?: string; payload?: {
190
219
  completionReason?: string; spec?: { name?: string; steps?: Array<{id?:string;type?:string;command?:string}> };
@@ -196,7 +225,7 @@ export async function verifyAuthoredNodeResult(
196
225
  if (page.length === 0) break;
197
226
  for (const raw of page) {
198
227
  const entry = raw as typeof entries[number];
199
- if (!Number.isSafeInteger(entry?.seq) || entry.seq < fromSeq) invalid();
228
+ if (!Number.isSafeInteger(entry?.seq) || entry.seq < fromSeq) invalid('seq');
200
229
  fromSeq = entry.seq + 1;
201
230
  // Keep only completion evidence; streaming logs can span many pages.
202
231
  if (['run.spawned', 'step.completed', 'run.completed'].includes(entry.entry_type)) entries.push(entry);
@@ -204,12 +233,20 @@ export async function verifyAuthoredNodeResult(
204
233
  }
205
234
  const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
206
235
  const step = spec?.steps?.[0];
236
+ // A named gate lowers INTO the step's own spec as a second, dependent
237
+ // `<id>.gate` step (named-gate-lowering.ts); that is the only other
238
+ // step a child spec may carry, and it must have completed too.
239
+ const lowered = spec?.steps ?? [];
240
+ const specShape = lowered.length === 1 || (lowered.length === 2 && lowered[1]?.id === `${claimed.id}.gate`);
207
241
  const completed = entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === claimed.id);
242
+ const gateCompleted = lowered.length === 2
243
+ ? entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === `${claimed.id}.gate`) : [];
208
244
  const terminalFacts = entries.filter(entry => entry.entry_type === 'run.completed');
209
- if (spec?.name !== `${metadata.flowName}/${claimed.id}` || spec?.steps?.length !== 1
245
+ if (spec?.name !== `${metadata.flowName}/${claimed.id}` || !specShape
210
246
  || step?.id !== claimed.id || completed.length !== 1
211
247
  || completed[0]?.payload?.completionReason !== 'success'
212
- || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid();
248
+ || (lowered.length === 2 && (gateCompleted.length !== 1 || gateCompleted[0]?.payload?.completionReason !== 'success'))
249
+ || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid(`evidence ${claimed.id} spec=${spec?.name} step=${step?.id} completed=${completed.length}`);
213
250
  if (claimed === terminal) {
214
251
  // The claimed verdict must match the marker the journal actually
215
252
  // recorded, so an IPC frame cannot claim `success` over a run whose
@@ -219,7 +256,7 @@ export async function verifyAuthoredNodeResult(
219
256
  // the runtime validation of untrusted IPC, and it is why nothing has
220
257
  // to be re-asserted here just to satisfy the type.
221
258
  if (step?.type !== 'deterministic'
222
- || step.command !== completionMarker(result.completionReason)) invalid();
259
+ || step.command !== completionMarker(result.completionReason)) invalid('marker');
223
260
  }
224
261
  }
225
262
  } finally { journal.close(); }
@@ -15,6 +15,8 @@ import type { RunOutcome, StepDispatchEvent } from './protocol.js';
15
15
  import { SPEC_SCHEMA_VERSION } from './spec.js';
16
16
  import type { RunLifecycleOptions } from './cli/run.js';
17
17
  import { withWorkerLease } from './worker-lease.js';
18
+ import { AuthoredHumanParked } from './authored-flow-error.js';
19
+ import { readOpenHumanWaits } from './authored-human.js';
18
20
  import { isSurfaceCompletionReason } from './authored-step-output.js';
19
21
 
20
22
  const ROOT_KIND = 'relayflows.authored-root.v1';
@@ -94,7 +96,8 @@ export async function executeDurableAuthoredFlow(
94
96
  // active run but receives no second dispatch from start itself. Resume is
95
97
  // safe for the first caller too: the daemon preserves a live lease and
96
98
  // redelivers only when the former worker connection is gone.
97
- await journal.runResume(outcome.run_id);
99
+ const resumed = await journal.runResume(outcome.run_id);
100
+ await assertNoOpenHumanWait(journal, resumed);
98
101
  const dispatch = await dispatchWait.promise;
99
102
  return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
100
103
  } finally {
@@ -133,6 +136,7 @@ export async function resumeDurableAuthoredFlow(
133
136
  return await completedRootResult(journal, rootRunId);
134
137
  }
135
138
  assertRootCanDispatch(outcome);
139
+ await assertNoOpenHumanWait(journal, outcome);
136
140
  const dispatch = await dispatchWait.promise;
137
141
  return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
138
142
  } finally {
@@ -141,6 +145,17 @@ export async function resumeDurableAuthoredFlow(
141
145
  }
142
146
  }
143
147
 
148
+ /**
149
+ * A root parked on an unanswered `f.human` will not be dispatched: the
150
+ * kernel holds it in `needs_human` until `event.emit` closes the wait. Report
151
+ * the open question instead of waiting for a dispatch that cannot arrive.
152
+ */
153
+ async function assertNoOpenHumanWait(journal: JournalClient, outcome: RunOutcome): Promise<void> {
154
+ if (outcome.status !== 'parked') return;
155
+ const [open] = await readOpenHumanWaits(journal, outcome.run_id);
156
+ if (open !== undefined) throw new AuthoredHumanParked(open, outcome.run_id);
157
+ }
158
+
144
159
  export async function readAuthoredRootMetadata(
145
160
  journal: JournalClient,
146
161
  runId: string,
@@ -206,6 +221,18 @@ async function driveRoot(
206
221
  );
207
222
  return Object.freeze({ ...result, rootRunId: dispatch.run_id });
208
223
  } catch (error) {
224
+ if (error instanceof AuthoredHumanParked) {
225
+ // Not a failure: the body reached a question nobody has answered. Park
226
+ // THIS attempt on the kernel's `wait.human` under the body's own wait
227
+ // id, so the answer (`event.emit` keyed by it) re-dispatches the root
228
+ // and the re-run body finds it. The lease is released by the verb; the
229
+ // signal propagates so the CLI reports the question with exit 3.
230
+ await peer.stepWait(dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.idempotency_key, {
231
+ wait_id: error.wait.waitId, prompt: error.wait.question, requested_of: error.wait.to,
232
+ options: ['yes', 'no'],
233
+ });
234
+ throw error;
235
+ }
209
236
  await terminalizeRootFailure(peer, dispatch, error);
210
237
  throw error;
211
238
  }