@vercel/factory 0.0.15 → 0.0.16
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/CHANGELOG.md +356 -0
- package/README.md +49 -261
- package/dist/agent-routes.d.mts +47 -3
- package/dist/agent-routes.mjs +28 -1
- package/dist/agent-routes.mjs.map +1 -1
- package/dist/api-contracts.d.mts +20 -1
- package/dist/api-contracts.mjs +2 -1
- package/dist/api-contracts.mjs.map +1 -1
- package/dist/api.d.mts +45 -2
- package/dist/api.mjs +199 -10
- package/dist/api.mjs.map +1 -1
- package/dist/approval-contracts.d.mts +6 -0
- package/dist/blob/index.d.mts +51 -14
- package/dist/blob/index.mjs +26 -10
- package/dist/blob/index.mjs.map +1 -1
- package/dist/budget.d.mts +7 -0
- package/dist/budget.mjs +6 -0
- package/dist/budget.mjs.map +1 -1
- package/dist/build-factory.d.mts +1 -0
- package/dist/change-verification/dispatch.d.mts +16 -3
- package/dist/change-verification/dispatch.mjs +45 -7
- package/dist/change-verification/dispatch.mjs.map +1 -1
- package/dist/change-verification/eve-tool.d.mts +2 -1
- package/dist/change-verification/eve-tool.mjs +35 -47
- package/dist/change-verification/eve-tool.mjs.map +1 -1
- package/dist/change-verification/result.mjs +130 -0
- package/dist/change-verification/result.mjs.map +1 -0
- package/dist/changes/eve-record-change.d.mts +2 -2
- package/dist/changes/eve-record-change.mjs +43 -9
- package/dist/changes/eve-record-change.mjs.map +1 -1
- package/dist/changes.d.mts +4 -3
- package/dist/changes.mjs +2 -2
- package/dist/changes.mjs.map +1 -1
- package/dist/client-events.d.mts +10 -3
- package/dist/client-events.mjs +6 -2
- package/dist/client-events.mjs.map +1 -1
- package/dist/client-stream.mjs +8 -2
- package/dist/client-stream.mjs.map +1 -1
- package/dist/client-transcript.mjs +5 -1
- package/dist/client-transcript.mjs.map +1 -1
- package/dist/client.d.mts +121 -12
- package/dist/client.mjs +117 -9
- package/dist/client.mjs.map +1 -1
- package/dist/code-review/contracts.d.mts +1 -0
- package/dist/code-review/eve-post-review.d.mts +4 -4
- package/dist/code-review/eve-post-review.mjs +29 -17
- package/dist/code-review/eve-post-review.mjs.map +1 -1
- package/dist/code-review/eve-review-comments.d.mts +13 -2
- package/dist/code-review/eve-review-comments.mjs +45 -11
- package/dist/code-review/eve-review-comments.mjs.map +1 -1
- package/dist/code-review/github-reporter.d.mts +2 -0
- package/dist/code-review/github-reporter.mjs +7 -5
- package/dist/code-review/github-reporter.mjs.map +1 -1
- package/dist/code-review.d.mts +3 -2
- package/dist/deepsec/eve-tool.mjs +3 -1
- package/dist/deepsec/eve-tool.mjs.map +1 -1
- package/dist/dispatch.d.mts +79 -8
- package/dist/dispatch.mjs +68 -9
- package/dist/dispatch.mjs.map +1 -1
- package/dist/eve/index.d.mts +70 -10
- package/dist/eve/index.mjs +93 -22
- package/dist/eve/index.mjs.map +1 -1
- package/dist/eve/invoke.mjs +24 -11
- package/dist/eve/invoke.mjs.map +1 -1
- package/dist/eve/session-client.d.mts +122 -4
- package/dist/eve/session-client.mjs +127 -13
- package/dist/eve/session-client.mjs.map +1 -1
- package/dist/eve/task-execution.d.mts +380 -0
- package/dist/eve/task-execution.mjs +57 -2
- package/dist/eve/task-execution.mjs.map +1 -1
- package/dist/eve/task-session.d.mts +44 -2
- package/dist/eve/task-session.mjs +44 -2
- package/dist/eve/task-session.mjs.map +1 -1
- package/dist/eve/transcript.mjs +5 -1
- package/dist/eve/transcript.mjs.map +1 -1
- package/dist/execution.d.mts +117 -9
- package/dist/execution.mjs +76 -6
- package/dist/execution.mjs.map +1 -1
- package/dist/finding-remediation/admission.d.mts +2 -0
- package/dist/finding-remediation/admission.mjs +4 -1
- package/dist/finding-remediation/admission.mjs.map +1 -1
- package/dist/findings.d.mts +1 -0
- package/dist/github-publication.d.mts +1 -0
- package/dist/github-publication.mjs +97 -84
- package/dist/github-publication.mjs.map +1 -1
- package/dist/github-transfer.d.mts +15 -6
- package/dist/github-transfer.mjs +214 -66
- package/dist/github-transfer.mjs.map +1 -1
- package/dist/github.d.mts +51 -11
- package/dist/github.mjs +126 -24
- package/dist/github.mjs.map +1 -1
- package/dist/inbox-activity.d.mts +53 -0
- package/dist/inbox-activity.mjs +41 -0
- package/dist/inbox-activity.mjs.map +1 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -2
- package/dist/intake-contracts.d.mts +0 -1
- package/dist/integrations/deepsec.d.mts +1 -0
- package/dist/integrations/github.d.mts +2 -2
- package/dist/integrations/github.mjs +2 -2
- package/dist/integrations/slack.d.mts +3 -1
- package/dist/integrations/slack.mjs +3 -1
- package/dist/integrations/vercel.d.mts +4 -2
- package/dist/integrations/vercel.mjs +3 -2
- package/dist/merge-resolution/eve-tools.d.mts +1 -0
- package/dist/merge-resolution/eve-tools.mjs +7 -2
- package/dist/merge-resolution/eve-tools.mjs.map +1 -1
- package/dist/model-settings.d.mts +41 -0
- package/dist/model-settings.mjs +35 -0
- package/dist/model-settings.mjs.map +1 -0
- package/dist/planning/reconcile.mjs +6 -0
- package/dist/planning/reconcile.mjs.map +1 -1
- package/dist/postgres/index.d.mts +43 -2
- package/dist/postgres/index.mjs +40 -2
- package/dist/postgres/index.mjs.map +1 -1
- package/dist/presets/software-development/dispatch.d.mts +4 -1
- package/dist/presets/software-development/dispatch.mjs +2 -1
- package/dist/presets/software-development/dispatch.mjs.map +1 -1
- package/dist/presets/software-development/task-communication.d.mts +1 -0
- package/dist/presets/software-development/task-communication.mjs +48 -11
- package/dist/presets/software-development/task-communication.mjs.map +1 -1
- package/dist/presets/software-development.d.mts +1 -0
- package/dist/pull-requests/github-publisher.d.mts +15 -1
- package/dist/pull-requests/github-publisher.mjs +61 -1
- package/dist/pull-requests/github-publisher.mjs.map +1 -1
- package/dist/pull-requests.d.mts +1 -0
- package/dist/sandbox/index.d.mts +1 -0
- package/dist/schema/agent-route.d.mts +19 -1
- package/dist/schema/agent-route.mjs +19 -1
- package/dist/schema/agent-route.mjs.map +1 -1
- package/dist/schema/factory-config.d.mts +27 -0
- package/dist/schema/factory-config.mjs +33 -3
- package/dist/schema/factory-config.mjs.map +1 -1
- package/dist/schema/repository.d.mts +4 -0
- package/dist/schema/repository.mjs +5 -1
- package/dist/schema/repository.mjs.map +1 -1
- package/dist/schema/session.d.mts +1 -0
- package/dist/schema/session.mjs +1 -0
- package/dist/schema/session.mjs.map +1 -1
- package/dist/schema/slack-pr-notifications.d.mts +12 -0
- package/dist/schema/slack-pr-notifications.mjs +11 -0
- package/dist/schema/slack-pr-notifications.mjs.map +1 -0
- package/dist/schema/task-graph.d.mts +39 -0
- package/dist/schema/task.d.mts +1 -0
- package/dist/schema/task.mjs +2 -1
- package/dist/schema/task.mjs.map +1 -1
- package/dist/schema/transcript.d.mts +6 -0
- package/dist/schema/transcript.mjs +2 -1
- package/dist/schema/transcript.mjs.map +1 -1
- package/dist/schema/work.d.mts +52 -3
- package/dist/schema/work.mjs.map +1 -1
- package/dist/session-previews.d.mts +76 -0
- package/dist/session-previews.mjs +55 -0
- package/dist/session-previews.mjs.map +1 -0
- package/dist/session-review.d.mts +120 -0
- package/dist/session-review.mjs +79 -0
- package/dist/session-review.mjs.map +1 -0
- package/dist/signal-triage.mjs +1 -1
- package/dist/signals.d.mts +1 -0
- package/dist/stall.d.mts +4 -1
- package/dist/stall.mjs +6 -2
- package/dist/stall.mjs.map +1 -1
- package/dist/store/driver.d.mts +1 -1
- package/dist/store/driver.mjs.map +1 -1
- package/dist/store/engine.d.mts +206 -8
- package/dist/store/engine.mjs +147 -13
- package/dist/store/engine.mjs.map +1 -1
- package/dist/store/memory.d.mts +18 -1
- package/dist/store/memory.mjs +18 -1
- package/dist/store/memory.mjs.map +1 -1
- package/dist/store/slack-pr-notifications.d.mts +44 -0
- package/dist/store/slack-pr-notifications.mjs +121 -0
- package/dist/store/slack-pr-notifications.mjs.map +1 -0
- package/dist/store/task-work.d.mts +121 -6
- package/dist/store/task-work.mjs +7 -4
- package/dist/store/task-work.mjs.map +1 -1
- package/dist/sweep.d.mts +28 -6
- package/dist/sweep.mjs +34 -6
- package/dist/sweep.mjs.map +1 -1
- package/dist/task-graph-view.d.mts +3 -0
- package/dist/tasks.d.mts +3 -3
- package/dist/tasks.mjs +3 -3
- package/dist/vercel-git.d.mts +35 -3
- package/dist/vercel-git.mjs +265 -33
- package/dist/vercel-git.mjs.map +1 -1
- package/dist/vercel-github-api.d.mts +103 -0
- package/dist/vercel-github-api.mjs +363 -0
- package/dist/vercel-github-api.mjs.map +1 -0
- package/dist/vercel.d.mts +3 -2
- package/dist/vercel.mjs +3 -2
- package/dist/vercel.mjs.map +1 -1
- package/dist/work-triage.d.mts +1 -0
- package/dist/workflows.d.mts +102 -4
- package/dist/workflows.mjs +55 -2
- package/dist/workflows.mjs.map +1 -1
- package/dist/workspace-files-git.d.mts +15 -0
- package/dist/workspace-files-git.mjs +61 -0
- package/dist/workspace-files-git.mjs.map +1 -0
- package/dist/workspace-files.d.mts +107 -0
- package/dist/workspace-files.mjs +74 -0
- package/dist/workspace-files.mjs.map +1 -0
- package/docs/getting-started.md +104 -0
- package/docs/index.md +100 -0
- package/docs/recipes/cancellation.md +215 -0
- package/docs/recipes/custom-workflow.md +153 -0
- package/docs/recipes/dependent-tasks.md +207 -0
- package/docs/recipes/eve-agent.md +277 -0
- package/docs/recipes/human-input.md +204 -0
- package/docs/recipes/persistence-recovery.md +268 -0
- package/docs/recipes/retry-recovery.md +241 -0
- package/docs/recipes/task-messaging.md +215 -0
- package/docs/recipes/typed-eve-result.md +161 -0
- package/docs/runtime-integration.md +137 -0
- package/package.json +17 -6
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Persist and recover workflow work
|
|
2
|
+
|
|
3
|
+
Use the Postgres storage adapter when Task state must survive process restarts. This recipe uses
|
|
4
|
+
PGlite's filesystem-backed PostgreSQL runtime, so it exercises the public Postgres adapter and its
|
|
5
|
+
migrations without credentials or a database service.
|
|
6
|
+
|
|
7
|
+
The example deliberately runs in three fresh Node.js processes. The initialize phase creates and
|
|
8
|
+
starts typed workflow work, the recover phase reopens the database and completes that work, and the
|
|
9
|
+
verify phase reopens it again to prove that the result and idempotency evidence were persisted.
|
|
10
|
+
|
|
11
|
+
## Install and run
|
|
12
|
+
|
|
13
|
+
In an ESM project running Node.js 24 or later, install the runtime packages:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add @vercel/factory @electric-sql/pglite drizzle-orm eve zod
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Save the runnable block below as `persistence-recovery.mts`, then run each phase as a separate
|
|
20
|
+
process against the same workspace directory:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
node persistence-recovery.mts initialize ./factory-recovery-data
|
|
24
|
+
node persistence-recovery.mts recover ./factory-recovery-data
|
|
25
|
+
node persistence-recovery.mts verify ./factory-recovery-data
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Each command prints one JSON object and exits unsuccessfully if an expected persistence or replay
|
|
29
|
+
property is missing. Use an empty workspace directory for a new run.
|
|
30
|
+
|
|
31
|
+
<!-- runnable-example:start -->
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
37
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
38
|
+
import { z } from "zod";
|
|
39
|
+
import { createStores } from "@vercel/factory/storage";
|
|
40
|
+
import { applyFactoryMigrations, createPostgresDriver } from "@vercel/factory/storage/postgres";
|
|
41
|
+
import { taskIdSchema } from "@vercel/factory/tasks";
|
|
42
|
+
import { defineWorkflow, defineWorkflows, taskWork } from "@vercel/factory/workflows";
|
|
43
|
+
|
|
44
|
+
const phase = process.argv[2];
|
|
45
|
+
const workspaceArgument = process.argv[3];
|
|
46
|
+
if (
|
|
47
|
+
(phase !== "initialize" && phase !== "recover" && phase !== "verify") ||
|
|
48
|
+
workspaceArgument === undefined
|
|
49
|
+
) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Usage: persistence-recovery.mts <initialize|recover|verify> <workspace-directory>",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const workspace = path.resolve(workspaceArgument);
|
|
56
|
+
const databasePath = path.join(workspace, "postgres");
|
|
57
|
+
const taskIdPath = path.join(workspace, "root-task-id");
|
|
58
|
+
const greetingWorkflow = defineWorkflow({
|
|
59
|
+
id: "persistent-greeting",
|
|
60
|
+
version: 1,
|
|
61
|
+
input: z.strictObject({ name: z.string().min(1) }),
|
|
62
|
+
output: z.strictObject({ greeting: z.string().min(1) }),
|
|
63
|
+
});
|
|
64
|
+
const workflows = defineWorkflows([greetingWorkflow]);
|
|
65
|
+
|
|
66
|
+
async function openFactory() {
|
|
67
|
+
const client = new PGlite(databasePath);
|
|
68
|
+
const db = drizzle(client);
|
|
69
|
+
const appliedMigrations = await applyFactoryMigrations(db);
|
|
70
|
+
const stores = createStores({
|
|
71
|
+
driver: createPostgresDriver(db),
|
|
72
|
+
workflows,
|
|
73
|
+
});
|
|
74
|
+
return { appliedMigrations, client, stores };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readRootTaskId() {
|
|
78
|
+
return taskIdSchema.parse((await readFile(taskIdPath, "utf8")).trim());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function initialize() {
|
|
82
|
+
await mkdir(workspace, { recursive: true });
|
|
83
|
+
const { appliedMigrations, client, stores } = await openFactory();
|
|
84
|
+
try {
|
|
85
|
+
const repository = await stores.repositories.put({
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
id: "repo_persistrecipe",
|
|
88
|
+
slug: "example/project",
|
|
89
|
+
defaultBranch: "main",
|
|
90
|
+
enabled: true,
|
|
91
|
+
dependsOn: [],
|
|
92
|
+
connectedAt: new Date().toISOString(),
|
|
93
|
+
});
|
|
94
|
+
const task = await stores.tasks.create({
|
|
95
|
+
repositoryIds: [repository.id],
|
|
96
|
+
kind: "example",
|
|
97
|
+
origin: { operator: "local:recipe" },
|
|
98
|
+
replyTo: { channel: "local", address: "persistence-recovery" },
|
|
99
|
+
work: {
|
|
100
|
+
...taskWork({
|
|
101
|
+
title: "Create a persistent greeting",
|
|
102
|
+
input: { name: "Factory" },
|
|
103
|
+
workflow: greetingWorkflow,
|
|
104
|
+
}),
|
|
105
|
+
completionCriteria: ["Return a greeting for the persisted name"],
|
|
106
|
+
},
|
|
107
|
+
dedupeKey: "persistence-recovery-root",
|
|
108
|
+
});
|
|
109
|
+
const running = await stores.tasks.transition(task.id, "running");
|
|
110
|
+
await writeFile(taskIdPath, `${running.id}\n`, { encoding: "utf8", flag: "wx" });
|
|
111
|
+
if (running.rootTaskId !== running.id || running.attempt !== 1) {
|
|
112
|
+
throw new Error("Expected a first-attempt root Task");
|
|
113
|
+
}
|
|
114
|
+
console.log(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
phase: "initialize",
|
|
117
|
+
taskId: running.id,
|
|
118
|
+
attempt: running.attempt,
|
|
119
|
+
workflow: running.work.workflow,
|
|
120
|
+
state: running.state,
|
|
121
|
+
appliedMigrations,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
} finally {
|
|
125
|
+
await client.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function recoverAndComplete() {
|
|
130
|
+
const rootTaskId = await readRootTaskId();
|
|
131
|
+
const { appliedMigrations, client, stores } = await openFactory();
|
|
132
|
+
try {
|
|
133
|
+
const recovered = await stores.work.recover(rootTaskId);
|
|
134
|
+
const task = recovered.find((candidate) => candidate.id === rootTaskId);
|
|
135
|
+
if (task === undefined || recovered.length !== 1 || task.state !== "running") {
|
|
136
|
+
throw new Error("Expected to recover one running root Task");
|
|
137
|
+
}
|
|
138
|
+
const input = greetingWorkflow.input.parse(task.work.input);
|
|
139
|
+
const completed = await stores.work.completeWorkflow({
|
|
140
|
+
task: { taskId: task.id, attempt: task.attempt },
|
|
141
|
+
workflow: greetingWorkflow.binding,
|
|
142
|
+
output: { greeting: `Hello, ${input.name}!` },
|
|
143
|
+
});
|
|
144
|
+
const output = greetingWorkflow.output.parse(completed.workResult?.output);
|
|
145
|
+
if (completed.state !== "succeeded" || output.greeting !== "Hello, Factory!") {
|
|
146
|
+
throw new Error("Expected recovered work to complete with its validated output");
|
|
147
|
+
}
|
|
148
|
+
console.log(
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
phase: "recover",
|
|
151
|
+
taskId: completed.id,
|
|
152
|
+
attempt: completed.attempt,
|
|
153
|
+
workflow: completed.work.workflow,
|
|
154
|
+
recoveredState: task.state,
|
|
155
|
+
state: completed.state,
|
|
156
|
+
output,
|
|
157
|
+
appliedMigrations,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
} finally {
|
|
161
|
+
await client.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function verify() {
|
|
166
|
+
const rootTaskId = await readRootTaskId();
|
|
167
|
+
const { appliedMigrations, client, stores } = await openFactory();
|
|
168
|
+
try {
|
|
169
|
+
const recovered = await stores.work.recover(rootTaskId);
|
|
170
|
+
const task = recovered.find((candidate) => candidate.id === rootTaskId);
|
|
171
|
+
if (task === undefined || task.state !== "succeeded") {
|
|
172
|
+
throw new Error("Expected the completed Task to survive another restart");
|
|
173
|
+
}
|
|
174
|
+
const output = greetingWorkflow.output.parse(task.workResult?.output);
|
|
175
|
+
const succeededReceiptsBefore = (await stores.receipts.list()).filter(
|
|
176
|
+
(receipt) => receipt.taskId === task.id && receipt.state === "succeeded",
|
|
177
|
+
).length;
|
|
178
|
+
const replayed = await stores.work.completeWorkflow({
|
|
179
|
+
task: { taskId: task.id, attempt: task.attempt },
|
|
180
|
+
workflow: greetingWorkflow.binding,
|
|
181
|
+
output,
|
|
182
|
+
});
|
|
183
|
+
const succeededReceiptsAfterReplay = (await stores.receipts.list()).filter(
|
|
184
|
+
(receipt) => receipt.taskId === task.id && receipt.state === "succeeded",
|
|
185
|
+
).length;
|
|
186
|
+
|
|
187
|
+
let conflictingOutputRejected = false;
|
|
188
|
+
try {
|
|
189
|
+
await stores.work.completeWorkflow({
|
|
190
|
+
task: { taskId: task.id, attempt: task.attempt },
|
|
191
|
+
workflow: greetingWorkflow.binding,
|
|
192
|
+
output: { greeting: "Goodbye, Factory!" },
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
conflictingOutputRejected = true;
|
|
196
|
+
}
|
|
197
|
+
const succeededReceiptCount = (await stores.receipts.list()).filter(
|
|
198
|
+
(receipt) => receipt.taskId === task.id && receipt.state === "succeeded",
|
|
199
|
+
).length;
|
|
200
|
+
if (
|
|
201
|
+
replayed.state !== "succeeded" ||
|
|
202
|
+
replayed.workResult?.completedAt !== task.workResult?.completedAt ||
|
|
203
|
+
succeededReceiptsBefore !== 1 ||
|
|
204
|
+
succeededReceiptsAfterReplay !== 1 ||
|
|
205
|
+
succeededReceiptCount !== 1 ||
|
|
206
|
+
!conflictingOutputRejected
|
|
207
|
+
) {
|
|
208
|
+
throw new Error("Expected idempotent replay and rejection of conflicting output");
|
|
209
|
+
}
|
|
210
|
+
console.log(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
phase: "verify",
|
|
213
|
+
taskId: replayed.id,
|
|
214
|
+
attempt: replayed.attempt,
|
|
215
|
+
workflow: replayed.work.workflow,
|
|
216
|
+
state: replayed.state,
|
|
217
|
+
replayed: true,
|
|
218
|
+
conflictingOutputRejected,
|
|
219
|
+
succeededReceiptCount,
|
|
220
|
+
appliedMigrations,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
} finally {
|
|
224
|
+
await client.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (phase === "initialize") await initialize();
|
|
229
|
+
if (phase === "recover") await recoverAndComplete();
|
|
230
|
+
if (phase === "verify") await verify();
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
<!-- runnable-example:end -->
|
|
234
|
+
|
|
235
|
+
Expected states are `running`, then `succeeded`, then `succeeded`. The verify phase also reports
|
|
236
|
+
`replayed: true`, `conflictingOutputRejected: true`, and `succeededReceiptCount: 1`.
|
|
237
|
+
|
|
238
|
+
## What recovery means
|
|
239
|
+
|
|
240
|
+
This example demonstrates three separate responsibilities:
|
|
241
|
+
|
|
242
|
+
- Reopening storage constructs a new PGlite connection, Postgres driver, store engine, and workflow
|
|
243
|
+
registry over the same database directory. No JavaScript process state is reused.
|
|
244
|
+
- `stores.work.recover(rootTaskId)` reads the canonical graph by its root ID, validates every Task
|
|
245
|
+
against its exact registered workflow, and repairs a missing canonical Task snapshot from its
|
|
246
|
+
recorded graph admission when necessary. It does not resume an agent, restart provider work, or
|
|
247
|
+
replay arbitrary external effects.
|
|
248
|
+
- Execution recovery belongs to workflow code. For provider-owned execution, inspect the persisted
|
|
249
|
+
execution reference, reattach only when the provider confirms it is active, and apply the
|
|
250
|
+
workflow's retry policy otherwise. This recipe uses deterministic local, workflow-owned work, so
|
|
251
|
+
it can complete directly from the persisted input and attempt. See
|
|
252
|
+
[Retry a failed Eve attempt](./retry-recovery.md) and
|
|
253
|
+
[Cancel an active Task](./cancellation.md) for those lifecycle paths.
|
|
254
|
+
|
|
255
|
+
Register every historical workflow ID and version that stored Tasks can reference whenever a
|
|
256
|
+
process creates its stores. A missing exact definition causes reads and recovery to fail rather
|
|
257
|
+
than silently selecting a newer contract.
|
|
258
|
+
|
|
259
|
+
`applyFactoryMigrations` updates the Factory SQL tables and migration ledger. It does not rewrite
|
|
260
|
+
old Task JSON when a persisted application contract changes. Such a release needs an explicit data
|
|
261
|
+
migration or reset decision before deployment.
|
|
262
|
+
|
|
263
|
+
PGlite owns the `postgres` subdirectory, while `root-task-id` is the durable pointer used by each
|
|
264
|
+
fresh process. Remove the workspace after the example when its data is no longer needed:
|
|
265
|
+
|
|
266
|
+
```sh
|
|
267
|
+
rm -rf ./factory-recovery-data
|
|
268
|
+
```
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Retry a failed Eve attempt
|
|
2
|
+
|
|
3
|
+
`createFactoryHooks` turns an Eve `turn.failed` or `session.failed` event into durable recovery
|
|
4
|
+
policy. It requeues the exact route-bound Task while attempts remain, ignores late events from a
|
|
5
|
+
replaced attempt, and fails the Task when the configured attempt limit is exhausted. A scheduled
|
|
6
|
+
`sweepQueued` pass starts each replacement.
|
|
7
|
+
|
|
8
|
+
## Run the recipe
|
|
9
|
+
|
|
10
|
+
Save this as `retry-recovery.mts` in an ESM project with `@vercel/factory`, `eve`, and `zod`
|
|
11
|
+
installed. Injected session responses make the example credential-free.
|
|
12
|
+
|
|
13
|
+
<!-- runnable-example:start -->
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { ToolContext } from "eve/tools";
|
|
17
|
+
import {
|
|
18
|
+
createFactoryHooks,
|
|
19
|
+
createTaskTools,
|
|
20
|
+
factoryTaskAttemptAttribute,
|
|
21
|
+
factoryTaskAttribute,
|
|
22
|
+
startEveSession,
|
|
23
|
+
taskHeaders,
|
|
24
|
+
} from "@vercel/factory";
|
|
25
|
+
import {
|
|
26
|
+
ExecutionLaunchPendingError,
|
|
27
|
+
sweepQueued,
|
|
28
|
+
type TaskLauncher,
|
|
29
|
+
} from "@vercel/factory/execution";
|
|
30
|
+
import { createInMemoryDriver, createStores } from "@vercel/factory/storage";
|
|
31
|
+
import type { Task } from "@vercel/factory/tasks";
|
|
32
|
+
import { parseAgentRouteBinding, routeKey, taskWork } from "@vercel/factory/workflows";
|
|
33
|
+
|
|
34
|
+
const stores = createStores({ driver: createInMemoryDriver() });
|
|
35
|
+
const repository = await stores.repositories.put({
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
id: "repo_recipe04",
|
|
38
|
+
slug: "example/project",
|
|
39
|
+
defaultBranch: "main",
|
|
40
|
+
enabled: true,
|
|
41
|
+
dependsOn: [],
|
|
42
|
+
connectedAt: new Date().toISOString(),
|
|
43
|
+
});
|
|
44
|
+
const route = parseAgentRouteBinding({ id: "worker", version: 1 });
|
|
45
|
+
const sessions: string[] = [];
|
|
46
|
+
const launchers = {
|
|
47
|
+
[routeKey(route)]: (claimed: Task) =>
|
|
48
|
+
startEveSession({
|
|
49
|
+
agentUrl: "https://factory.example/eve/agents/worker",
|
|
50
|
+
message: `Work Task ${claimed.id}`,
|
|
51
|
+
operationId: `factory-task:${claimed.id}:${claimed.attempt}`,
|
|
52
|
+
headers: taskHeaders({ taskId: claimed.id, attempt: claimed.attempt }),
|
|
53
|
+
fetch: async () => {
|
|
54
|
+
const sessionId = `ses_${claimed.id}_${claimed.attempt}`;
|
|
55
|
+
sessions.push(sessionId);
|
|
56
|
+
return Response.json({ sessionId });
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const createTask = (title: string) =>
|
|
62
|
+
stores.tasks.create({
|
|
63
|
+
repositoryIds: [repository.id],
|
|
64
|
+
kind: "example",
|
|
65
|
+
origin: { operator: "local:recipe" },
|
|
66
|
+
replyTo: { channel: "local", address: "retry-recovery" },
|
|
67
|
+
work: { ...taskWork({ title }), route },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function sessionContext(task: Task) {
|
|
71
|
+
if (task.execution?.provider !== "eve") throw new Error("Expected an Eve execution");
|
|
72
|
+
return {
|
|
73
|
+
session: {
|
|
74
|
+
id: task.execution.sessionId,
|
|
75
|
+
auth: {
|
|
76
|
+
initiator: {
|
|
77
|
+
attributes: {
|
|
78
|
+
[factoryTaskAttribute]: task.id,
|
|
79
|
+
[factoryTaskAttemptAttribute]: String(task.attempt),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const events = createFactoryHooks(stores, {
|
|
88
|
+
maxAttempts: 2,
|
|
89
|
+
presentQuestion: async () => {},
|
|
90
|
+
}).factory.events;
|
|
91
|
+
const turnFailed = events?.["turn.failed"];
|
|
92
|
+
const sessionFailed = events?.["session.failed"];
|
|
93
|
+
if (turnFailed === undefined || sessionFailed === undefined) {
|
|
94
|
+
throw new Error("Expected Eve failure hook handlers");
|
|
95
|
+
}
|
|
96
|
+
const turnFailure = {
|
|
97
|
+
type: "turn.failed",
|
|
98
|
+
data: {
|
|
99
|
+
code: "MODEL_CALL_FAILED",
|
|
100
|
+
message: "Provider unavailable",
|
|
101
|
+
sequence: 1,
|
|
102
|
+
turnId: "turn_1",
|
|
103
|
+
},
|
|
104
|
+
} as Parameters<typeof turnFailed>[0];
|
|
105
|
+
const sessionFailure = {
|
|
106
|
+
type: "session.failed",
|
|
107
|
+
data: {
|
|
108
|
+
code: "SESSION_FAILED",
|
|
109
|
+
message: "Session stopped",
|
|
110
|
+
sessionId: "session_1",
|
|
111
|
+
},
|
|
112
|
+
} as Parameters<typeof sessionFailed>[0];
|
|
113
|
+
|
|
114
|
+
const recoveredTask = await createTask("Recover after one failure");
|
|
115
|
+
await sweepQueued(stores, { launchers });
|
|
116
|
+
const firstAttempt = await stores.tasks.get(recoveredTask.id);
|
|
117
|
+
if (firstAttempt?.state !== "running") throw new Error("First attempt did not launch");
|
|
118
|
+
|
|
119
|
+
await turnFailed(
|
|
120
|
+
turnFailure,
|
|
121
|
+
sessionContext(firstAttempt) as unknown as Parameters<typeof turnFailed>[1],
|
|
122
|
+
);
|
|
123
|
+
const queuedReplacement = await stores.tasks.get(recoveredTask.id);
|
|
124
|
+
if (queuedReplacement?.state !== "queued" || queuedReplacement.attempt !== 2) {
|
|
125
|
+
throw new Error("The failed first attempt was not requeued");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await sweepQueued(stores, { launchers });
|
|
129
|
+
const replacement = await stores.tasks.get(recoveredTask.id);
|
|
130
|
+
if (replacement?.state !== "running" || replacement.attempt !== 2) {
|
|
131
|
+
throw new Error("The replacement attempt did not launch");
|
|
132
|
+
}
|
|
133
|
+
await turnFailed(
|
|
134
|
+
turnFailure,
|
|
135
|
+
sessionContext(firstAttempt) as unknown as Parameters<typeof turnFailed>[1],
|
|
136
|
+
);
|
|
137
|
+
if ((await stores.tasks.get(recoveredTask.id))?.state !== "running") {
|
|
138
|
+
throw new Error("A stale failure changed the replacement attempt");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const finish = await createTaskTools(stores).finish_task.execute(
|
|
142
|
+
{ taskId: replacement.id, summary: "Recovered on the replacement attempt" },
|
|
143
|
+
sessionContext(replacement) as unknown as ToolContext,
|
|
144
|
+
);
|
|
145
|
+
if (!finish.finished) throw new Error(finish.reason);
|
|
146
|
+
|
|
147
|
+
const exhaustedTask = await createTask("Exhaust the retry policy");
|
|
148
|
+
await sweepQueued(stores, { launchers });
|
|
149
|
+
const exhaustedFirst = await stores.tasks.get(exhaustedTask.id);
|
|
150
|
+
if (exhaustedFirst?.state !== "running") throw new Error("Exhaustion attempt did not launch");
|
|
151
|
+
await turnFailed(
|
|
152
|
+
turnFailure,
|
|
153
|
+
sessionContext(exhaustedFirst) as unknown as Parameters<typeof turnFailed>[1],
|
|
154
|
+
);
|
|
155
|
+
await sweepQueued(stores, { launchers });
|
|
156
|
+
const exhaustedSecond = await stores.tasks.get(exhaustedTask.id);
|
|
157
|
+
if (exhaustedSecond?.state !== "running" || exhaustedSecond.attempt !== 2) {
|
|
158
|
+
throw new Error("Final allowed attempt did not launch");
|
|
159
|
+
}
|
|
160
|
+
await sessionFailed(
|
|
161
|
+
sessionFailure,
|
|
162
|
+
sessionContext(exhaustedSecond) as unknown as Parameters<typeof sessionFailed>[1],
|
|
163
|
+
);
|
|
164
|
+
const failed = await stores.tasks.get(exhaustedTask.id);
|
|
165
|
+
const finalSweep = await sweepQueued(stores, { launchers });
|
|
166
|
+
if (
|
|
167
|
+
failed?.state !== "failed" ||
|
|
168
|
+
failed.attempt !== 2 ||
|
|
169
|
+
finalSweep.dispatched.includes(exhaustedTask.id) ||
|
|
170
|
+
sessions.length !== 4
|
|
171
|
+
) {
|
|
172
|
+
throw new Error("The attempt limit did not produce terminal failure");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ambiguousTask = await createTask("Reconnect an ambiguously accepted launch");
|
|
176
|
+
const operationIds: string[] = [];
|
|
177
|
+
let launchCalls = 0;
|
|
178
|
+
const reconnectingLauncher: TaskLauncher = async (claimed) => {
|
|
179
|
+
const operationId = `factory-task:${claimed.id}:${claimed.attempt}`;
|
|
180
|
+
operationIds.push(operationId);
|
|
181
|
+
launchCalls += 1;
|
|
182
|
+
if (launchCalls === 1) {
|
|
183
|
+
throw new ExecutionLaunchPendingError("Eve accepted the request but its response was lost");
|
|
184
|
+
}
|
|
185
|
+
return startEveSession({
|
|
186
|
+
agentUrl: "https://factory.example/eve/agents/worker",
|
|
187
|
+
message: `Work Task ${claimed.id}`,
|
|
188
|
+
operationId,
|
|
189
|
+
headers: taskHeaders({ taskId: claimed.id, attempt: claimed.attempt }),
|
|
190
|
+
fetch: async () => Response.json({ sessionId: "ses_ambiguous_reconnected" }),
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
const ambiguousSweep = await sweepQueued(stores, {
|
|
194
|
+
launchers: { [routeKey(route)]: reconnectingLauncher },
|
|
195
|
+
tasks: [ambiguousTask],
|
|
196
|
+
});
|
|
197
|
+
const pendingLaunch = await stores.tasks.get(ambiguousTask.id);
|
|
198
|
+
if (
|
|
199
|
+
ambiguousSweep.failed[0]?.reasonCode !== "launch_failed" ||
|
|
200
|
+
pendingLaunch?.state !== "running" ||
|
|
201
|
+
pendingLaunch.execution !== undefined
|
|
202
|
+
) {
|
|
203
|
+
throw new Error("Ambiguous acceptance did not preserve the running attempt for recovery");
|
|
204
|
+
}
|
|
205
|
+
const recoveredExecution = await reconnectingLauncher(pendingLaunch, repository);
|
|
206
|
+
await stores.tasks.recordExecution(pendingLaunch.id, {
|
|
207
|
+
execution: recoveredExecution,
|
|
208
|
+
expectAttempt: pendingLaunch.attempt,
|
|
209
|
+
});
|
|
210
|
+
const reconnected = await stores.tasks.get(pendingLaunch.id);
|
|
211
|
+
if (
|
|
212
|
+
reconnected?.execution?.provider !== "eve" ||
|
|
213
|
+
reconnected.execution.sessionId !== "ses_ambiguous_reconnected" ||
|
|
214
|
+
operationIds.length !== 2 ||
|
|
215
|
+
operationIds[0] !== operationIds[1]
|
|
216
|
+
) {
|
|
217
|
+
throw new Error("The recovery launch did not reconnect with the stable operation ID");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(
|
|
221
|
+
JSON.stringify({
|
|
222
|
+
recovered: (await stores.tasks.get(recoveredTask.id))?.state,
|
|
223
|
+
recoveredAttempt: replacement.attempt,
|
|
224
|
+
exhausted: failed.state,
|
|
225
|
+
exhaustedAttempt: failed.attempt,
|
|
226
|
+
reconnectedSession: reconnected.execution.sessionId,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
<!-- runnable-example:end -->
|
|
232
|
+
|
|
233
|
+
The first failure queues attempt 2 and clears the old execution. The late attempt-1 event is
|
|
234
|
+
ignored, and only the replacement session can call `finish_task`. The other Task demonstrates the
|
|
235
|
+
terminal path at `maxAttempts`; another sweep has nothing to relaunch.
|
|
236
|
+
|
|
237
|
+
Run `sweepQueued` from a durable schedule or queue wakeup, including after failures, approvals,
|
|
238
|
+
answers, and dependency completion. An ambiguous launch deliberately remains `running` without an
|
|
239
|
+
execution reference, so an ordinary queued sweep will not pick it up. A recovery worker must find
|
|
240
|
+
that state, invoke the exact stored route again with the same Task-and-attempt `operationId`, and
|
|
241
|
+
record the reconnected execution with `expectAttempt`, as the final part of the example does.
|