@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.
- package/dist/authored-flow-error.d.ts +21 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +14 -0
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +47 -3
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-human.d.ts +49 -0
- package/dist/authored-human.d.ts.map +1 -0
- package/dist/authored-human.js +97 -0
- package/dist/authored-human.js.map +1 -0
- package/dist/authored-node-entry.js +3 -2
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +15 -4
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +29 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/cli/answer.d.ts +17 -0
- package/dist/cli/answer.d.ts.map +1 -0
- package/dist/cli/answer.js +89 -0
- package/dist/cli/answer.js.map +1 -0
- package/dist/cli/build.js +1 -1
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +2 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +11 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +14 -2
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-connect-cli.d.ts +54 -0
- package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
- package/dist/cli/cloud-connect-cli.js +109 -0
- package/dist/cli/cloud-connect-cli.js.map +1 -0
- package/dist/cli/cloud-deploy.d.ts +3 -1
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +44 -6
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +2 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +17 -3
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-schedule.d.ts +3 -1
- package/dist/cli/cloud-schedule.d.ts.map +1 -1
- package/dist/cli/cloud-schedule.js +31 -6
- package/dist/cli/cloud-schedule.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +5 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +22 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +34 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +114 -9
- package/dist/cli.js.map +1 -1
- package/dist/cloud-connect.d.ts +52 -0
- package/dist/cloud-connect.d.ts.map +1 -0
- package/dist/cloud-connect.js +138 -0
- package/dist/cloud-connect.js.map +1 -0
- package/dist/cloud-deploy.d.ts +19 -1
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +40 -1
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-http.d.ts +2 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-schedule.d.ts +7 -0
- package/dist/cloud-schedule.d.ts.map +1 -1
- package/dist/cloud-schedule.js +22 -1
- package/dist/cloud-schedule.js.map +1 -1
- package/dist/failure-kinds.d.ts +1 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +2 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-requirements.d.ts +68 -0
- package/dist/flow-requirements.d.ts.map +1 -0
- package/dist/flow-requirements.js +152 -0
- package/dist/flow-requirements.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +11 -0
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +13 -0
- package/dist/journal-client.js.map +1 -1
- package/dist/progress.d.ts +2 -1
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +9 -3
- package/dist/progress.js.map +1 -1
- package/dist/protocol.d.ts +25 -1
- package/dist/protocol.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/authored-flow-error.ts +29 -0
- package/src/authored-flow-executor.ts +54 -2
- package/src/authored-human.ts +150 -0
- package/src/authored-node-entry.ts +3 -2
- package/src/authored-node-runner.ts +20 -5
- package/src/authored-root.ts +28 -1
- package/src/cli/answer.ts +98 -0
- package/src/cli/build.ts +1 -1
- package/src/cli/check-triggers.ts +2 -0
- package/src/cli/check.ts +24 -2
- package/src/cli/cloud-connect-cli.ts +130 -0
- package/src/cli/cloud-deploy.ts +40 -6
- package/src/cli/cloud-run.ts +16 -4
- package/src/cli/cloud-schedule.ts +31 -6
- package/src/cli/direct-run.ts +5 -0
- package/src/cli/run.ts +48 -2
- package/src/cli.ts +103 -10
- package/src/cloud-connect.ts +187 -0
- package/src/cloud-deploy.ts +57 -2
- package/src/cloud-http.ts +1 -1
- package/src/cloud-schedule.ts +20 -3
- package/src/failure-kinds.ts +2 -0
- package/src/flow-requirements.ts +201 -0
- package/src/index.ts +8 -0
- package/src/journal-client.ts +20 -0
- package/src/progress.ts +11 -4
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
135
|
-
? new
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
package/src/authored-root.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
};
|