@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.
- 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 +133 -3
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +10 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +7 -0
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-flow-operation.d.ts +11 -0
- package/dist/authored-flow-operation.d.ts.map +1 -1
- package/dist/authored-flow-operation.js +32 -7
- package/dist/authored-flow-operation.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 +52 -18
- 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/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +14 -25
- package/dist/authored-worker-step.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 +12 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +25 -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 +31 -0
- package/dist/cli/cloud-schedule.d.ts.map +1 -0
- package/dist/cli/cloud-schedule.js +156 -0
- package/dist/cli/cloud-schedule.js.map +1 -0
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +6 -2
- 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 +39 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +148 -8
- 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 +52 -2
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-http.d.ts +17 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +44 -8
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +30 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +70 -27
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-schedule.d.ts +55 -0
- package/dist/cloud-schedule.d.ts.map +1 -0
- package/dist/cloud-schedule.js +146 -0
- package/dist/cloud-schedule.js.map +1 -0
- package/dist/failure-kinds.d.ts +3 -3
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +4 -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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/named-gate-lowering.js +9 -0
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/named-gates.d.ts +1 -1
- package/dist/named-gates.d.ts.map +1 -1
- package/dist/named-gates.js +11 -1
- package/dist/named-gates.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +155 -9
- package/dist/preflight.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/dist/schedule-trigger.d.ts +35 -0
- package/dist/schedule-trigger.d.ts.map +1 -0
- package/dist/schedule-trigger.js +53 -0
- package/dist/schedule-trigger.js.map +1 -0
- package/dist/spec.d.ts +11 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/trigger-executor.js +1 -1
- package/dist/trigger-executor.js.map +1 -1
- package/dist/validate.js +1 -1
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +10 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +51 -22
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +9 -0
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +3 -1
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +6 -3
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +3 -3
- package/src/authored-flow-error.ts +30 -0
- package/src/authored-flow-executor.ts +141 -2
- package/src/authored-flow-lifecycle.ts +7 -0
- package/src/authored-flow-operation.ts +35 -10
- package/src/authored-human.ts +150 -0
- package/src/authored-node-entry.ts +3 -2
- package/src/authored-node-runner.ts +55 -18
- package/src/authored-root.ts +28 -1
- package/src/authored-worker-step.ts +15 -26
- package/src/cli/answer.ts +98 -0
- package/src/cli/build.ts +1 -1
- package/src/cli/check-triggers.ts +11 -0
- package/src/cli/check.ts +39 -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 +160 -0
- package/src/cli/direct-run.ts +6 -1
- package/src/cli/run.ts +53 -5
- package/src/cli.ts +134 -9
- package/src/cloud-connect.ts +187 -0
- package/src/cloud-deploy.ts +69 -3
- package/src/cloud-http.ts +52 -8
- package/src/cloud-run.ts +99 -26
- package/src/cloud-schedule.ts +175 -0
- package/src/failure-kinds.ts +4 -0
- package/src/flow-requirements.ts +201 -0
- package/src/index.ts +14 -0
- package/src/journal-client.ts +20 -0
- package/src/named-gate-lowering.ts +9 -0
- package/src/named-gates.ts +11 -1
- package/src/preflight.ts +132 -9
- package/src/progress.ts +11 -4
- package/src/protocol.ts +23 -0
- package/src/schedule-trigger.ts +73 -0
- package/src/spec.ts +12 -1
- package/src/trigger-executor.ts +1 -1
- package/src/validate.ts +1 -1
- package/src/worker-cli.ts +41 -0
- package/src/worker.ts +9 -0
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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 {
|
|
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,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(
|
|
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
|
-
|
|
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 = [...
|
|
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}` ||
|
|
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
|
-
||
|
|
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(); }
|
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
|
}
|