specrails-core 5.2.3 → 5.4.0
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/README.md +3 -0
- package/bin/specrails-core.mjs +5 -0
- package/dist/agent-runtime/capabilities.d.ts +27 -0
- package/dist/agent-runtime/capabilities.js +73 -0
- package/dist/agent-runtime/capabilities.js.map +1 -0
- package/dist/agent-runtime/cli-executor.d.ts +43 -0
- package/dist/agent-runtime/cli-executor.js +405 -0
- package/dist/agent-runtime/cli-executor.js.map +1 -0
- package/dist/agent-runtime/cli-process.d.ts +32 -0
- package/dist/agent-runtime/cli-process.js +159 -0
- package/dist/agent-runtime/cli-process.js.map +1 -0
- package/dist/agent-runtime/cli.d.ts +39 -0
- package/dist/agent-runtime/cli.js +216 -0
- package/dist/agent-runtime/cli.js.map +1 -0
- package/dist/agent-runtime/codex-schema.d.ts +4 -0
- package/dist/agent-runtime/codex-schema.js +31 -0
- package/dist/agent-runtime/codex-schema.js.map +1 -0
- package/dist/agent-runtime/config.d.ts +11 -0
- package/dist/agent-runtime/config.js +263 -0
- package/dist/agent-runtime/config.js.map +1 -0
- package/dist/agent-runtime/core-host.d.ts +41 -0
- package/dist/agent-runtime/core-host.js +132 -0
- package/dist/agent-runtime/core-host.js.map +1 -0
- package/dist/agent-runtime/durable-store.d.ts +22 -0
- package/dist/agent-runtime/durable-store.js +205 -0
- package/dist/agent-runtime/durable-store.js.map +1 -0
- package/dist/agent-runtime/efficiency-summary.d.ts +67 -0
- package/dist/agent-runtime/efficiency-summary.js +52 -0
- package/dist/agent-runtime/efficiency-summary.js.map +1 -0
- package/dist/agent-runtime/efficiency-types.d.ts +60 -0
- package/dist/agent-runtime/efficiency-types.js +8 -0
- package/dist/agent-runtime/efficiency-types.js.map +1 -0
- package/dist/agent-runtime/efficiency.d.ts +5 -0
- package/dist/agent-runtime/efficiency.js +35 -0
- package/dist/agent-runtime/efficiency.js.map +1 -0
- package/dist/agent-runtime/evaluation-corpus.d.ts +13 -0
- package/dist/agent-runtime/evaluation-corpus.js +8 -0
- package/dist/agent-runtime/evaluation-corpus.js.map +1 -0
- package/dist/agent-runtime/evaluation.d.ts +43 -0
- package/dist/agent-runtime/evaluation.js +171 -0
- package/dist/agent-runtime/evaluation.js.map +1 -0
- package/dist/agent-runtime/executor-types.d.ts +125 -0
- package/dist/agent-runtime/executor-types.js +29 -0
- package/dist/agent-runtime/executor-types.js.map +1 -0
- package/dist/agent-runtime/executors.d.ts +23 -0
- package/dist/agent-runtime/executors.js +48 -0
- package/dist/agent-runtime/executors.js.map +1 -0
- package/dist/agent-runtime/gemini-policy.d.ts +8 -0
- package/dist/agent-runtime/gemini-policy.js +36 -0
- package/dist/agent-runtime/gemini-policy.js.map +1 -0
- package/dist/agent-runtime/graph/artifacts.d.ts +26 -0
- package/dist/agent-runtime/graph/artifacts.js +205 -0
- package/dist/agent-runtime/graph/artifacts.js.map +1 -0
- package/dist/agent-runtime/graph/nodes.d.ts +39 -0
- package/dist/agent-runtime/graph/nodes.js +321 -0
- package/dist/agent-runtime/graph/nodes.js.map +1 -0
- package/dist/agent-runtime/graph/review-policy.d.ts +18 -0
- package/dist/agent-runtime/graph/review-policy.js +32 -0
- package/dist/agent-runtime/graph/review-policy.js.map +1 -0
- package/dist/agent-runtime/graph/roles.d.ts +47 -0
- package/dist/agent-runtime/graph/roles.js +190 -0
- package/dist/agent-runtime/graph/roles.js.map +1 -0
- package/dist/agent-runtime/graph/state.d.ts +83 -0
- package/dist/agent-runtime/graph/state.js +23 -0
- package/dist/agent-runtime/graph/state.js.map +1 -0
- package/dist/agent-runtime/graph-checkpointer.d.ts +61 -0
- package/dist/agent-runtime/graph-checkpointer.js +137 -0
- package/dist/agent-runtime/graph-checkpointer.js.map +1 -0
- package/dist/agent-runtime/index.d.ts +20 -0
- package/dist/agent-runtime/index.js +21 -0
- package/dist/agent-runtime/index.js.map +1 -0
- package/dist/agent-runtime/kimi-acp.d.ts +11 -0
- package/dist/agent-runtime/kimi-acp.js +190 -0
- package/dist/agent-runtime/kimi-acp.js.map +1 -0
- package/dist/agent-runtime/openai-executor.d.ts +26 -0
- package/dist/agent-runtime/openai-executor.js +192 -0
- package/dist/agent-runtime/openai-executor.js.map +1 -0
- package/dist/agent-runtime/openspec-tool-server.d.ts +1 -0
- package/dist/agent-runtime/openspec-tool-server.js +37 -0
- package/dist/agent-runtime/openspec-tool-server.js.map +1 -0
- package/dist/agent-runtime/openspec.d.ts +123 -0
- package/dist/agent-runtime/openspec.js +263 -0
- package/dist/agent-runtime/openspec.js.map +1 -0
- package/dist/agent-runtime/prompts.d.ts +44 -0
- package/dist/agent-runtime/prompts.js +310 -0
- package/dist/agent-runtime/prompts.js.map +1 -0
- package/dist/agent-runtime/provider-diagnostic.d.ts +2 -0
- package/dist/agent-runtime/provider-diagnostic.js +25 -0
- package/dist/agent-runtime/provider-diagnostic.js.map +1 -0
- package/dist/agent-runtime/repository-context.d.ts +25 -0
- package/dist/agent-runtime/repository-context.js +100 -0
- package/dist/agent-runtime/repository-context.js.map +1 -0
- package/dist/agent-runtime/review-context.d.ts +14 -0
- package/dist/agent-runtime/review-context.js +41 -0
- package/dist/agent-runtime/review-context.js.map +1 -0
- package/dist/agent-runtime/role-routing.d.ts +10 -0
- package/dist/agent-runtime/role-routing.js +29 -0
- package/dist/agent-runtime/role-routing.js.map +1 -0
- package/dist/agent-runtime/role-state.d.ts +19 -0
- package/dist/agent-runtime/role-state.js +24 -0
- package/dist/agent-runtime/role-state.js.map +1 -0
- package/dist/agent-runtime/runtime-identity.d.ts +11 -0
- package/dist/agent-runtime/runtime-identity.js +33 -0
- package/dist/agent-runtime/runtime-identity.js.map +1 -0
- package/dist/agent-runtime/tool-event.d.ts +3 -0
- package/dist/agent-runtime/tool-event.js +24 -0
- package/dist/agent-runtime/tool-event.js.map +1 -0
- package/dist/agent-runtime/verification-plan.d.ts +55 -0
- package/dist/agent-runtime/verification-plan.js +206 -0
- package/dist/agent-runtime/verification-plan.js.map +1 -0
- package/dist/agent-runtime/workflow-types.d.ts +226 -0
- package/dist/agent-runtime/workflow-types.js +2 -0
- package/dist/agent-runtime/workflow-types.js.map +1 -0
- package/dist/agent-runtime/workflow.d.ts +17 -0
- package/dist/agent-runtime/workflow.js +636 -0
- package/dist/agent-runtime/workflow.js.map +1 -0
- package/dist/agent-runtime/workspace-tools.d.ts +29 -0
- package/dist/agent-runtime/workspace-tools.js +282 -0
- package/dist/agent-runtime/workspace-tools.js.map +1 -0
- package/dist/installer/cli.d.ts +35 -0
- package/dist/installer/cli.js +3 -0
- package/dist/installer/cli.js.map +1 -1
- package/dist/installer/commands/doctor.d.ts +27 -0
- package/dist/installer/commands/framework.d.ts +85 -0
- package/dist/installer/commands/init.d.ts +147 -0
- package/dist/installer/commands/update.d.ts +56 -0
- package/dist/installer/commands/v5-migration.d.ts +32 -0
- package/dist/installer/phases/framework-lifecycle.d.ts +53 -0
- package/dist/installer/phases/install-config.d.ts +64 -0
- package/dist/installer/phases/manifest.d.ts +45 -0
- package/dist/installer/phases/prereqs.d.ts +51 -0
- package/dist/installer/phases/provider-detect.d.ts +89 -0
- package/dist/installer/phases/scaffold.d.ts +211 -0
- package/dist/installer/phases/scaffold.js +39 -109
- package/dist/installer/phases/scaffold.js.map +1 -1
- package/dist/installer/runtime/kimi.d.ts +84 -0
- package/dist/installer/runtime/pipeline-state.d.ts +351 -0
- package/dist/installer/runtime/pipeline-state.js +514 -41
- package/dist/installer/runtime/pipeline-state.js.map +1 -1
- package/dist/installer/util/errors.d.ts +46 -0
- package/dist/installer/util/exec.d.ts +41 -0
- package/dist/installer/util/fs.d.ts +153 -0
- package/dist/installer/util/git.d.ts +44 -0
- package/dist/installer/util/install-transaction.d.ts +29 -0
- package/dist/installer/util/logger.d.ts +31 -0
- package/dist/installer/util/paths.d.ts +34 -0
- package/dist/installer/util/prompts.d.ts +23 -0
- package/dist/installer/util/registry.d.ts +174 -0
- package/dist/installer/util/template.d.ts +23 -0
- package/docs/README.md +1 -0
- package/docs/agent-runtime-efficiency.md +65 -0
- package/docs/agent-runtime.md +339 -0
- package/integration-contract.json +80 -7
- package/package.json +20 -2
- package/schemas/agent-runtime.schema.json +77 -0
- package/schemas/fixtures/runtime-efficiency-summary.v1.json +790 -0
- package/templates/codex-skills/batch-implement/SKILL.md +33 -58
- package/templates/codex-skills/implement/SKILL.md +21 -124
- package/templates/codex-skills/retry/SKILL.md +8 -34
- package/templates/commands/specrails/batch-implement.md +21 -16
- package/templates/commands/specrails/implement.md +17 -276
- package/templates/commands/specrails/retry.md +6 -34
- package/templates/gemini-commands/batch-implement.toml +34 -28
- package/templates/gemini-commands/implement.toml +34 -55
- package/templates/gemini-commands/retry.toml +10 -16
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { Command, END, START, StateGraph, interrupt, isGraphInterrupt, isInterrupted } from '@langchain/langgraph';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
import { acquireWorkflowLease, fingerprint, readWorkflowEnvelope, writeWorkflowEnvelope } from './durable-store.js';
|
|
5
|
+
import { FileCheckpointSaver } from './graph-checkpointer.js';
|
|
6
|
+
export * from './workflow-types.js';
|
|
7
|
+
export { readWorkflowState, readWorkflowEnvelope, writeWorkflowEnvelope, WorkflowStoreError } from './durable-store.js';
|
|
8
|
+
export class WorkflowError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'WorkflowError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Leaves the graph parked at the node whose terminal receipt is already committed, so a resume re-runs it. */
|
|
17
|
+
class StepTerminated extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status) {
|
|
20
|
+
super(`Workflow ${status}`);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.name = 'StepTerminated';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const clone = (value) => structuredClone(value);
|
|
26
|
+
const NODE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,119}$/;
|
|
27
|
+
const RESERVED = new Set([START, END, 'next']);
|
|
28
|
+
const ANSWER_LIMIT = 20_000;
|
|
29
|
+
function validateBudget(budget) {
|
|
30
|
+
for (const [name, value] of Object.entries(budget)) {
|
|
31
|
+
if (value === undefined && ['maxCostUsd', 'maxTokens', 'maxDurationMs'].includes(name))
|
|
32
|
+
continue;
|
|
33
|
+
if (!['maxCostUsd', 'maxTokens', 'maxDurationMs'].includes(name) || typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
34
|
+
throw new WorkflowError('INVALID_WORKFLOW', `Invalid workflow budget ${name}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function definedBudget(budget) {
|
|
39
|
+
return Object.fromEntries(Object.entries(budget).filter(([, value]) => value !== undefined));
|
|
40
|
+
}
|
|
41
|
+
function validateWorkflow(workflow) {
|
|
42
|
+
const ids = Object.keys(workflow.nodes ?? {});
|
|
43
|
+
if (typeof workflow.id !== 'string' || !workflow.id || typeof workflow.version !== 'string' || !workflow.version || !ids.length || !workflow.schema) {
|
|
44
|
+
throw new WorkflowError('INVALID_WORKFLOW', 'Workflow requires an id, version, state schema and at least one node');
|
|
45
|
+
}
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
const node = workflow.nodes[id];
|
|
48
|
+
if (!NODE_ID.test(id) || RESERVED.has(id) || typeof node.run !== 'function' || !Array.isArray(node.ends) ||
|
|
49
|
+
node.ends.some(end => !ids.includes(end)) || new Set(node.ends).size !== node.ends.length ||
|
|
50
|
+
(node.effect !== undefined && node.effect !== 'read' && node.effect !== 'write') ||
|
|
51
|
+
!Number.isInteger(node.maxAttempts ?? 1) || (node.maxAttempts ?? 1) < 1 || (node.maxAttempts ?? 1) > 100) {
|
|
52
|
+
throw new WorkflowError('INVALID_WORKFLOW', `Invalid workflow node ${id}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!ids.includes(workflow.entry))
|
|
56
|
+
throw new WorkflowError('INVALID_WORKFLOW', `Unknown entry node ${workflow.entry}`);
|
|
57
|
+
// LangGraph addresses channels and nodes in one namespace.
|
|
58
|
+
const channels = Object.keys(workflow.schema.spec ?? {});
|
|
59
|
+
for (const id of ids)
|
|
60
|
+
if (channels.includes(id))
|
|
61
|
+
throw new WorkflowError('INVALID_WORKFLOW', `Node ${id} shares its name with a state channel`);
|
|
62
|
+
const maxTransitions = workflow.maxTransitions ?? 100;
|
|
63
|
+
if (!Number.isInteger(maxTransitions) || maxTransitions < 1 || maxTransitions > 10_000) {
|
|
64
|
+
throw new WorkflowError('INVALID_WORKFLOW', 'maxTransitions must be an integer from 1 to 10000');
|
|
65
|
+
}
|
|
66
|
+
return fingerprint({
|
|
67
|
+
id: workflow.id, version: workflow.version, maxTransitions, entry: workflow.entry,
|
|
68
|
+
nodes: ids.map(id => ({ id, effect: workflow.nodes[id].effect ?? 'read', maxAttempts: workflow.nodes[id].maxAttempts ?? 1, retrySafe: workflow.nodes[id].retrySafe ?? false, ends: workflow.nodes[id].ends })),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function validateUsage(usage) {
|
|
72
|
+
if (usage === undefined)
|
|
73
|
+
return;
|
|
74
|
+
for (const key of ['costUsd', 'inputTokens', 'outputTokens']) {
|
|
75
|
+
const value = usage[key];
|
|
76
|
+
if (value !== undefined && value !== null && (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || (key !== 'costUsd' && !Number.isInteger(value)))) {
|
|
77
|
+
throw new TypeError(`Invalid step usage ${key}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function accountUsage(state, usage) {
|
|
82
|
+
for (const key of ['costUsd', 'inputTokens', 'outputTokens']) {
|
|
83
|
+
const value = usage?.[key];
|
|
84
|
+
state.usage[key] = state.usage[key] === null || value == null ? null : state.usage[key] + value;
|
|
85
|
+
}
|
|
86
|
+
state.usage.knownCostUsd += usage?.costUsd ?? 0;
|
|
87
|
+
state.usage.knownTokens += (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
88
|
+
}
|
|
89
|
+
/** Sum of everything an attempt reported; unknown values stay unknown instead of becoming zero. */
|
|
90
|
+
function addUsage(total, usage) {
|
|
91
|
+
const add = (a, b) => a === null || b === null || b === undefined && a === undefined ? null : (a ?? 0) + (b ?? 0);
|
|
92
|
+
return { costUsd: add(total.costUsd, usage?.costUsd), inputTokens: add(total.inputTokens, usage?.inputTokens), outputTokens: add(total.outputTokens, usage?.outputTokens) };
|
|
93
|
+
}
|
|
94
|
+
function budgetError(state, currentElapsedMs = 0, completed = false) {
|
|
95
|
+
if (state.budget.maxCostUsd !== undefined && (completed ? state.usage.knownCostUsd > state.budget.maxCostUsd : state.usage.knownCostUsd >= state.budget.maxCostUsd))
|
|
96
|
+
return 'Workflow cost budget exhausted';
|
|
97
|
+
if (state.budget.maxTokens !== undefined && (completed ? state.usage.knownTokens > state.budget.maxTokens : state.usage.knownTokens >= state.budget.maxTokens))
|
|
98
|
+
return 'Workflow token budget exhausted';
|
|
99
|
+
if (state.budget.maxDurationMs !== undefined && state.usage.durationMs + currentElapsedMs >= state.budget.maxDurationMs)
|
|
100
|
+
return 'Workflow duration budget exhausted';
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
function initialState(options, workflowFingerprint, inputFingerprint) {
|
|
104
|
+
const now = new Date().toISOString();
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: 2, runId: options.runId, traceId: randomUUID(),
|
|
107
|
+
workflowId: options.workflow.id, workflowVersion: options.workflow.version,
|
|
108
|
+
workflowFingerprint, inputFingerprint, status: 'running', createdAt: now, updatedAt: now,
|
|
109
|
+
nextStep: options.workflow.entry, nextAttempt: 1, transitions: 0, executionCount: 0,
|
|
110
|
+
steps: Object.fromEntries(Object.entries(options.workflow.nodes).map(([id, node]) => [id, { id, status: 'pending', effect: node.effect ?? 'read', visits: 0, attempt: 0 }])),
|
|
111
|
+
history: [], events: [], budget: definedBudget(options.budget ?? {}),
|
|
112
|
+
usage: { costUsd: 0, inputTokens: 0, outputTokens: 0, knownCostUsd: 0, knownTokens: 0, durationMs: 0 },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The interrupt a step raised earlier, as the host ledger recorded it. */
|
|
116
|
+
function pendingRequest(state, stepId) {
|
|
117
|
+
if (state.pendingApproval?.stepId === stepId)
|
|
118
|
+
return { kind: 'approval', reason: state.pendingApproval.reason ?? 'Approval required' };
|
|
119
|
+
if (state.pendingQuestion?.stepId === stepId)
|
|
120
|
+
return { kind: 'question', question: state.pendingQuestion.question };
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function resetRecord(state, id) {
|
|
124
|
+
const record = state.steps[id];
|
|
125
|
+
record.status = 'pending';
|
|
126
|
+
delete record.output;
|
|
127
|
+
delete record.update;
|
|
128
|
+
delete record.next;
|
|
129
|
+
delete record.error;
|
|
130
|
+
delete record.completedAt;
|
|
131
|
+
}
|
|
132
|
+
/** JSON-only outputs keep the checkpoint portable; a lossy value fails the attempt instead of corrupting the ledger. */
|
|
133
|
+
function jsonOrError(value, what) {
|
|
134
|
+
if (value === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
try {
|
|
137
|
+
fingerprint(value);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return `Invalid ${what}: ${error instanceof Error ? error.message : String(error)}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Execute a LangGraph state graph with a durable host ledger at every effect
|
|
146
|
+
* boundary. LangGraph owns traversal, state reducers, checkpoints, interrupts
|
|
147
|
+
* and time travel; the ledger owns receipts, usage, budgets, leases and
|
|
148
|
+
* interrupted-write recovery, and it is authoritative for which node runs
|
|
149
|
+
* next. Both are written through one atomic envelope, so they cannot disagree
|
|
150
|
+
* after a crash. Cancellation is cooperative; node callbacks must settle only
|
|
151
|
+
* after their owned subprocesses/tools have stopped.
|
|
152
|
+
*/
|
|
153
|
+
export async function runWorkflow(options) {
|
|
154
|
+
const definitionFingerprint = validateWorkflow(options.workflow);
|
|
155
|
+
const inputFingerprint = fingerprint(options.input);
|
|
156
|
+
const input = clone(options.input);
|
|
157
|
+
validateBudget(options.budget ?? {});
|
|
158
|
+
const ids = Object.keys(options.workflow.nodes);
|
|
159
|
+
for (const id of [...options.approve ?? [], ...options.recoverInterrupted ?? [], ...options.invalidate ?? []]) {
|
|
160
|
+
if (!ids.includes(id))
|
|
161
|
+
throw new WorkflowError('INVALID_WORKFLOW', `Unknown recovery/approval/invalidation step ${id}`);
|
|
162
|
+
}
|
|
163
|
+
if (options.answer !== undefined && (typeof options.answer !== 'string' || !options.answer.trim() || options.answer.length > ANSWER_LIMIT)) {
|
|
164
|
+
throw new WorkflowError('INVALID_WORKFLOW', 'An answer must be a nonempty string of at most 20000 characters');
|
|
165
|
+
}
|
|
166
|
+
const release = await acquireWorkflowLease(options.directory, options.runId);
|
|
167
|
+
let timeout;
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
let timeoutReached = false;
|
|
170
|
+
const abort = () => { controller.abort(options.signal?.reason); };
|
|
171
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
172
|
+
if (options.signal?.aborted)
|
|
173
|
+
abort();
|
|
174
|
+
try {
|
|
175
|
+
const envelope = await readWorkflowEnvelope(options.directory, options.runId);
|
|
176
|
+
const existing = envelope?.state ?? null;
|
|
177
|
+
if (existing && !options.resume)
|
|
178
|
+
throw new WorkflowError('ALREADY_EXISTS', `Workflow ${options.runId} already exists; resume explicitly`);
|
|
179
|
+
if (!existing && options.resume)
|
|
180
|
+
throw new WorkflowError('NOT_FOUND', `Workflow ${options.runId} does not exist`);
|
|
181
|
+
if (existing && (existing.workflowFingerprint !== definitionFingerprint || existing.inputFingerprint !== inputFingerprint)) {
|
|
182
|
+
throw new WorkflowError('INCOMPATIBLE_RESUME', 'Workflow definition/version or input differs from the saved run');
|
|
183
|
+
}
|
|
184
|
+
const state = existing ?? initialState(options, definitionFingerprint, inputFingerprint);
|
|
185
|
+
let graphStore = envelope?.graph;
|
|
186
|
+
let elapsedMark = performance.now();
|
|
187
|
+
// Ledger receipts and LangGraph checkpoints share one file; writes are
|
|
188
|
+
// queued so two envelopes never race for the same rename.
|
|
189
|
+
let persistQueue = Promise.resolve();
|
|
190
|
+
const persist = () => {
|
|
191
|
+
const next = persistQueue.catch(() => undefined).then(async () => {
|
|
192
|
+
const elapsed = performance.now();
|
|
193
|
+
state.usage.durationMs += Math.max(0, elapsed - elapsedMark);
|
|
194
|
+
elapsedMark = elapsed;
|
|
195
|
+
state.updatedAt = new Date().toISOString();
|
|
196
|
+
await writeWorkflowEnvelope(options.directory, { state, ...(graphStore ? { graph: graphStore } : {}) });
|
|
197
|
+
});
|
|
198
|
+
persistQueue = next;
|
|
199
|
+
return next;
|
|
200
|
+
};
|
|
201
|
+
const commit = async (...events) => {
|
|
202
|
+
const now = new Date().toISOString();
|
|
203
|
+
const committed = events.map(event => {
|
|
204
|
+
const sequence = state.events.length + 1;
|
|
205
|
+
const entry = { ...event, id: `${state.runId}:${sequence}`, sequence, runId: state.runId, traceId: state.traceId, timestamp: now };
|
|
206
|
+
state.events.push(entry);
|
|
207
|
+
return entry;
|
|
208
|
+
});
|
|
209
|
+
await persist();
|
|
210
|
+
for (const event of committed) {
|
|
211
|
+
try {
|
|
212
|
+
await options.onEvent?.(clone(event));
|
|
213
|
+
}
|
|
214
|
+
catch { /* Observers cannot invalidate a committed effect. */ }
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const span = async (attempt) => {
|
|
218
|
+
if (!options.onSpan || !attempt.completedAt)
|
|
219
|
+
return;
|
|
220
|
+
const record = {
|
|
221
|
+
traceId: state.traceId, spanId: attempt.id, name: `${state.workflowId}.${attempt.stepId}`, stepId: attempt.stepId,
|
|
222
|
+
attempt: attempt.attempt, visit: attempt.visit, startedAt: attempt.startedAt, endedAt: attempt.completedAt, status: attempt.status,
|
|
223
|
+
...(attempt.usage ? { usage: attempt.usage } : {}), ...(attempt.error ? { error: attempt.error } : {}),
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
await options.onSpan(clone(record));
|
|
227
|
+
}
|
|
228
|
+
catch { /* Tracing cannot replay a committed effect. */ }
|
|
229
|
+
};
|
|
230
|
+
const finish = async (status, message) => {
|
|
231
|
+
state.status = status;
|
|
232
|
+
if (message)
|
|
233
|
+
state.error = message;
|
|
234
|
+
else
|
|
235
|
+
delete state.error;
|
|
236
|
+
await commit({ type: `workflow_${status}`, ...(message ? { message } : {}) });
|
|
237
|
+
};
|
|
238
|
+
const stopped = () => timeoutReached ? 'blocked' : 'cancelled';
|
|
239
|
+
const stoppedReason = () => timeoutReached ? 'Workflow duration budget exhausted' : 'Workflow cancelled';
|
|
240
|
+
if (!existing)
|
|
241
|
+
await commit({ type: 'workflow_started' });
|
|
242
|
+
// A write interrupted by process death remains ambiguous even if the caller
|
|
243
|
+
// also asks to invalidate it. Only an explicit recovery decision clears it.
|
|
244
|
+
const interrupted = state.nextStep ? state.steps[state.nextStep] : undefined;
|
|
245
|
+
if (existing && interrupted && (interrupted.status === 'running' || interrupted.status === 'interrupted')) {
|
|
246
|
+
if (interrupted.status === 'running') {
|
|
247
|
+
interrupted.status = 'interrupted';
|
|
248
|
+
// The crashed callback may have consumed unreported provider usage.
|
|
249
|
+
accountUsage(state);
|
|
250
|
+
const attempt = state.history.find(item => item.id === interrupted.attemptId);
|
|
251
|
+
if (attempt) {
|
|
252
|
+
attempt.status = 'interrupted';
|
|
253
|
+
attempt.error = 'Process ended before the step receipt was committed';
|
|
254
|
+
}
|
|
255
|
+
await commit({ type: 'step_interrupted', stepId: interrupted.id, attemptId: interrupted.attemptId, spanId: interrupted.attemptId });
|
|
256
|
+
}
|
|
257
|
+
if (interrupted.effect === 'write' && !options.recoverInterrupted?.includes(interrupted.id)) {
|
|
258
|
+
const message = `Interrupted write step ${interrupted.id} requires explicit recovery`;
|
|
259
|
+
if (state.status !== 'blocked' || state.error !== message)
|
|
260
|
+
await finish('blocked', message);
|
|
261
|
+
return clone(state);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const invalid = new Set(options.invalidate ?? []);
|
|
265
|
+
if (existing && options.validateCompleted) {
|
|
266
|
+
for (const id of ids) {
|
|
267
|
+
const record = state.steps[id];
|
|
268
|
+
if (record.status === 'succeeded' && !await options.validateCompleted(id, clone(record), clone(state)))
|
|
269
|
+
invalid.add(id);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let forkTarget;
|
|
273
|
+
if (invalid.size) {
|
|
274
|
+
let first = Math.min(...Array.from(invalid, id => ids.indexOf(id)));
|
|
275
|
+
// A stale later receipt must never jump over an unfinished correction.
|
|
276
|
+
// This also repairs checkpoints whose previous resume chose that receipt.
|
|
277
|
+
const unfinished = ids.findIndex((id, index) => index < first && ['blocked', 'failed'].includes(state.steps[id].status));
|
|
278
|
+
if (unfinished >= 0)
|
|
279
|
+
first = unfinished;
|
|
280
|
+
for (const id of ids.slice(first))
|
|
281
|
+
resetRecord(state, id);
|
|
282
|
+
forkTarget = ids[first];
|
|
283
|
+
state.nextStep = forkTarget;
|
|
284
|
+
state.nextAttempt = 1;
|
|
285
|
+
state.status = 'running';
|
|
286
|
+
delete state.error;
|
|
287
|
+
delete state.pendingApproval;
|
|
288
|
+
delete state.pendingQuestion;
|
|
289
|
+
await commit({ type: 'workflow_invalidated', stepId: forkTarget, message: 'Completed evidence was invalidated' });
|
|
290
|
+
}
|
|
291
|
+
// A pending interrupt is answered by the host or the run stays paused.
|
|
292
|
+
let resumeValue;
|
|
293
|
+
if (!forkTarget) {
|
|
294
|
+
if (state.pendingApproval) {
|
|
295
|
+
const granted = state.pendingApproval.grantedAt !== undefined || options.approve?.includes(state.pendingApproval.stepId) === true;
|
|
296
|
+
if (!granted) {
|
|
297
|
+
if (state.status !== 'paused')
|
|
298
|
+
await finish('paused', state.pendingApproval.reason);
|
|
299
|
+
return clone(state);
|
|
300
|
+
}
|
|
301
|
+
if (!state.pendingApproval.grantedAt)
|
|
302
|
+
state.pendingApproval.grantedAt = new Date().toISOString();
|
|
303
|
+
resumeValue = { approved: true };
|
|
304
|
+
}
|
|
305
|
+
else if (state.pendingQuestion) {
|
|
306
|
+
const answer = state.pendingQuestion.answer ?? options.answer;
|
|
307
|
+
if (answer === undefined) {
|
|
308
|
+
if (state.status !== 'paused')
|
|
309
|
+
await finish('paused', state.pendingQuestion.question);
|
|
310
|
+
return clone(state);
|
|
311
|
+
}
|
|
312
|
+
if (state.pendingQuestion.answer === undefined) {
|
|
313
|
+
state.pendingQuestion.answer = answer;
|
|
314
|
+
state.pendingQuestion.answeredAt = new Date().toISOString();
|
|
315
|
+
}
|
|
316
|
+
resumeValue = { answer };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (state.status === 'succeeded' && !forkTarget)
|
|
320
|
+
return clone(state);
|
|
321
|
+
if (options.budget)
|
|
322
|
+
state.budget = { ...state.budget, ...definedBudget(options.budget) };
|
|
323
|
+
if (existing) {
|
|
324
|
+
state.status = 'running';
|
|
325
|
+
delete state.error;
|
|
326
|
+
// Failed visits begin a fresh bounded attempt series when explicitly resumed.
|
|
327
|
+
if (state.nextStep && ['failed', 'blocked'].includes(state.steps[state.nextStep]?.status ?? ''))
|
|
328
|
+
state.nextAttempt = 1;
|
|
329
|
+
// maxTransitions bounds one invocation. An explicit resume is a human
|
|
330
|
+
// decision to continue, so it starts a fresh transition budget; visits and
|
|
331
|
+
// history keep the complete record.
|
|
332
|
+
state.transitions = 0;
|
|
333
|
+
await commit({ type: 'workflow_resumed' });
|
|
334
|
+
}
|
|
335
|
+
const remaining = state.budget.maxDurationMs === undefined ? undefined : state.budget.maxDurationMs - state.usage.durationMs;
|
|
336
|
+
if (remaining !== undefined) {
|
|
337
|
+
if (remaining <= 0) {
|
|
338
|
+
timeoutReached = true;
|
|
339
|
+
controller.abort(new Error('Workflow duration budget exhausted'));
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
timeout = setTimeout(() => { timeoutReached = true; controller.abort(new Error('Workflow duration budget exhausted')); }, remaining);
|
|
343
|
+
}
|
|
344
|
+
const maxTransitions = options.workflow.maxTransitions ?? 100;
|
|
345
|
+
const wrap = (id) => async (graphState) => {
|
|
346
|
+
const node = options.workflow.nodes[id];
|
|
347
|
+
const record = state.steps[id];
|
|
348
|
+
const exhausted = budgetError(state, performance.now() - elapsedMark);
|
|
349
|
+
if (controller.signal.aborted || exhausted) {
|
|
350
|
+
const status = exhausted ? 'blocked' : stopped();
|
|
351
|
+
await finish(status, exhausted ?? stoppedReason());
|
|
352
|
+
throw new StepTerminated(status);
|
|
353
|
+
}
|
|
354
|
+
if (state.nextAttempt === 1 && state.transitions >= maxTransitions) {
|
|
355
|
+
await finish('blocked', 'Workflow transition limit exhausted');
|
|
356
|
+
throw new StepTerminated('blocked');
|
|
357
|
+
}
|
|
358
|
+
// A lost LangGraph checkpoint re-runs a node whose receipt the ledger already
|
|
359
|
+
// committed: replay its recorded result instead of repeating the effect.
|
|
360
|
+
if (record.status === 'succeeded' && record.next !== undefined && record.next !== id && state.nextStep === record.next) {
|
|
361
|
+
return new Command({ update: (record.update ?? {}), goto: record.next ?? END });
|
|
362
|
+
}
|
|
363
|
+
if (state.nextAttempt === 1 && record.status !== 'paused') {
|
|
364
|
+
state.transitions++;
|
|
365
|
+
record.visits++;
|
|
366
|
+
}
|
|
367
|
+
// A granted interrupt stays in the ledger until this node succeeds, so a
|
|
368
|
+
// crash during the resumed attempt never asks the host twice.
|
|
369
|
+
const pending = resumeValue ? pendingRequest(state, id) : undefined;
|
|
370
|
+
let attempt = state.nextAttempt;
|
|
371
|
+
const maxAttempts = node.maxAttempts ?? 1;
|
|
372
|
+
for (;;) {
|
|
373
|
+
const attemptId = randomUUID();
|
|
374
|
+
const startedAt = new Date().toISOString();
|
|
375
|
+
record.status = 'running';
|
|
376
|
+
record.attempt = attempt;
|
|
377
|
+
record.attemptId = attemptId;
|
|
378
|
+
record.startedAt = startedAt;
|
|
379
|
+
delete record.error;
|
|
380
|
+
state.nextStep = id;
|
|
381
|
+
state.nextAttempt = attempt;
|
|
382
|
+
state.executionCount++;
|
|
383
|
+
const history = { id: attemptId, stepId: id, attempt, visit: record.visits, status: 'running', startedAt };
|
|
384
|
+
state.history.push(history);
|
|
385
|
+
await commit({ type: 'step_started', stepId: id, attemptId, spanId: attemptId });
|
|
386
|
+
let reported;
|
|
387
|
+
let raised;
|
|
388
|
+
const context = {
|
|
389
|
+
runId: state.runId, stepId: id, attemptId, attempt, input, signal: controller.signal, checkpoint: clone(state), ...(pending ? { pending } : {}),
|
|
390
|
+
interrupt: (request) => {
|
|
391
|
+
// The ledger is authoritative for a granted interrupt: a resumed node
|
|
392
|
+
// gets its answer without depending on LangGraph's replay order.
|
|
393
|
+
if (pending && resumeValue && pending.kind === request.kind)
|
|
394
|
+
return resumeValue;
|
|
395
|
+
raised = request;
|
|
396
|
+
return interrupt(request);
|
|
397
|
+
},
|
|
398
|
+
reportUsage: usage => { validateUsage(usage); accountUsage(state, usage); reported = addUsage(reported ?? { costUsd: 0, inputTokens: 0, outputTokens: 0 }, usage); },
|
|
399
|
+
reportEfficiencyActivity: async (kind, payload) => { await commit({ type: 'efficiency_updated', stepId: id, attemptId, efficiencyActivity: { kind, payload } }); },
|
|
400
|
+
reportInvocationStarted: async (invocation) => {
|
|
401
|
+
const ordinal = (history.invocations?.length ?? 0) + (history.pendingInvocations?.length ?? 0) + 1;
|
|
402
|
+
const identity = { invocationId: `${attemptId}:call:${ordinal}`, ordinal };
|
|
403
|
+
(history.pendingInvocations ??= []).push({ ...clone(invocation), ...identity });
|
|
404
|
+
await persist();
|
|
405
|
+
return identity;
|
|
406
|
+
},
|
|
407
|
+
reportInvocation: async (invocation) => {
|
|
408
|
+
validateUsage(invocation.usage);
|
|
409
|
+
if (!Number.isFinite(invocation.durationMs) || invocation.durationMs < 0 || !Number.isSafeInteger(invocation.toolCalls) || invocation.toolCalls < 0)
|
|
410
|
+
throw new TypeError('Invalid invocation measurement');
|
|
411
|
+
if (invocation.invocationId && history.invocations?.some(item => item.invocationId === invocation.invocationId))
|
|
412
|
+
return;
|
|
413
|
+
(history.invocations ??= []).push(clone(invocation));
|
|
414
|
+
if (invocation.invocationId)
|
|
415
|
+
history.pendingInvocations = history.pendingInvocations?.filter(item => item.invocationId !== invocation.invocationId);
|
|
416
|
+
if (reported)
|
|
417
|
+
history.usage = reported;
|
|
418
|
+
await commit({ type: 'efficiency_updated', stepId: id, attemptId, efficiency: clone(invocation) });
|
|
419
|
+
},
|
|
420
|
+
remainingBudget: () => ({
|
|
421
|
+
...(state.budget.maxDurationMs === undefined ? {} : { maxDurationMs: Math.max(0, state.budget.maxDurationMs - state.usage.durationMs - (performance.now() - elapsedMark)) }),
|
|
422
|
+
...(state.budget.maxTokens === undefined ? {} : { maxTokens: Math.max(0, state.budget.maxTokens - state.usage.knownTokens) }),
|
|
423
|
+
...(state.budget.maxCostUsd === undefined ? {} : { maxCostUsd: Math.max(0, state.budget.maxCostUsd - state.usage.knownCostUsd) }),
|
|
424
|
+
}),
|
|
425
|
+
};
|
|
426
|
+
let result;
|
|
427
|
+
try {
|
|
428
|
+
result = await node.run(graphState, context);
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (isGraphInterrupt(error)) {
|
|
432
|
+
const request = raised ?? error.interrupts[0]?.value;
|
|
433
|
+
const message = request?.kind === 'question' ? request.question : request?.kind === 'approval' ? request.reason : 'Workflow paused';
|
|
434
|
+
const now = new Date().toISOString();
|
|
435
|
+
record.status = 'paused';
|
|
436
|
+
record.completedAt = now;
|
|
437
|
+
history.status = 'paused';
|
|
438
|
+
history.completedAt = now;
|
|
439
|
+
if (reported)
|
|
440
|
+
history.usage = reported;
|
|
441
|
+
if (request?.kind === 'question') {
|
|
442
|
+
delete state.pendingApproval;
|
|
443
|
+
state.pendingQuestion = { stepId: id, requestedAt: now, question: request.question };
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
delete state.pendingQuestion;
|
|
447
|
+
state.pendingApproval = { stepId: id, requestedAt: now, ...(request?.kind === 'approval' ? { reason: request.reason } : {}) };
|
|
448
|
+
}
|
|
449
|
+
await commit({ type: 'step_paused', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}), message });
|
|
450
|
+
await span(history);
|
|
451
|
+
await finish('paused', message);
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
result = { status: 'failed', error: error instanceof Error ? error.message : String(error), retryable: false };
|
|
455
|
+
}
|
|
456
|
+
// Usage: explicit result usage adds to what the node reported; a node that
|
|
457
|
+
// reported nothing has unknown usage, never a fabricated zero.
|
|
458
|
+
try {
|
|
459
|
+
validateUsage(result.usage);
|
|
460
|
+
if (result.usage) {
|
|
461
|
+
accountUsage(state, result.usage);
|
|
462
|
+
reported = addUsage(reported ?? { costUsd: 0, inputTokens: 0, outputTokens: 0 }, result.usage);
|
|
463
|
+
}
|
|
464
|
+
else if (!reported)
|
|
465
|
+
accountUsage(state);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
result = { status: 'failed', error: error instanceof Error ? error.message : String(error), retryable: false };
|
|
469
|
+
}
|
|
470
|
+
const invalidJson = jsonOrError(result.output, 'step output') ?? jsonOrError(result.update, 'state update');
|
|
471
|
+
if (invalidJson)
|
|
472
|
+
result = { status: 'failed', error: invalidJson, retryable: false };
|
|
473
|
+
const now = new Date().toISOString();
|
|
474
|
+
history.completedAt = now;
|
|
475
|
+
if (reported)
|
|
476
|
+
history.usage = reported;
|
|
477
|
+
if (result.output !== undefined)
|
|
478
|
+
history.output = result.output;
|
|
479
|
+
if (result.error)
|
|
480
|
+
history.error = result.error;
|
|
481
|
+
record.completedAt = now;
|
|
482
|
+
if (controller.signal.aborted) {
|
|
483
|
+
if ((node.effect ?? 'read') === 'write') {
|
|
484
|
+
// A write that settled after cancellation is ambiguous evidence; keep it explicit.
|
|
485
|
+
record.status = 'interrupted';
|
|
486
|
+
record.error = result.status === 'succeeded' ? 'Write interrupted by cancellation' : result.error ?? 'Write interrupted by cancellation';
|
|
487
|
+
history.status = 'interrupted';
|
|
488
|
+
history.error = record.error;
|
|
489
|
+
await commit({ type: 'step_interrupted', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}), message: record.error });
|
|
490
|
+
await span(history);
|
|
491
|
+
await finish('blocked', `Interrupted write step ${id} requires explicit recovery`);
|
|
492
|
+
throw new StepTerminated('blocked');
|
|
493
|
+
}
|
|
494
|
+
history.status = result.status === 'succeeded' ? 'succeeded' : 'failed';
|
|
495
|
+
record.status = history.status;
|
|
496
|
+
if (result.error)
|
|
497
|
+
record.error = result.error;
|
|
498
|
+
await commit({ type: history.status === 'succeeded' ? 'step_succeeded' : 'step_failed', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}), ...(result.error ? { message: result.error } : {}) });
|
|
499
|
+
await span(history);
|
|
500
|
+
await finish(stopped(), stoppedReason());
|
|
501
|
+
throw new StepTerminated(stopped());
|
|
502
|
+
}
|
|
503
|
+
if (result.status === 'failed' && result.retryable && attempt < maxAttempts && ((node.effect ?? 'read') === 'read' || node.retrySafe)) {
|
|
504
|
+
history.status = 'failed';
|
|
505
|
+
record.status = 'failed';
|
|
506
|
+
record.error = result.error;
|
|
507
|
+
await commit({ type: 'step_failed', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}), message: result.error });
|
|
508
|
+
await span(history);
|
|
509
|
+
attempt += 1;
|
|
510
|
+
state.nextAttempt = attempt;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
history.status = result.status;
|
|
514
|
+
record.status = result.status;
|
|
515
|
+
if (result.output !== undefined)
|
|
516
|
+
record.output = result.output;
|
|
517
|
+
else
|
|
518
|
+
delete record.output;
|
|
519
|
+
if (result.error)
|
|
520
|
+
record.error = result.error;
|
|
521
|
+
if (result.status === 'succeeded') {
|
|
522
|
+
const next = result.next === undefined ? (node.ends[0] ?? null) : result.next;
|
|
523
|
+
if (next !== null && !node.ends.includes(next)) {
|
|
524
|
+
history.status = 'failed';
|
|
525
|
+
record.status = 'failed';
|
|
526
|
+
record.error = `Node ${id} routed to an undeclared successor ${next}`;
|
|
527
|
+
history.error = record.error;
|
|
528
|
+
await commit({ type: 'step_failed', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}), message: record.error });
|
|
529
|
+
await span(history);
|
|
530
|
+
await finish('failed', record.error);
|
|
531
|
+
throw new StepTerminated('failed');
|
|
532
|
+
}
|
|
533
|
+
record.next = next;
|
|
534
|
+
record.update = (result.update ?? {});
|
|
535
|
+
if (pending) {
|
|
536
|
+
delete state.pendingApproval;
|
|
537
|
+
delete state.pendingQuestion;
|
|
538
|
+
}
|
|
539
|
+
state.nextStep = next;
|
|
540
|
+
state.nextAttempt = 1;
|
|
541
|
+
await commit({ type: 'step_succeeded', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}) });
|
|
542
|
+
await span(history);
|
|
543
|
+
return new Command({ update: (result.update ?? {}), goto: next ?? END });
|
|
544
|
+
}
|
|
545
|
+
await commit({ type: result.status === 'blocked' ? 'step_blocked' : 'step_failed', stepId: id, attemptId, spanId: attemptId, ...(reported ? { usage: reported } : {}), message: result.error });
|
|
546
|
+
await span(history);
|
|
547
|
+
await finish(result.status, result.error);
|
|
548
|
+
throw new StepTerminated(result.status);
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
const saver = new FileCheckpointSaver({ load: () => graphStore, save: async (store) => { graphStore = store; await persist(); } });
|
|
552
|
+
// Node names are only known at run time, so the builder is typed with plain strings.
|
|
553
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the schema is host-defined; state typing lives in WorkflowNode<S>.
|
|
554
|
+
const builder = new StateGraph(options.workflow.schema);
|
|
555
|
+
for (const id of ids)
|
|
556
|
+
builder.addNode(id, wrap(id), { ends: [...options.workflow.nodes[id].ends, END] });
|
|
557
|
+
builder.addEdge(START, options.workflow.entry);
|
|
558
|
+
const graph = builder.compile({ checkpointer: saver });
|
|
559
|
+
const thread = { configurable: { thread_id: state.runId } };
|
|
560
|
+
let startConfig = thread;
|
|
561
|
+
let graphInput = existing ? null : {};
|
|
562
|
+
if (existing && !forkTarget && state.nextStep !== null) {
|
|
563
|
+
// The ledger names the next node. When LangGraph's own position disagrees
|
|
564
|
+
// (its checkpoint was written before the ledger receipt, or the receipt was
|
|
565
|
+
// reconciled by the host), traversal follows the ledger.
|
|
566
|
+
const snapshot = await graph.getState(thread);
|
|
567
|
+
if (!snapshot.next.includes(state.nextStep))
|
|
568
|
+
forkTarget = state.nextStep;
|
|
569
|
+
else if (resumeValue && snapshot.tasks.some(task => task.interrupts.length > 0))
|
|
570
|
+
graphInput = new Command({ resume: resumeValue });
|
|
571
|
+
}
|
|
572
|
+
if (forkTarget) {
|
|
573
|
+
// Time travel: continue from the checkpoint taken right before the target
|
|
574
|
+
// node last ran, so its predecessors' state is exactly what it saw then and
|
|
575
|
+
// every later checkpoint becomes a discarded branch.
|
|
576
|
+
let fork;
|
|
577
|
+
for await (const snapshot of graph.getStateHistory(thread)) {
|
|
578
|
+
if (snapshot.next.includes(forkTarget)) {
|
|
579
|
+
fork = snapshot.config;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (fork) {
|
|
584
|
+
startConfig = fork;
|
|
585
|
+
graphInput = null;
|
|
586
|
+
}
|
|
587
|
+
else if (forkTarget === options.workflow.entry) {
|
|
588
|
+
await saver.deleteThread(state.runId);
|
|
589
|
+
graphInput = {};
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
await finish('failed', `No checkpoint precedes step ${forkTarget}; start a new run`);
|
|
593
|
+
return clone(state);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (state.nextStep === null && existing && !forkTarget)
|
|
597
|
+
graphInput = null;
|
|
598
|
+
const maxAttempts = Math.max(...ids.map(id => options.workflow.nodes[id].maxAttempts ?? 1));
|
|
599
|
+
let output;
|
|
600
|
+
try {
|
|
601
|
+
// Persist each graph checkpoint before starting the next node, including crash recovery.
|
|
602
|
+
output = state.nextStep === null ? {} : await graph.invoke(graphInput, { ...startConfig, durability: 'sync', recursionLimit: maxTransitions * maxAttempts + 2 });
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (error instanceof StepTerminated || isGraphInterrupt(error))
|
|
606
|
+
return clone(state);
|
|
607
|
+
if (state.status === 'running')
|
|
608
|
+
await finish(controller.signal.aborted ? stopped() : 'failed', controller.signal.aborted ? stoppedReason() : error instanceof Error ? error.message : String(error));
|
|
609
|
+
if (controller.signal.aborted)
|
|
610
|
+
return clone(state);
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
if (isInterrupted(output))
|
|
614
|
+
return clone(state);
|
|
615
|
+
// A process may die after the final step receipt but before workflow_succeeded.
|
|
616
|
+
if (state.status === 'running') {
|
|
617
|
+
const exhausted = budgetError(state, performance.now() - elapsedMark, true);
|
|
618
|
+
if (exhausted)
|
|
619
|
+
await finish('blocked', exhausted);
|
|
620
|
+
else if (controller.signal.aborted)
|
|
621
|
+
await finish(stopped(), stoppedReason());
|
|
622
|
+
else if (state.nextStep === null)
|
|
623
|
+
await finish('succeeded');
|
|
624
|
+
else
|
|
625
|
+
await finish('failed', 'Workflow ended without a terminal step receipt');
|
|
626
|
+
}
|
|
627
|
+
return clone(state);
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
if (timeout)
|
|
631
|
+
clearTimeout(timeout);
|
|
632
|
+
options.signal?.removeEventListener('abort', abort);
|
|
633
|
+
await release();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
//# sourceMappingURL=workflow.js.map
|