@relayflows/sdk 2.0.17 → 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 (123) 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 +47 -3
  7. package/dist/authored-flow-executor.js.map +1 -1
  8. package/dist/authored-human.d.ts +49 -0
  9. package/dist/authored-human.d.ts.map +1 -0
  10. package/dist/authored-human.js +97 -0
  11. package/dist/authored-human.js.map +1 -0
  12. package/dist/authored-node-entry.js +3 -2
  13. package/dist/authored-node-entry.js.map +1 -1
  14. package/dist/authored-node-runner.d.ts.map +1 -1
  15. package/dist/authored-node-runner.js +15 -4
  16. package/dist/authored-node-runner.js.map +1 -1
  17. package/dist/authored-root.d.ts.map +1 -1
  18. package/dist/authored-root.js +29 -1
  19. package/dist/authored-root.js.map +1 -1
  20. package/dist/cli/answer.d.ts +17 -0
  21. package/dist/cli/answer.d.ts.map +1 -0
  22. package/dist/cli/answer.js +89 -0
  23. package/dist/cli/answer.js.map +1 -0
  24. package/dist/cli/build.js +1 -1
  25. package/dist/cli/build.js.map +1 -1
  26. package/dist/cli/check-triggers.d.ts.map +1 -1
  27. package/dist/cli/check-triggers.js +2 -0
  28. package/dist/cli/check-triggers.js.map +1 -1
  29. package/dist/cli/check.d.ts +11 -0
  30. package/dist/cli/check.d.ts.map +1 -1
  31. package/dist/cli/check.js +14 -2
  32. package/dist/cli/check.js.map +1 -1
  33. package/dist/cli/cloud-connect-cli.d.ts +54 -0
  34. package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
  35. package/dist/cli/cloud-connect-cli.js +109 -0
  36. package/dist/cli/cloud-connect-cli.js.map +1 -0
  37. package/dist/cli/cloud-deploy.d.ts +3 -1
  38. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  39. package/dist/cli/cloud-deploy.js +44 -6
  40. package/dist/cli/cloud-deploy.js.map +1 -1
  41. package/dist/cli/cloud-run.d.ts +2 -1
  42. package/dist/cli/cloud-run.d.ts.map +1 -1
  43. package/dist/cli/cloud-run.js +17 -3
  44. package/dist/cli/cloud-run.js.map +1 -1
  45. package/dist/cli/cloud-schedule.d.ts +3 -1
  46. package/dist/cli/cloud-schedule.d.ts.map +1 -1
  47. package/dist/cli/cloud-schedule.js +31 -6
  48. package/dist/cli/cloud-schedule.js.map +1 -1
  49. package/dist/cli/direct-run.d.ts.map +1 -1
  50. package/dist/cli/direct-run.js +5 -1
  51. package/dist/cli/direct-run.js.map +1 -1
  52. package/dist/cli/run.d.ts +22 -2
  53. package/dist/cli/run.d.ts.map +1 -1
  54. package/dist/cli/run.js +34 -1
  55. package/dist/cli/run.js.map +1 -1
  56. package/dist/cli.d.ts.map +1 -1
  57. package/dist/cli.js +114 -9
  58. package/dist/cli.js.map +1 -1
  59. package/dist/cloud-connect.d.ts +52 -0
  60. package/dist/cloud-connect.d.ts.map +1 -0
  61. package/dist/cloud-connect.js +138 -0
  62. package/dist/cloud-connect.js.map +1 -0
  63. package/dist/cloud-deploy.d.ts +19 -1
  64. package/dist/cloud-deploy.d.ts.map +1 -1
  65. package/dist/cloud-deploy.js +40 -1
  66. package/dist/cloud-deploy.js.map +1 -1
  67. package/dist/cloud-http.d.ts +2 -2
  68. package/dist/cloud-http.d.ts.map +1 -1
  69. package/dist/cloud-http.js.map +1 -1
  70. package/dist/cloud-schedule.d.ts +7 -0
  71. package/dist/cloud-schedule.d.ts.map +1 -1
  72. package/dist/cloud-schedule.js +22 -1
  73. package/dist/cloud-schedule.js.map +1 -1
  74. package/dist/failure-kinds.d.ts +1 -1
  75. package/dist/failure-kinds.d.ts.map +1 -1
  76. package/dist/failure-kinds.js +2 -0
  77. package/dist/failure-kinds.js.map +1 -1
  78. package/dist/flow-requirements.d.ts +68 -0
  79. package/dist/flow-requirements.d.ts.map +1 -0
  80. package/dist/flow-requirements.js +152 -0
  81. package/dist/flow-requirements.js.map +1 -0
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -0
  85. package/dist/index.js.map +1 -1
  86. package/dist/journal-client.d.ts +11 -0
  87. package/dist/journal-client.d.ts.map +1 -1
  88. package/dist/journal-client.js +13 -0
  89. package/dist/journal-client.js.map +1 -1
  90. package/dist/progress.d.ts +2 -1
  91. package/dist/progress.d.ts.map +1 -1
  92. package/dist/progress.js +9 -3
  93. package/dist/progress.js.map +1 -1
  94. package/dist/protocol.d.ts +25 -1
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/package.json +2 -2
  97. package/src/authored-flow-error.ts +29 -0
  98. package/src/authored-flow-executor.ts +54 -2
  99. package/src/authored-human.ts +150 -0
  100. package/src/authored-node-entry.ts +3 -2
  101. package/src/authored-node-runner.ts +20 -5
  102. package/src/authored-root.ts +28 -1
  103. package/src/cli/answer.ts +98 -0
  104. package/src/cli/build.ts +1 -1
  105. package/src/cli/check-triggers.ts +2 -0
  106. package/src/cli/check.ts +24 -2
  107. package/src/cli/cloud-connect-cli.ts +130 -0
  108. package/src/cli/cloud-deploy.ts +40 -6
  109. package/src/cli/cloud-run.ts +16 -4
  110. package/src/cli/cloud-schedule.ts +31 -6
  111. package/src/cli/direct-run.ts +5 -0
  112. package/src/cli/run.ts +48 -2
  113. package/src/cli.ts +103 -10
  114. package/src/cloud-connect.ts +187 -0
  115. package/src/cloud-deploy.ts +57 -2
  116. package/src/cloud-http.ts +1 -1
  117. package/src/cloud-schedule.ts +20 -3
  118. package/src/failure-kinds.ts +2 -0
  119. package/src/flow-requirements.ts +201 -0
  120. package/src/index.ts +8 -0
  121. package/src/journal-client.ts +20 -0
  122. package/src/progress.ts +11 -4
  123. package/src/protocol.ts +23 -0
@@ -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,
@@ -406,9 +408,59 @@ export async function executeAuthoredFlow<Input = undefined>(
406
408
  );
407
409
  return trackStep(authoredSteps, agentOp);
408
410
  },
409
- 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) {
410
427
  assertOperationAllowed('human', definition.name, requestedCompletion);
411
- 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);
412
464
  },
413
465
  dispatch<T>() {
414
466
  assertOperationAllowed('dispatch', definition.name, requestedCompletion);
@@ -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,6 +162,15 @@ 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,
@@ -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
  }
@@ -0,0 +1,98 @@
1
+ import { userInfo } from 'node:os';
2
+ import { readAuthoredRootMetadata } from '../authored-root.js';
3
+ import { HUMAN_WAIT_ID, humanAnswerPayload, readHumanAnswer, readOpenHumanWaits, resumeCommand } from '../authored-human.js';
4
+ import { JournalClient, JournalProtocolError } from '../journal-client.js';
5
+ import type { EnsureDaemonOptions } from '../daemon-lifecycle.js';
6
+ import { connect, emptyReport, protocolFailure, socketFor, type RunExecution } from './run.js';
7
+
8
+ export interface AnswerOptions {
9
+ readonly note?: string;
10
+ /** Who answered, when relaying a person's decision; defaults to the OS user. */
11
+ readonly answeredBy?: string;
12
+ readonly daemon?: EnsureDaemonOptions;
13
+ }
14
+
15
+ /**
16
+ * `flows answer <run-id> <wait-id> yes|no`: record a person's answer to a
17
+ * parked `f.human`. The answer is an `event.emit` keyed by the wait id; the
18
+ * kernel closes the `wait.human` as `human_responded` and the root becomes
19
+ * runnable. Nothing runs here — this process attaches no worker — so the
20
+ * report names the `flows resume` that continues the body with the answer.
21
+ */
22
+ export async function answerFlow(
23
+ runId: string,
24
+ waitId: string,
25
+ answer: boolean,
26
+ dataDir: string,
27
+ options: AnswerOptions = {},
28
+ ): Promise<RunExecution> {
29
+ const socketPath = socketFor(dataDir);
30
+ const base = emptyReport('answer');
31
+ if (!HUMAN_WAIT_ID.test(waitId)) {
32
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
33
+ severity: 'refusal', kind: 'human_wait_unknown',
34
+ message: `"${waitId}" is not an f.human wait id; they are named human-<n> in the order the body asked.`,
35
+ }] } };
36
+ }
37
+ const client = new JournalClient(socketPath);
38
+ const connected = await connect(client, 'answer', dataDir, base, { daemon: options.daemon ?? {} });
39
+ if (connected !== undefined) return connected;
40
+ try {
41
+ const open = await readOpenHumanWaits(client, runId);
42
+ const wait = open.find(candidate => candidate.waitId === waitId);
43
+ if (wait === undefined) {
44
+ const answered = await readHumanAnswer(client, runId, waitId).catch(() => undefined);
45
+ const others = open.map(candidate => `${candidate.waitId} (${candidate.to}: ${JSON.stringify(candidate.question)})`);
46
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
47
+ severity: 'refusal', kind: 'human_wait_unknown',
48
+ message: answered !== undefined
49
+ ? `Run "${runId}" already has an answer to ${waitId} (${answered.answer ? 'yes' : 'no'}); the kernel closes a wait once.`
50
+ : `Run "${runId}" is not asking ${waitId}.`
51
+ + (others.length === 0 ? ' It has no open question.' : ` Open: ${others.join(', ')}.`),
52
+ }] } };
53
+ }
54
+ const answeredBy = options.answeredBy ?? safeUsername();
55
+ if (answeredBy === '') {
56
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
57
+ severity: 'refusal', kind: 'human_wait_unknown',
58
+ message: 'An answer must say who gave it and this environment has no OS user; pass --by <identity>.',
59
+ }] } };
60
+ }
61
+ const payload = humanAnswerPayload(answer, {
62
+ ...(options.note === undefined ? {} : { note: options.note }),
63
+ answeredBy,
64
+ });
65
+ const emitted = await client.eventEmit(runId, waitId, payload);
66
+ if (emitted.matched !== 1) {
67
+ return protocolFailure('answer', base, socketPath, new Error(
68
+ `event.emit matched ${emitted.matched} waits for ${waitId}; expected the one open question`,
69
+ ), runId);
70
+ }
71
+ const snapshot = await client.runGet(runId);
72
+ const root = await readAuthoredRootMetadata(client, runId).catch(() => undefined);
73
+ return {
74
+ exitCode: 0,
75
+ report: {
76
+ ...base, ok: true, runId, socketPath, status: snapshot.status,
77
+ answer: { waitId, answer, ...(options.note === undefined ? {} : { note: options.note }) },
78
+ humanWait: { waitId, question: wait.question, to: wait.to },
79
+ next: resumeCommand(runId, dataDir, root?.localAgentStream !== undefined),
80
+ diagnostics: [],
81
+ },
82
+ };
83
+ } catch (error) {
84
+ if (error instanceof JournalProtocolError && error.code === 'run_not_found') {
85
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
86
+ severity: 'refusal', kind: 'run_unavailable',
87
+ message: `Run "${runId}" is not known to relayflowd at "${socketPath}".`,
88
+ }] } };
89
+ }
90
+ return protocolFailure('answer', base, socketPath, error, runId);
91
+ } finally {
92
+ client.close();
93
+ }
94
+ }
95
+
96
+ function safeUsername(): string {
97
+ try { return userInfo().username; } catch { return ''; }
98
+ }
package/src/cli/build.ts CHANGED
@@ -112,7 +112,7 @@ export async function buildFlow(path: string, out: string, warn: (line: string)
112
112
  ...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
113
113
  projectSearchStart: directory,
114
114
  models: config.models,
115
- ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}),
115
+ ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
116
116
  probes: {
117
117
  cli: () => { throw new Error('deferred to deployment'); },
118
118
  executor: () => { throw new Error('deferred to deployment'); },
@@ -4,6 +4,7 @@ import { preflightWebhookTriggers } from '../preflight.js';
4
4
  import { preflightProviderTriggers } from '../provider-trigger-contract.js';
5
5
  import { scheduleLowering } from '../schedule-trigger.js';
6
6
  import { checkSlackHelpers } from '../slack-preflight.js';
7
+ import { flowRequirements } from '../flow-requirements.js';
7
8
  import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js';
8
9
 
9
10
  /**
@@ -46,6 +47,7 @@ export async function checkAuthoredTriggers(path: string): Promise<{
46
47
  ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal'),
47
48
  path, gates: [], resolutions: [], diagnostics,
48
49
  ...(schedules.length === 0 ? {} : { schedules }),
50
+ requirements: flowRequirements(definition, { projectCli: config.cli }),
49
51
  ...(config.path === undefined ? {} : { projectConfigPath: config.path }),
50
52
  },
51
53
  };
package/src/cli/check.ts CHANGED
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js';
6
6
  import { helperReady } from '../yaml-helper-effect.js';
7
+ import { flowRequirements, type FlowRequirements } from '../flow-requirements.js';
7
8
  import {
8
9
  adapterIdentification,
9
10
  authenticationProbe,
@@ -33,7 +34,15 @@ export interface ProjectConfig {
33
34
  mcp?: Record<string, McpServerConfig>;
34
35
  cli?: string;
35
36
  executors: string[];
37
+ /**
38
+ * Exact model allowlist. Only a flows.json that DECLARES `models` is a
39
+ * registry that preflight enforces: `{"cli":"claude"}` alone names the CLI
40
+ * and leaves model policy to the adapter defaults, exactly as no flows.json
41
+ * would. Otherwise every scaffolded project (`flows create` writes only
42
+ * `cli`) refuses its adapter's own default model as "not listed".
43
+ */
36
44
  models: string[];
45
+ modelRegistryPath?: string;
37
46
  directory: string;
38
47
  path?: string;
39
48
  }
@@ -47,6 +56,8 @@ export interface CheckReport {
47
56
  resolutions: CliResolution[];
48
57
  /** Authored `schedule.*` handlers and the `flows.tick` subscription each lowers to. */
49
58
  schedules?: ScheduleInspection[];
59
+ /** Integrations, harnesses and MCP servers the flow declares it needs (`flow-requirements.ts`). */
60
+ requirements?: FlowRequirements;
50
61
  diagnostics: Array<PreflightDiagnostic | CheckInputDiagnostic | CheckWarningDiagnostic>;
51
62
  }
52
63
 
@@ -117,6 +128,15 @@ export function checkFlow(path: string): CheckExecution {
117
128
  }
118
129
  }
119
130
 
131
+ /** Requirements never turn a preflight refusal into an unrelated exception. */
132
+ function safeRequirements(authoring: FlowSpec, projectCli: string | undefined): FlowRequirements | undefined {
133
+ try {
134
+ return flowRequirements(authoring, projectCli === undefined ? {} : { projectCli });
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
120
140
  /** Preflight a validated authored flow through the same path as YAML/JSON. */
121
141
  export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConfig?: ProjectConfig): CheckExecution {
122
142
  const absolutePath = resolve(path);
@@ -128,7 +148,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
128
148
  projectConfigPath: config.path,
129
149
  projectSearchStart: dirname(absolutePath),
130
150
  models: config.models,
131
- ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}),
151
+ ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
132
152
  probes,
133
153
  });
134
154
  const flow = result.ok
@@ -147,6 +167,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
147
167
  gates: result.gates,
148
168
  resolutions: result.resolutions,
149
169
  diagnostics: result.diagnostics,
170
+ requirements: safeRequirements(authoring, config.cli),
150
171
  },
151
172
  ...(flow !== undefined ? { flow } : {}),
152
173
  };
@@ -196,7 +217,7 @@ export async function checkBuildableFlow(path: string): Promise<CheckExecution>
196
217
  ...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
197
218
  projectSearchStart: dirname(absolutePath),
198
219
  models: config.models,
199
- ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}),
220
+ ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
200
221
  probes: deferred,
201
222
  });
202
223
  // Refusals rooted in build-machine environment probes (`probe_failed`)
@@ -329,6 +350,7 @@ export function readProjectConfig(start: string): ProjectConfig {
329
350
  ...(value['cli'] !== undefined ? { cli: value['cli'] as string } : {}),
330
351
  executors: (value['executors'] as string[] | undefined) ?? [],
331
352
  models: (value['models'] as string[] | undefined) ?? [],
353
+ ...(value['models'] !== undefined ? { modelRegistryPath: configPath } : {}),
332
354
  directory: dirname(configPath),
333
355
  path: configPath,
334
356
  };