@relayflows/sdk 2.0.35 → 2.0.36
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/adapters/base.d.ts +16 -0
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +15 -0
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/codex.d.ts.map +1 -1
- package/dist/adapters/codex.js +4 -0
- package/dist/adapters/codex.js.map +1 -1
- package/dist/adapters/usage-limit.d.ts +2 -0
- package/dist/adapters/usage-limit.d.ts.map +1 -0
- package/dist/adapters/usage-limit.js +17 -0
- package/dist/adapters/usage-limit.js.map +1 -0
- package/dist/authored-activity.d.ts +17 -0
- package/dist/authored-activity.d.ts.map +1 -0
- package/dist/authored-activity.js +218 -0
- package/dist/authored-activity.js.map +1 -0
- package/dist/authored-child-run.d.ts +34 -0
- package/dist/authored-child-run.d.ts.map +1 -0
- package/dist/authored-child-run.js +128 -0
- package/dist/authored-child-run.js.map +1 -0
- package/dist/authored-flow-error.d.ts +32 -3
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +3 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +9 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +30 -0
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-mcp.js +2 -2
- package/dist/authored-mcp.js.map +1 -1
- package/dist/authored-node-entry.js +3 -1
- 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 +40 -8
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-plugin-effect.js +2 -2
- package/dist/authored-plugin-effect.js.map +1 -1
- package/dist/authored-root.d.ts +10 -7
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +19 -0
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-output.d.ts +24 -8
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +81 -15
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-subscription-park.d.ts +5 -0
- package/dist/authored-subscription-park.d.ts.map +1 -0
- package/dist/authored-subscription-park.js +47 -0
- package/dist/authored-subscription-park.js.map +1 -0
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +7 -2
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-activities.d.ts +8 -0
- package/dist/cli/check-activities.d.ts.map +1 -0
- package/dist/cli/check-activities.js +155 -0
- package/dist/cli/check-activities.js.map +1 -0
- package/dist/cli/cli-probe.d.ts.map +1 -1
- package/dist/cli/cli-probe.js +14 -2
- package/dist/cli/cli-probe.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +10 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +18 -5
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +24 -5
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/subscription-report.d.ts +5 -0
- package/dist/cli/subscription-report.d.ts.map +1 -0
- package/dist/cli/subscription-report.js +48 -0
- package/dist/cli/subscription-report.js.map +1 -0
- package/dist/cli-adapter.d.ts +4 -2
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +4 -0
- package/dist/cli-adapter.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -5
- package/dist/cli.js.map +1 -1
- package/dist/failure-kinds.d.ts +2 -2
- 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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +17 -2
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +33 -2
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-reader.d.ts.map +1 -1
- package/dist/journal-reader.js +2 -0
- package/dist/journal-reader.js.map +1 -1
- package/dist/local-agent.d.ts +4 -1
- package/dist/local-agent.d.ts.map +1 -1
- package/dist/local-agent.js +14 -4
- package/dist/local-agent.js.map +1 -1
- package/dist/preflight.d.ts +6 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +36 -10
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +166 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/step-settling.d.ts +11 -0
- package/dist/step-settling.d.ts.map +1 -0
- package/dist/step-settling.js +22 -0
- package/dist/step-settling.js.map +1 -0
- package/package.json +2 -2
- package/src/adapters/base.ts +19 -0
- package/src/adapters/claude.ts +15 -0
- package/src/adapters/codex.ts +6 -0
- package/src/adapters/usage-limit.ts +17 -0
- package/src/authored-activity.ts +251 -0
- package/src/authored-child-run.ts +138 -0
- package/src/authored-flow-error.ts +33 -0
- package/src/authored-flow-executor.ts +40 -0
- package/src/authored-mcp.ts +2 -2
- package/src/authored-node-entry.ts +3 -1
- package/src/authored-node-runner.ts +41 -8
- package/src/authored-plugin-effect.ts +2 -2
- package/src/authored-root.ts +27 -4
- package/src/authored-step-output.ts +88 -15
- package/src/authored-subscription-park.ts +46 -0
- package/src/authored-worker-step.ts +7 -2
- package/src/cli/check-activities.ts +144 -0
- package/src/cli/cli-probe.ts +15 -3
- package/src/cli/direct-run.ts +9 -0
- package/src/cli/run.ts +46 -9
- package/src/cli/subscription-report.ts +51 -0
- package/src/cli-adapter.ts +8 -2
- package/src/cli.ts +13 -6
- package/src/failure-kinds.ts +2 -0
- package/src/index.ts +6 -0
- package/src/journal-client.ts +47 -3
- package/src/journal-reader.ts +2 -0
- package/src/local-agent.ts +17 -3
- package/src/preflight.ts +46 -10
- package/src/protocol.ts +129 -0
- package/src/step-settling.ts +19 -0
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
} from './failure-kinds.js';
|
|
19
19
|
import { HUMAN_WAIT_ID } from './authored-human.js';
|
|
20
20
|
import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
|
|
21
|
+
import { settlingCompletion } from './step-settling.js';
|
|
21
22
|
|
|
22
23
|
let embeddedSource: string | undefined;
|
|
23
24
|
/** Installed by the standalone build; never fetched or resolved from a workspace. */
|
|
@@ -144,7 +145,8 @@ export async function runAuthoredInNode(
|
|
|
144
145
|
} else if (typeof message.code === 'string') {
|
|
145
146
|
const authored = new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
|
|
146
147
|
message.message, message.completionReason, message.runId,
|
|
147
|
-
stepFailedFrame(message.details)
|
|
148
|
+
stepFailedFrame(message.details),
|
|
149
|
+
isSuspension(message.suspension) ? message.suspension : undefined);
|
|
148
150
|
// Validated, not trusted, like `details`: an unrecognised cause
|
|
149
151
|
// is dropped so the boundary says nothing about workers rather
|
|
150
152
|
// than acting on a value this frame could have invented.
|
|
@@ -172,6 +174,35 @@ export async function runAuthoredInNode(
|
|
|
172
174
|
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
function isSuspension(value: unknown): value is import('./authored-flow-error.js').AuthoredFlowSuspension {
|
|
178
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
179
|
+
const record = value as Record<string, unknown>;
|
|
180
|
+
const common = typeof record['subscriptionId'] === 'string' && record['subscriptionId'].length > 0
|
|
181
|
+
&& typeof record['stream'] === 'string' && record['stream'].length > 0
|
|
182
|
+
&& Number.isSafeInteger(record['deadlineAtMs']) && (record['deadlineAtMs'] as number) >= 0;
|
|
183
|
+
if (!common) return false;
|
|
184
|
+
if (record['kind'] === 'event_wait') return true;
|
|
185
|
+
return record['kind'] === 'activation'
|
|
186
|
+
&& Array.isArray(record['eventTypes']) && record['eventTypes'].length > 0
|
|
187
|
+
&& record['eventTypes'].every(type => typeof type === 'string' && type.length > 0)
|
|
188
|
+
&& (record['pattern'] === undefined || isJsonRecord(record['pattern']))
|
|
189
|
+
&& Number.isSafeInteger(record['settleMs']) && (record['settleMs'] as number) >= 0
|
|
190
|
+
&& Number.isSafeInteger(record['idleMs']) && (record['idleMs'] as number) > 0
|
|
191
|
+
&& typeof record['includeSelf'] === 'boolean';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
195
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
196
|
+
&& Object.values(value).every(isJsonValue);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isJsonValue(value: unknown): boolean {
|
|
200
|
+
return value === null || typeof value === 'boolean' || typeof value === 'string'
|
|
201
|
+
|| (typeof value === 'number' && Number.isFinite(value))
|
|
202
|
+
|| (Array.isArray(value) && value.every(isJsonValue))
|
|
203
|
+
|| isJsonRecord(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
175
206
|
/** A park signal from the child names the question the parent must journal. */
|
|
176
207
|
function isHumanWaitFrame(value: unknown): value is AuthoredHumanWait {
|
|
177
208
|
const wait = value as Partial<AuthoredHumanWait> | null;
|
|
@@ -342,15 +373,17 @@ export async function verifyAuthoredNodeResult(
|
|
|
342
373
|
// step a child spec may carry, and it must have completed too.
|
|
343
374
|
const lowered = spec?.steps ?? [];
|
|
344
375
|
const specShape = lowered.length === 1 || (lowered.length === 2 && lowered[1]?.id === `${claimed.id}.gate`);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
376
|
+
// Judged against the step's SETTLING completion, the same one the
|
|
377
|
+
// authored reader returned: a retried attempt (`disposition: retry`,
|
|
378
|
+
// e.g. crash recovery's `crashed`) journals a completion of its own
|
|
379
|
+
// ahead of it, so neither a count nor the first entry is the verdict.
|
|
380
|
+
const completed = settlingCompletion(entries, claimed.id);
|
|
381
|
+
const gateCompleted = lowered.length === 2 ? settlingCompletion(entries, `${claimed.id}.gate`) : undefined;
|
|
348
382
|
const terminalFacts = entries.filter(entry => entry.entry_type === 'run.completed');
|
|
349
383
|
if (spec?.name !== `${metadata.flowName}/${claimed.id}` || !specShape
|
|
350
|
-
|| step?.id !== claimed.id || completed
|
|
351
|
-
||
|
|
352
|
-
||
|
|
353
|
-
|| terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid(`evidence ${claimed.id} spec=${spec?.name} step=${step?.id} completed=${completed.length}`);
|
|
384
|
+
|| step?.id !== claimed.id || completed?.payload?.completionReason !== 'success'
|
|
385
|
+
|| (lowered.length === 2 && gateCompleted?.payload?.completionReason !== 'success')
|
|
386
|
+
|| terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid(`evidence ${claimed.id} spec=${spec?.name} step=${step?.id} completed=${completed?.payload?.completionReason}`);
|
|
354
387
|
if (claimed === terminal) {
|
|
355
388
|
// The claimed verdict must match the marker the journal actually
|
|
356
389
|
// recorded, so an IPC frame cannot claim `success` over a run whose
|
|
@@ -125,13 +125,13 @@ export async function runPluginEffect(
|
|
|
125
125
|
if (dispatchExpired) await cancelChildRun();
|
|
126
126
|
if (outcome.status !== 'completed') await completed;
|
|
127
127
|
if (diagnostic !== undefined) {
|
|
128
|
-
try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
|
|
128
|
+
try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status); }
|
|
129
129
|
catch (error) {
|
|
130
130
|
if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
|
|
131
131
|
throw new PluginStepError(diagnostic, outcome.run_id);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
|
|
134
|
+
const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status) as { output: unknown };
|
|
135
135
|
return receipt.output;
|
|
136
136
|
}, admissionKey), deadline]);
|
|
137
137
|
} finally {
|
package/src/authored-root.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { canonicalize } from './canonical.js';
|
|
6
6
|
import { compileSpec, toKernelSpec } from './compile.js';
|
|
7
|
-
import { executeAuthoredFlow, type AuthoredFlowExecutionResult } from './authored-flow-executor.js';
|
|
7
|
+
import { executeAuthoredFlow, type AuthoredFlowExecutionResult, type AuthoredFlowSuspendedResult } from './authored-flow-executor.js';
|
|
8
8
|
import { isDurableCompletionDetail, isLoweredCompletion } from './authored-completion.js';
|
|
9
9
|
import { AUTHORED_ROOT_KIND } from './authored-verdict.js';
|
|
10
10
|
import {
|
|
@@ -20,6 +20,11 @@ import { withWorkerLease } from './worker-lease.js';
|
|
|
20
20
|
import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
|
|
21
21
|
import { readOpenHumanWaits } from './authored-human.js';
|
|
22
22
|
import { isSurfaceCompletionReason } from './authored-step-output.js';
|
|
23
|
+
import { readSubscriptionPark } from './authored-subscription-park.js';
|
|
24
|
+
|
|
25
|
+
export type DurableAuthoredFlowResult =
|
|
26
|
+
| (AuthoredFlowExecutionResult & { readonly rootRunId: string })
|
|
27
|
+
| (AuthoredFlowSuspendedResult & { readonly rootRunId: string });
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Taken from the projection module rather than spelled twice: `flows status`
|
|
@@ -55,6 +60,8 @@ export interface AuthoredRootSourceAuthority {
|
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export interface DurableAuthoredOptions {
|
|
63
|
+
/** Preserve root authority even if a child later fails or parks. */
|
|
64
|
+
readonly onAdmitted?: (runId: string) => void;
|
|
58
65
|
readonly dataDir: string;
|
|
59
66
|
readonly admissionKey?: string;
|
|
60
67
|
readonly localAgentStream?: string;
|
|
@@ -69,7 +76,7 @@ export async function executeDurableAuthoredFlow(
|
|
|
69
76
|
journal: JournalClient,
|
|
70
77
|
input: unknown,
|
|
71
78
|
options: DurableAuthoredOptions,
|
|
72
|
-
): Promise<
|
|
79
|
+
): Promise<DurableAuthoredFlowResult> {
|
|
73
80
|
assertAuthoredRuntimeAvailable();
|
|
74
81
|
const source = await readFile(loaded.sourcePath);
|
|
75
82
|
const sources = await Promise.all(loaded.graph.map(async node => Object.freeze({
|
|
@@ -106,6 +113,7 @@ export async function executeDurableAuthoredFlow(
|
|
|
106
113
|
workspace: [], streams: [{ stream, read_offset: 0 }],
|
|
107
114
|
});
|
|
108
115
|
const outcome = await journal.runStart(spec, undefined, admissionKey);
|
|
116
|
+
options.onAdmitted?.(outcome.run_id);
|
|
109
117
|
if (outcome.status === 'completed') {
|
|
110
118
|
dispatchWait.cancel();
|
|
111
119
|
return await completedRootResult(journal, outcome.run_id);
|
|
@@ -119,6 +127,9 @@ export async function executeDurableAuthoredFlow(
|
|
|
119
127
|
// redelivers only when the former worker connection is gone.
|
|
120
128
|
const resumed = await journal.runResume(outcome.run_id);
|
|
121
129
|
await assertNoOpenHumanWait(journal, resumed);
|
|
130
|
+
const parked = await readSubscriptionPark(journal, outcome.run_id);
|
|
131
|
+
if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
|
|
132
|
+
suspension: parked, journalSteps: [], rootRunId: outcome.run_id };
|
|
122
133
|
const dispatch = await dispatchWait.promise;
|
|
123
134
|
return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
124
135
|
} finally {
|
|
@@ -132,7 +143,7 @@ export async function resumeDurableAuthoredFlow(
|
|
|
132
143
|
rootRunId: string,
|
|
133
144
|
journal: JournalClient,
|
|
134
145
|
options: Omit<DurableAuthoredOptions, 'admissionKey'>,
|
|
135
|
-
): Promise<
|
|
146
|
+
): Promise<DurableAuthoredFlowResult | undefined> {
|
|
136
147
|
const metadata = await readAuthoredRootMetadata(journal, rootRunId);
|
|
137
148
|
if (metadata === undefined) return undefined;
|
|
138
149
|
assertAuthoredRuntimeAvailable();
|
|
@@ -159,6 +170,9 @@ export async function resumeDurableAuthoredFlow(
|
|
|
159
170
|
assertRootCanDispatch(outcome);
|
|
160
171
|
await assertNoOpenHumanWait(journal, outcome);
|
|
161
172
|
options.lifecycle?.onRunStarted?.({ runId: rootRunId, flow: metadata.flowName, resumed: true });
|
|
173
|
+
const parked = await readSubscriptionPark(journal, rootRunId);
|
|
174
|
+
if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
|
|
175
|
+
suspension: parked, journalSteps: [], rootRunId };
|
|
162
176
|
const dispatch = await dispatchWait.promise;
|
|
163
177
|
return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
164
178
|
} finally {
|
|
@@ -204,7 +218,7 @@ async function driveRoot(
|
|
|
204
218
|
peer: JournalClient,
|
|
205
219
|
dispatch: StepDispatchEvent,
|
|
206
220
|
options: Omit<DurableAuthoredOptions, 'admissionKey'>,
|
|
207
|
-
): Promise<
|
|
221
|
+
): Promise<DurableAuthoredFlowResult> {
|
|
208
222
|
try {
|
|
209
223
|
const result = await withWorkerLease(peer, dispatch, async rootSignal => {
|
|
210
224
|
const callerSignal = options.lifecycle?.signal;
|
|
@@ -248,6 +262,15 @@ async function driveRoot(
|
|
|
248
262
|
);
|
|
249
263
|
return Object.freeze({ ...result, rootRunId: dispatch.run_id });
|
|
250
264
|
} catch (error) {
|
|
265
|
+
if (error instanceof AuthoredFlowExecutionError
|
|
266
|
+
&& error.code === 'subscription_suspended'
|
|
267
|
+
&& error.suspension !== undefined) {
|
|
268
|
+
await peer.subscriptionPark({ run_id: dispatch.run_id, step_id: dispatch.step_id,
|
|
269
|
+
attempt: dispatch.attempt, idempotency_key: dispatch.idempotency_key,
|
|
270
|
+
subscription_id: error.suspension.subscriptionId, phase: error.suspension.kind });
|
|
271
|
+
return Object.freeze({ state: 'suspended' as const, name: metadata.flowName,
|
|
272
|
+
suspension: error.suspension, journalSteps: Object.freeze([]), rootRunId: dispatch.run_id });
|
|
273
|
+
}
|
|
251
274
|
if (error instanceof AuthoredHumanParked) {
|
|
252
275
|
// Not a failure: the body reached a question nobody has answered. Park
|
|
253
276
|
// THIS attempt on the kernel's `wait.human` under the body's own wait
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { COMPLETION_REASONS, RUN_COMPLETION_REASONS, FLOW_COMPLETION_REASONS, type FlowCompletionReason } from '@relayflows/surface';
|
|
2
2
|
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
3
3
|
import type { JournalClient } from './journal-client.js';
|
|
4
|
-
import type { CompletionReason as ProtocolCompletionReason, RunCompletionReason as ProtocolRunCompletionReason, RunOutcome } from './protocol.js';
|
|
4
|
+
import type { CompletionReason as ProtocolCompletionReason, RunCompletionReason as ProtocolRunCompletionReason, RunOutcome, RunStatus } from './protocol.js';
|
|
5
5
|
import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
|
|
6
6
|
import type { StepFailedDetails } from './failure-kinds.js';
|
|
7
7
|
import { inspectionHint, renderInspection, renderStepEvidence, stepFailureDetails } from './cli/step-failure.js';
|
|
8
8
|
import { alsoRecord, recordAuthoredChild, type AuthoredStepEdges } from './authored-step-index.js';
|
|
9
|
+
import { readChildJournal, waitForTerminalChildRun } from './authored-child-run.js';
|
|
10
|
+
import { settlingCompletion } from './step-settling.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* What an authored operation needs in order to leave readable evidence:
|
|
@@ -19,6 +21,8 @@ export interface AuthoredStepContext {
|
|
|
19
21
|
readonly dataDir?: string;
|
|
20
22
|
/** The step's label and causal predecessors, carried on every record about it. */
|
|
21
23
|
readonly stepEdges?: (step: string) => AuthoredStepEdges | undefined;
|
|
24
|
+
/** Cancels the wait for an adopted child run that is not yet terminal. */
|
|
25
|
+
readonly signal?: AbortSignal;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -27,12 +31,26 @@ export interface AuthoredStepContext {
|
|
|
27
31
|
* clean success before handing the raw `output` back for verb-specific
|
|
28
32
|
* extraction (a plain string for `f.run`, an `AgentResult` for `f.agent`).
|
|
29
33
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* The run must be terminal before it is judged. Usually it already is —
|
|
35
|
+
* `f.run`'s `runStart` drives a deterministic step to completion inline, and
|
|
36
|
+
* `f.agent`'s caller has polled one with `classifyOutcome` (cli/run.ts). But a
|
|
37
|
+
* child ADOPTED through its admission key on resume is returned as it stands,
|
|
38
|
+
* possibly mid-attempt or mid-backoff on the request that first started it,
|
|
39
|
+
* so unless the step has already settled as a failure, a run that neither
|
|
40
|
+
* the caller's own kernel answer (`knownStatus`) nor a journaled
|
|
41
|
+
* `run.completed` shows terminal is waited out first (`waitForTerminalChildRun`, bounded by
|
|
42
|
+
* the child's own lease and backoff). A success is never read off a child
|
|
43
|
+
* whose run is not terminal. A child that cannot settle — parked on a worker
|
|
44
|
+
* nobody attached — reports its latest attempt that ran to a failed result,
|
|
45
|
+
* or is refused.
|
|
46
|
+
*
|
|
47
|
+
* The step's verdict is its TERMINAL completion. A retried attempt journals a
|
|
48
|
+
* `step.completed` too — `disposition: retry` (crash recovery's `crashed`, a
|
|
49
|
+
* `worker_error`, a failed gate with iterations left) — and it precedes the
|
|
50
|
+
* attempt that settled the step. Taking the first `step.completed` read that
|
|
51
|
+
* retried attempt's failure as the step's, and failed a child that completed
|
|
52
|
+
* `success`. Only the last completion whose disposition is not `retry` or
|
|
53
|
+
* `park` is read, recorded and indexed.
|
|
36
54
|
*
|
|
37
55
|
* What is NOT delegated to the caller is the run's verdict. A child spec has
|
|
38
56
|
* more than one step whenever the author declared a `.gate()`, and that gate
|
|
@@ -56,15 +74,63 @@ export async function readCompletedStepOutput(
|
|
|
56
74
|
stepId: string,
|
|
57
75
|
journalSteps: AuthoredFlowJournalStep[],
|
|
58
76
|
context: AuthoredStepContext = {},
|
|
77
|
+
// `suspended` (an Event Await boundary, `RunReport.status`) is not terminal:
|
|
78
|
+
// it is judged exactly like a run still in flight.
|
|
79
|
+
knownStatus?: RunStatus | 'suspended',
|
|
59
80
|
): Promise<unknown> {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
let entries = await readChildJournal(journal, runId);
|
|
82
|
+
let completed = terminalCompletion(entries, stepId);
|
|
83
|
+
// A success is never accepted before the child RUN is terminal: a declared
|
|
84
|
+
// gate lowers to a dependent step (`lowerNamedGates`) that may not even have
|
|
85
|
+
// started when the producer's success is journaled, and the run's verdict,
|
|
86
|
+
// not the producer's, is the operation's. Only a settled FAILURE is final
|
|
87
|
+
// on its own — the step has no attempt left.
|
|
88
|
+
// `knownStatus` is the kernel's own answer the caller already holds (the
|
|
89
|
+
// `run.start` receipt, or the outcome `classifyOutcome` polled to): a
|
|
90
|
+
// terminal one is as authoritative as a journaled `run.completed`.
|
|
91
|
+
const settledFailure = completed !== undefined && completed.payload.completionReason !== 'success';
|
|
92
|
+
const terminal = knownStatus === 'completed' || knownStatus === 'failed'
|
|
93
|
+
|| entries.some(entry => runCompletionReason(entry) !== undefined);
|
|
94
|
+
if (!terminal && !settledFailure) {
|
|
95
|
+
const status = await waitForTerminalChildRun(journal, runId, context.signal);
|
|
96
|
+
entries = await readChildJournal(journal, runId);
|
|
97
|
+
completed = terminalCompletion(entries, stepId);
|
|
98
|
+
if (status !== 'completed' && status !== 'failed') {
|
|
99
|
+
if (completed?.payload.completionReason === 'success') {
|
|
100
|
+
throw protocolViolation(runId,
|
|
101
|
+
`child run for step "${stepId}" is not terminal (status: ${status}); its verdict is still pending`);
|
|
102
|
+
}
|
|
103
|
+
if (completed === undefined) {
|
|
104
|
+
// Nothing will settle the step from here: the child is parked on a
|
|
105
|
+
// worker the caller did not keep attached (a helper drives exactly
|
|
106
|
+
// one attempt, and the retry dispatched after it dies with its
|
|
107
|
+
// connection). The latest attempt that ran to a result — not one the
|
|
108
|
+
// kernel recorded as dead (`completed_by: kernel`,
|
|
109
|
+
// machine/recovery.rs) — is then why the step stands where it does,
|
|
110
|
+
// and is reported as that: never as a success, never silently.
|
|
111
|
+
const attempts = entries.filter((entry): entry is StepCompletedEntry => isStepCompleted(entry, stepId));
|
|
112
|
+
const latest = attempts.filter(entry => entry.payload.completed_by !== 'kernel').at(-1) ?? attempts.at(-1);
|
|
113
|
+
if (latest === undefined || latest.payload.completionReason === 'success') {
|
|
114
|
+
throw protocolViolation(runId, `child run for step "${stepId}" is not terminal (status: ${status})`);
|
|
115
|
+
}
|
|
116
|
+
completed = latest;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const runReason = entries.map(runCompletionReason).find((value) => value !== undefined);
|
|
121
|
+
const edges = context.stepEdges?.(stepId);
|
|
122
|
+
if (completed === undefined) {
|
|
123
|
+
// Canceled or out of budget while the step was between attempts: the run
|
|
124
|
+
// settled with no terminal completion for the step. That is the run's
|
|
125
|
+
// failure to report, not a malformed journal.
|
|
126
|
+
if (runReason !== undefined && runReason !== 'success') {
|
|
127
|
+
throw await stepFailure(journal, runId, stepId, runReason, context, edges,
|
|
128
|
+
`journal run for step "${stepId}" completed with ${runReason}`);
|
|
129
|
+
}
|
|
130
|
+
throw protocolViolation(runId, `journal has no terminal step.completed for "${stepId}"`);
|
|
64
131
|
}
|
|
65
132
|
|
|
66
133
|
const reason = completed.payload.completionReason;
|
|
67
|
-
const edges = context.stepEdges?.(stepId);
|
|
68
134
|
journalSteps.push(Object.freeze({ id: stepId, runId, completionReason: reason, ...edges }));
|
|
69
135
|
if (reason !== 'success') {
|
|
70
136
|
throw await stepFailure(journal, runId, stepId, reason, context, edges,
|
|
@@ -76,7 +142,6 @@ export async function readCompletedStepOutput(
|
|
|
76
142
|
// verdict. Reading only the producer's entry let a failed gate resolve as
|
|
77
143
|
// though the command had passed — the `|| true` invisibility this module's
|
|
78
144
|
// recording policy exists to remove, reappearing one layer up.
|
|
79
|
-
const runReason = entries.map(runCompletionReason).find((value) => value !== undefined);
|
|
80
145
|
if (runReason !== undefined && runReason !== 'success') {
|
|
81
146
|
throw await stepFailure(journal, runId, stepId, runReason, context, edges,
|
|
82
147
|
`journal run for step "${stepId}" completed with ${runReason}`);
|
|
@@ -130,7 +195,7 @@ export async function readSuccessfulOutput(
|
|
|
130
195
|
journalSteps: AuthoredFlowJournalStep[],
|
|
131
196
|
context: AuthoredStepContext = {},
|
|
132
197
|
): Promise<string> {
|
|
133
|
-
const output = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps, context)
|
|
198
|
+
const output = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps, context, outcome.status)
|
|
134
199
|
.catch(relabelCommandTimeout(stepId));
|
|
135
200
|
if (!isRecord(output) || typeof output['stdout_tail'] !== 'string') {
|
|
136
201
|
throw protocolViolation(outcome.run_id, `step "${stepId}" has no string stdout_tail`);
|
|
@@ -166,7 +231,7 @@ export async function readRecordedOutcome(
|
|
|
166
231
|
journalSteps: AuthoredFlowJournalStep[],
|
|
167
232
|
context: AuthoredStepContext = {},
|
|
168
233
|
): Promise<RecordedRunOutcome> {
|
|
169
|
-
const envelope = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps, context)
|
|
234
|
+
const envelope = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps, context, outcome.status)
|
|
170
235
|
.catch(relabelCommandTimeout(stepId));
|
|
171
236
|
const exitCode = isRecord(envelope) ? envelope['exit_code'] : undefined;
|
|
172
237
|
if (!isRecord(envelope) || typeof envelope['stdout_tail'] !== 'string'
|
|
@@ -204,10 +269,18 @@ interface StepCompletedEntry {
|
|
|
204
269
|
step_id: string;
|
|
205
270
|
payload: {
|
|
206
271
|
completionReason: ProtocolCompletionReason;
|
|
272
|
+
disposition?: unknown;
|
|
273
|
+
completed_by?: unknown;
|
|
207
274
|
output: unknown;
|
|
208
275
|
};
|
|
209
276
|
}
|
|
210
277
|
|
|
278
|
+
/** The step's LAST settling completion, when it is well formed. */
|
|
279
|
+
function terminalCompletion(entries: readonly unknown[], stepId: string): StepCompletedEntry | undefined {
|
|
280
|
+
const settling = settlingCompletion(entries, stepId);
|
|
281
|
+
return isStepCompleted(settling, stepId) ? settling : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
211
284
|
function isStepCompleted(value: unknown, stepId: string): value is StepCompletedEntry {
|
|
212
285
|
if (!isRecord(value) || value['entry_type'] !== 'step.completed' || value['step_id'] !== stepId) {
|
|
213
286
|
return false;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AuthoredFlowSuspension } from './authored-flow-error.js';
|
|
2
|
+
import type { JournalClient } from './journal-client.js';
|
|
3
|
+
|
|
4
|
+
/** Recover the handoff report without starting a worker on a still-open wait. */
|
|
5
|
+
export async function readSubscriptionPark(
|
|
6
|
+
journal: JournalClient, runId: string,
|
|
7
|
+
): Promise<AuthoredFlowSuspension | undefined> {
|
|
8
|
+
const open = new Map<string, { subscription_id: string; phase: 'activation' | 'event_wait' }>();
|
|
9
|
+
const prepared = new Map<string, Record<string, unknown>>();
|
|
10
|
+
let from = 1;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const { entries } = await journal.journalRead(runId, from, 500);
|
|
13
|
+
for (const raw of entries) {
|
|
14
|
+
const entry = raw as { seq: number; entry_type: string; step_id?: string; payload: Record<string, unknown> };
|
|
15
|
+
from = entry.seq + 1;
|
|
16
|
+
const p = entry.payload;
|
|
17
|
+
if (entry.entry_type === 'subscription.prepared') prepared.set(String(p['subscription_id']), p);
|
|
18
|
+
if (entry.entry_type === 'wait.event' && entry.step_id === 'authored-root'
|
|
19
|
+
&& typeof p['event_key'] === 'string' && p['event_key'].startsWith('subscription.park:')) {
|
|
20
|
+
const park = JSON.parse(p['event_key'].slice('subscription.park:'.length)) as Record<string, unknown>;
|
|
21
|
+
if (typeof park['subscription_id'] !== 'string'
|
|
22
|
+
|| (park['phase'] !== 'activation' && park['phase'] !== 'event_wait')) {
|
|
23
|
+
throw new Error('malformed durable subscription park');
|
|
24
|
+
}
|
|
25
|
+
open.set(String(p['wait_id']), { subscription_id: park['subscription_id'], phase: park['phase'] });
|
|
26
|
+
}
|
|
27
|
+
if (entry.entry_type === 'wait.completed') open.delete(String(p['wait_id']));
|
|
28
|
+
}
|
|
29
|
+
if (entries.length < 500) break;
|
|
30
|
+
}
|
|
31
|
+
const park = [...open.values()].at(-1);
|
|
32
|
+
if (park === undefined) return undefined;
|
|
33
|
+
const p = prepared.get(park.subscription_id);
|
|
34
|
+
if (p === undefined || typeof p['stream'] !== 'string' || typeof p['deadline_at_ms'] !== 'number') {
|
|
35
|
+
throw new Error('subscription park has no prepared boundary');
|
|
36
|
+
}
|
|
37
|
+
const common = { subscriptionId: park.subscription_id, stream: p['stream'], deadlineAtMs: p['deadline_at_ms'] };
|
|
38
|
+
if (park.phase === 'event_wait') return { kind: 'event_wait', ...common };
|
|
39
|
+
if (!Array.isArray(p['event_types']) || !p['event_types'].every(v => typeof v === 'string')
|
|
40
|
+
|| typeof p['settle_ms'] !== 'number' || typeof p['idle_ms'] !== 'number' || typeof p['include_self'] !== 'boolean') {
|
|
41
|
+
throw new Error('subscription park has malformed prepared facts');
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'activation', ...common, eventTypes: p['event_types'], settleMs: p['settle_ms'],
|
|
44
|
+
idleMs: p['idle_ms'], includeSelf: p['include_self'],
|
|
45
|
+
...(p['pattern'] === undefined || p['pattern'] === null ? {} : { pattern: p['pattern'] as Record<string, unknown> }) };
|
|
46
|
+
}
|
|
@@ -41,6 +41,7 @@ export function authoredWorkerRunner(
|
|
|
41
41
|
...(rootRunId === undefined ? {} : { rootRunId }),
|
|
42
42
|
...(waitOptions.dataDir === undefined ? {} : { dataDir: waitOptions.dataDir }),
|
|
43
43
|
...(stepEdges === undefined ? {} : { stepEdges }),
|
|
44
|
+
...(waitOptions.signal === undefined ? {} : { signal: waitOptions.signal }),
|
|
44
45
|
};
|
|
45
46
|
async function run(step: StepSpec): Promise<unknown> {
|
|
46
47
|
const id = step.id;
|
|
@@ -56,8 +57,12 @@ export function authoredWorkerRunner(
|
|
|
56
57
|
(diagnostic): diagnostic is PreflightDiagnostic & { severity: 'refusal' } =>
|
|
57
58
|
diagnostic.severity === 'refusal',
|
|
58
59
|
);
|
|
60
|
+
// Environment refusals keep their own kind: folding a usage limit or an
|
|
61
|
+
// outdated CLI into `*_cli_unresolved` reported them as `invalid_spec`,
|
|
62
|
+
// telling the author to fix a flow that was never wrong.
|
|
59
63
|
throw new AuthoredFlowExecutionError(
|
|
60
|
-
refusal?.kind === 'budget_syntax_invalid'
|
|
64
|
+
refusal?.kind === 'budget_syntax_invalid' || refusal?.kind === 'cli_outdated'
|
|
65
|
+
|| refusal?.kind === 'provider_usage_limited' ? refusal.kind
|
|
61
66
|
: step.type === 'llm' ? 'llm_cli_unresolved' : 'agent_cli_unresolved',
|
|
62
67
|
refusal?.message
|
|
63
68
|
?? `flow "${definition.name}" step "${id}": no CLI could be resolved for f.${step.type} `
|
|
@@ -135,7 +140,7 @@ export function authoredWorkerRunner(
|
|
|
135
140
|
details,
|
|
136
141
|
);
|
|
137
142
|
}
|
|
138
|
-
return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, context);
|
|
143
|
+
return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, context, execution.report.status);
|
|
139
144
|
};
|
|
140
145
|
const admissionKey = authoredChildAdmissionKey(rootRunId, id);
|
|
141
146
|
const admit = async () => budget === undefined
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { inputFailureReport, type CheckExecution } from './check.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fail closed on body-level `f.on` calls that visibly omit an end bound.
|
|
7
|
+
* Dynamic options cannot be proved at check time and are refused here too;
|
|
8
|
+
* runtime validation covers JS callers and values assembled at runtime.
|
|
9
|
+
*/
|
|
10
|
+
export async function checkAuthoredActivities(path: string): Promise<CheckExecution> {
|
|
11
|
+
try {
|
|
12
|
+
const source = await readFile(path, 'utf8');
|
|
13
|
+
const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
|
|
14
|
+
let diagnostic: string | undefined;
|
|
15
|
+
const visit = (node: ts.Node, context: ContextNames): void => {
|
|
16
|
+
if (diagnostic !== undefined) return;
|
|
17
|
+
if (ts.isCallExpression(node) && isContextOn(node.expression, context)) {
|
|
18
|
+
const options = node.arguments[1];
|
|
19
|
+
if (options === undefined || !ts.isObjectLiteralExpression(options)) {
|
|
20
|
+
diagnostic = 'body-level f.on() requires literal idle and deadline bounds (unbounded_subscription)';
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const keys = new Set(options.properties.flatMap((property) => {
|
|
24
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return [];
|
|
25
|
+
return ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? [property.name.text] : [];
|
|
26
|
+
}));
|
|
27
|
+
if (!keys.has('idle') || !keys.has('deadline')) {
|
|
28
|
+
diagnostic = 'body-level f.on() requires both idle and deadline bounds (unbounded_subscription)';
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// A nested function that rebinds a context name is not the flow context.
|
|
33
|
+
const inner = ts.isFunctionLike(node) ? shadow(context, node.parameters) : context;
|
|
34
|
+
ts.forEachChild(node, (child) => visit(child, inner));
|
|
35
|
+
};
|
|
36
|
+
for (const body of authoredBodies(file)) {
|
|
37
|
+
if (body.body !== undefined) visit(body.body, contextNames(body));
|
|
38
|
+
}
|
|
39
|
+
if (diagnostic !== undefined) {
|
|
40
|
+
return { report: inputFailureReport({ kind: 'invalid_spec', message: diagnostic }, path) };
|
|
41
|
+
}
|
|
42
|
+
return { report: { ok: true, path, gates: [], resolutions: [], diagnostics: [] } };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return { report: inputFailureReport({ kind: 'invalid_spec',
|
|
45
|
+
message: error instanceof Error ? error.message : 'Cannot inspect body-level activities' }, path) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ContextNames {
|
|
50
|
+
/** Identifiers bound to a body's flow context, e.g. `f` or `ctx`. */
|
|
51
|
+
readonly objects: Set<string>;
|
|
52
|
+
/** Local names bound to a destructured `on`, e.g. `({ on }) => on(...)`. */
|
|
53
|
+
readonly on: Set<string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type BodyFunction = ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every authored body in this file: the last function argument of a
|
|
60
|
+
* `flow(...)` call, each trigger handler registered with `.on(trigger, body)`
|
|
61
|
+
* on a flow handle, and the default export, inline or named by an identifier
|
|
62
|
+
* declared in the same file. Only these bodies are checked.
|
|
63
|
+
*/
|
|
64
|
+
function authoredBodies(file: ts.SourceFile): Set<BodyFunction> {
|
|
65
|
+
const functions = new Map<string, BodyFunction>();
|
|
66
|
+
const handles = new Set<string>();
|
|
67
|
+
const isFlowHandle = (node: ts.Expression): boolean => {
|
|
68
|
+
if (ts.isIdentifier(node)) return handles.has(node.text);
|
|
69
|
+
if (!ts.isCallExpression(node)) return false;
|
|
70
|
+
if (ts.isIdentifier(node.expression)) return node.expression.text === 'flow';
|
|
71
|
+
return ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'on'
|
|
72
|
+
&& isFlowHandle(node.expression.expression);
|
|
73
|
+
};
|
|
74
|
+
const bodies = new Set<BodyFunction>();
|
|
75
|
+
const references: string[] = [];
|
|
76
|
+
const addBody = (node: ts.Node | undefined): void => {
|
|
77
|
+
if (node === undefined) return;
|
|
78
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) bodies.add(node);
|
|
79
|
+
else if (ts.isIdentifier(node)) references.push(node.text);
|
|
80
|
+
};
|
|
81
|
+
const visit = (node: ts.Node): void => {
|
|
82
|
+
if (ts.isFunctionDeclaration(node) && node.name !== undefined) functions.set(node.name.text, node);
|
|
83
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined
|
|
84
|
+
&& (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
|
|
85
|
+
functions.set(node.name.text, node.initializer);
|
|
86
|
+
}
|
|
87
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined
|
|
88
|
+
&& isFlowHandle(node.initializer)) {
|
|
89
|
+
handles.add(node.name.text);
|
|
90
|
+
}
|
|
91
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'flow') {
|
|
92
|
+
addBody(node.arguments[node.arguments.length - 1]);
|
|
93
|
+
}
|
|
94
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
|
95
|
+
&& node.expression.name.text === 'on' && isFlowHandle(node.expression.expression)) {
|
|
96
|
+
addBody(node.arguments[1]);
|
|
97
|
+
}
|
|
98
|
+
if (ts.isExportAssignment(node) && !node.isExportEquals) addBody(node.expression);
|
|
99
|
+
if (ts.isFunctionDeclaration(node) && node.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) addBody(node);
|
|
100
|
+
ts.forEachChild(node, visit);
|
|
101
|
+
};
|
|
102
|
+
visit(file);
|
|
103
|
+
for (const name of references) {
|
|
104
|
+
const body = functions.get(name);
|
|
105
|
+
if (body !== undefined) bodies.add(body);
|
|
106
|
+
}
|
|
107
|
+
return bodies;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function contextNames(body: BodyFunction): ContextNames {
|
|
111
|
+
const names: ContextNames = { objects: new Set(), on: new Set() };
|
|
112
|
+
const parameter = body.parameters[0]?.name;
|
|
113
|
+
if (parameter === undefined) return names;
|
|
114
|
+
if (ts.isIdentifier(parameter)) names.objects.add(parameter.text);
|
|
115
|
+
else if (ts.isObjectBindingPattern(parameter)) {
|
|
116
|
+
for (const element of parameter.elements) {
|
|
117
|
+
const key = element.propertyName ?? element.name;
|
|
118
|
+
if (ts.isIdentifier(key) && key.text === 'on' && ts.isIdentifier(element.name)) names.on.add(element.name.text);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return names;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shadow(context: ContextNames, parameters: ts.NodeArray<ts.ParameterDeclaration>): ContextNames {
|
|
125
|
+
const bound = new Set<string>();
|
|
126
|
+
const collect = (name: ts.BindingName): void => {
|
|
127
|
+
if (ts.isIdentifier(name)) bound.add(name.text);
|
|
128
|
+
else for (const element of name.elements) if (!ts.isOmittedExpression(element)) collect(element.name);
|
|
129
|
+
};
|
|
130
|
+
for (const parameter of parameters) collect(parameter.name);
|
|
131
|
+
if (bound.size === 0) return context;
|
|
132
|
+
return {
|
|
133
|
+
objects: new Set([...context.objects].filter(name => !bound.has(name))),
|
|
134
|
+
on: new Set([...context.on].filter(name => !bound.has(name))),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isContextOn(expression: ts.LeftHandSideExpression, context: ContextNames): boolean {
|
|
139
|
+
if (ts.isIdentifier(expression)) return context.on.has(expression.text);
|
|
140
|
+
return ts.isPropertyAccessExpression(expression)
|
|
141
|
+
&& ts.isIdentifier(expression.expression)
|
|
142
|
+
&& context.objects.has(expression.expression.text)
|
|
143
|
+
&& expression.name.text === 'on';
|
|
144
|
+
}
|
package/src/cli/cli-probe.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { accessSync, constants } from 'node:fs';
|
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { execFile, spawnSync } from 'node:child_process';
|
|
4
4
|
import { agentEnvironment, brokerEnvironment } from '../communication/environment.js';
|
|
5
|
-
import { adapterIdentification, authenticationProbe,
|
|
6
|
-
modelReadinessProbe, type CliInvocation } from '../cli-adapter.js';
|
|
5
|
+
import { adapterIdentification, authenticationProbe, classifyModelProbeFailure, cliAdapterKind,
|
|
6
|
+
displayInvocation, modelReadinessProbe, type CliInvocation } from '../cli-adapter.js';
|
|
7
7
|
import { MODEL_ENV } from '../worker-cli.js';
|
|
8
8
|
import { CliProbeError, type CliProbeResult } from '../preflight.js';
|
|
9
9
|
|
|
@@ -117,7 +117,8 @@ function* probeSequence(
|
|
|
117
117
|
// A successful real provider round trip (or identified wrapper probe)
|
|
118
118
|
// proves both auth and exact-model access. On failure, run the adapter's
|
|
119
119
|
// actual auth command solely to classify auth vs model access truthfully.
|
|
120
|
-
|
|
120
|
+
const scopedProbe = yield probe(scoped);
|
|
121
|
+
if (scopedProbe.status === 0) {
|
|
121
122
|
return {
|
|
122
123
|
exists: true,
|
|
123
124
|
supported: true,
|
|
@@ -129,6 +130,14 @@ function* probeSequence(
|
|
|
129
130
|
}
|
|
130
131
|
const authProbe = yield probe(auth);
|
|
131
132
|
const authenticated = authProbe.status === 0;
|
|
133
|
+
// The scoped probe's own output is the only evidence of WHY it failed. A
|
|
134
|
+
// usage-limited credential or an outdated CLI exits non-zero exactly like an
|
|
135
|
+
// unknown model; discarding the output made all three "verify the model
|
|
136
|
+
// name". Classified on the raw text, surfaced redacted — stdout first,
|
|
137
|
+
// because Claude Code prints the provider's API error there and its own
|
|
138
|
+
// catalog warnings on stderr.
|
|
139
|
+
const failure = classifyModelProbeFailure(kind, `${scopedProbe.stdout}\n${scopedProbe.stderr}`);
|
|
140
|
+
const modelDetail = redactProbeOutput(scopedProbe.stdout.trim() || scopedProbe.stderr.trim()).slice(0, 500);
|
|
132
141
|
return {
|
|
133
142
|
exists: true,
|
|
134
143
|
supported: true,
|
|
@@ -136,6 +145,9 @@ function* probeSequence(
|
|
|
136
145
|
modelAvailable: false,
|
|
137
146
|
authCommand,
|
|
138
147
|
modelCommand,
|
|
148
|
+
modelExitCode: scopedProbe.status,
|
|
149
|
+
...(failure === undefined ? {} : { modelFailure: failure }),
|
|
150
|
+
...(modelDetail.length > 0 ? { modelFailureDetail: modelDetail } : {}),
|
|
139
151
|
// Only on failure: on success there is nothing to explain, and the output
|
|
140
152
|
// is the most identity-bearing thing this function touches.
|
|
141
153
|
...(authenticated
|