@plico-ai/runtime 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +11 -2
- package/dist/artifacts.d.ts +10 -0
- package/dist/artifacts.js +106 -0
- package/dist/engine/context.d.ts +16 -0
- package/dist/engine/context.js +77 -0
- package/dist/engine/engine.d.ts +12 -0
- package/dist/engine/engine.js +235 -0
- package/dist/engine/errors.d.ts +12 -0
- package/dist/engine/errors.js +19 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.js +5 -0
- package/dist/engine/journal.d.ts +65 -0
- package/dist/engine/journal.js +1 -0
- package/dist/engine/memory-journal.d.ts +2 -0
- package/dist/engine/memory-journal.js +125 -0
- package/dist/engine/sqlite-engine.d.ts +7 -0
- package/dist/engine/sqlite-engine.js +6 -0
- package/dist/engine/sqlite-journal.d.ts +5 -0
- package/dist/engine/sqlite-journal.js +136 -0
- package/dist/engine/types.d.ts +97 -0
- package/dist/engine/types.js +1 -0
- package/dist/env.d.ts +7 -0
- package/dist/env.js +19 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +40 -0
- package/dist/evals/discover.d.ts +3 -0
- package/dist/evals/discover.js +49 -0
- package/dist/evals/index.d.ts +3 -0
- package/dist/evals/index.js +3 -0
- package/dist/evals/promote.d.ts +7 -0
- package/dist/evals/promote.js +32 -0
- package/dist/evals/run-evals.d.ts +2 -0
- package/dist/evals/run-evals.js +69 -0
- package/dist/events.d.ts +28 -0
- package/dist/events.js +104 -0
- package/dist/guards.d.ts +1 -0
- package/dist/guards.js +3 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/instructions.d.ts +4 -0
- package/dist/instructions.js +23 -0
- package/dist/policy/approval.d.ts +8 -0
- package/dist/policy/approval.js +27 -0
- package/dist/providers/ai-sdk.d.ts +10 -0
- package/dist/providers/ai-sdk.js +154 -0
- package/dist/providers/configured.d.ts +17 -0
- package/dist/providers/configured.js +60 -0
- package/dist/providers/openai-compatible.d.ts +8 -0
- package/dist/providers/openai-compatible.js +14 -0
- package/dist/providers/project-module.d.ts +7 -0
- package/dist/providers/project-module.js +29 -0
- package/dist/providers/registry.d.ts +20 -0
- package/dist/providers/registry.js +53 -0
- package/dist/readiness/check-readiness.d.ts +2 -0
- package/dist/readiness/check-readiness.js +33 -0
- package/dist/readiness/checks/approval-policy.d.ts +5 -0
- package/dist/readiness/checks/approval-policy.js +19 -0
- package/dist/readiness/checks/artifact-access.d.ts +3 -0
- package/dist/readiness/checks/artifact-access.js +5 -0
- package/dist/readiness/checks/artifacts.d.ts +6 -0
- package/dist/readiness/checks/artifacts.js +22 -0
- package/dist/readiness/checks/env.d.ts +3 -0
- package/dist/readiness/checks/env.js +10 -0
- package/dist/readiness/checks/evals.d.ts +2 -0
- package/dist/readiness/checks/evals.js +53 -0
- package/dist/readiness/checks/persistence.d.ts +3 -0
- package/dist/readiness/checks/persistence.js +61 -0
- package/dist/readiness/checks/project.d.ts +7 -0
- package/dist/readiness/checks/project.js +21 -0
- package/dist/readiness/checks/provider.d.ts +2 -0
- package/dist/readiness/checks/provider.js +20 -0
- package/dist/readiness/checks/server-config.d.ts +2 -0
- package/dist/readiness/checks/server-config.js +13 -0
- package/dist/readiness/checks/studio.d.ts +2 -0
- package/dist/readiness/checks/studio.js +16 -0
- package/dist/readiness/checks/thread-store.d.ts +3 -0
- package/dist/readiness/checks/thread-store.js +12 -0
- package/dist/readiness/checks/tools.d.ts +3 -0
- package/dist/readiness/checks/tools.js +42 -0
- package/dist/readiness/diagnostic.d.ts +2 -0
- package/dist/readiness/diagnostic.js +7 -0
- package/dist/readiness/index.d.ts +1 -0
- package/dist/readiness/index.js +1 -0
- package/dist/readiness/open-database.d.ts +8 -0
- package/dist/readiness/open-database.js +15 -0
- package/dist/run/artifact-tool-context.d.ts +19 -0
- package/dist/run/artifact-tool-context.js +74 -0
- package/dist/run/finalize.d.ts +16 -0
- package/dist/run/finalize.js +11 -0
- package/dist/run/index.d.ts +1 -0
- package/dist/run/index.js +1 -0
- package/dist/run/pinned-instructions.d.ts +18 -0
- package/dist/run/pinned-instructions.js +36 -0
- package/dist/run/record-tools.d.ts +8 -0
- package/dist/run/record-tools.js +21 -0
- package/dist/run/run-project.d.ts +17 -0
- package/dist/run/run-project.js +222 -0
- package/dist/run/strategies/approval.d.ts +9 -0
- package/dist/run/strategies/approval.js +42 -0
- package/dist/run/strategies/default-dry.d.ts +3 -0
- package/dist/run/strategies/default-dry.js +5 -0
- package/dist/run/strategies/provider.d.ts +6 -0
- package/dist/run/strategies/provider.js +193 -0
- package/dist/run/strategies/scripted.d.ts +5 -0
- package/dist/run/strategies/scripted.js +106 -0
- package/dist/run/tool-fingerprint.d.ts +2 -0
- package/dist/run/tool-fingerprint.js +26 -0
- package/dist/run/tool-step.d.ts +4 -0
- package/dist/run/tool-step.js +62 -0
- package/dist/run/tracing.d.ts +12 -0
- package/dist/run/tracing.js +92 -0
- package/dist/script.d.ts +4 -0
- package/dist/script.js +51 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/metadata.d.ts +5 -0
- package/dist/storage/metadata.js +26 -0
- package/dist/storage/sqlite/append-event.d.ts +3 -0
- package/dist/storage/sqlite/append-event.js +108 -0
- package/dist/storage/sqlite/driver.d.ts +9 -0
- package/dist/storage/sqlite/driver.js +13 -0
- package/dist/storage/sqlite/index.d.ts +1 -0
- package/dist/storage/sqlite/index.js +1 -0
- package/dist/storage/sqlite/open.d.ts +3 -0
- package/dist/storage/sqlite/open.js +54 -0
- package/dist/storage/sqlite/owner-lock.d.ts +5 -0
- package/dist/storage/sqlite/owner-lock.js +55 -0
- package/dist/storage/sqlite/project-thread.d.ts +4 -0
- package/dist/storage/sqlite/project-thread.js +33 -0
- package/dist/storage/sqlite/queries.d.ts +9 -0
- package/dist/storage/sqlite/queries.js +134 -0
- package/dist/storage/sqlite/recover-interrupted.d.ts +2 -0
- package/dist/storage/sqlite/recover-interrupted.js +46 -0
- package/dist/storage/sqlite/schema.d.ts +10 -0
- package/dist/storage/sqlite/schema.js +205 -0
- package/dist/storage/sqlite/store.d.ts +2 -0
- package/dist/storage/sqlite/store.js +51 -0
- package/dist/storage/sqlite/thread-log.d.ts +4 -0
- package/dist/storage/sqlite/thread-log.js +9 -0
- package/dist/storage/sqlite/thread-title.d.ts +24 -0
- package/dist/storage/sqlite/thread-title.js +85 -0
- package/dist/storage/sqlite/threads.d.ts +47 -0
- package/dist/storage/sqlite/threads.js +156 -0
- package/dist/threads/stopwords.d.ts +1 -0
- package/dist/threads/stopwords.js +16 -0
- package/dist/threads/suggest-title.d.ts +9 -0
- package/dist/threads/suggest-title.js +102 -0
- package/dist/threads/titler.d.ts +10 -0
- package/dist/threads/titler.js +63 -0
- package/dist/threads/words.d.ts +12 -0
- package/dist/threads/words.js +39 -0
- package/dist/tools/artifact-model-tool-operations.d.ts +29 -0
- package/dist/tools/artifact-model-tool-operations.js +124 -0
- package/dist/tools/artifact-model-tools.d.ts +2 -0
- package/dist/tools/artifact-model-tools.js +101 -0
- package/dist/tools/discover.d.ts +4 -0
- package/dist/tools/discover.js +34 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/load.d.ts +2 -0
- package/dist/tools/load.js +83 -0
- package/dist/tools/normalize.d.ts +2 -0
- package/dist/tools/normalize.js +48 -0
- package/dist/tools/schema.d.ts +7 -0
- package/dist/tools/schema.js +101 -0
- package/dist/tools/skill-model-tool.d.ts +7 -0
- package/dist/tools/skill-model-tool.js +38 -0
- package/dist/types/artifacts.d.ts +5 -0
- package/dist/types/artifacts.js +1 -0
- package/dist/types/evals.d.ts +16 -0
- package/dist/types/evals.js +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.js +1 -0
- package/dist/types/provider.d.ts +45 -0
- package/dist/types/provider.js +1 -0
- package/dist/types/readiness.d.ts +9 -0
- package/dist/types/readiness.js +1 -0
- package/dist/types/run.d.ts +28 -0
- package/dist/types/run.js +1 -0
- package/dist/types/script.d.ts +16 -0
- package/dist/types/script.js +1 -0
- package/dist/types/store.d.ts +24 -0
- package/dist/types/store.js +1 -0
- package/dist/types/tools.d.ts +15 -0
- package/dist/types/tools.js +1 -0
- package/package.json +77 -3
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createContext, toJson } from "./context.js";
|
|
3
|
+
import { RunCancelled, RunSuspended } from "./errors.js";
|
|
4
|
+
export const defaultLeaseMs = 30_000;
|
|
5
|
+
export function createEngine({ journal, workerId = randomUUID(), leaseMs = defaultLeaseMs, now = Date.now, }) {
|
|
6
|
+
const lease = () => ({ workerId, until: now() + leaseMs });
|
|
7
|
+
/** Runs this process can continue by itself when a signal or timeout arrives. */
|
|
8
|
+
const registered = new Map();
|
|
9
|
+
/** Runs executing in this process right now. */
|
|
10
|
+
const active = new Set();
|
|
11
|
+
const timers = new Map();
|
|
12
|
+
/** Aborts the step a run is executing here when it is cancelled. */
|
|
13
|
+
const controllers = new Map();
|
|
14
|
+
function stopTimer(runId) {
|
|
15
|
+
clearTimeout(timers.get(runId));
|
|
16
|
+
timers.delete(runId);
|
|
17
|
+
}
|
|
18
|
+
/** Runs the workflow, replaying it each time a wait ends, until it completes or must wait. */
|
|
19
|
+
async function execute(runId, workflow, input) {
|
|
20
|
+
registered.set(runId, { workflow, input });
|
|
21
|
+
stopTimer(runId);
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
controllers.set(runId, controller);
|
|
24
|
+
const heartbeat = setInterval(() => {
|
|
25
|
+
journal.renew(runId, lease()).catch(() => undefined);
|
|
26
|
+
// Cancel may have been requested from another process.
|
|
27
|
+
journal
|
|
28
|
+
.getRun(runId)
|
|
29
|
+
.then((run) => run?.cancelRequested && controller.abort())
|
|
30
|
+
.catch(() => undefined);
|
|
31
|
+
}, leaseMs / 3);
|
|
32
|
+
heartbeat.unref();
|
|
33
|
+
try {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const ctx = createContext(runId, journal, now, controller.signal);
|
|
36
|
+
let output;
|
|
37
|
+
let returned = false;
|
|
38
|
+
try {
|
|
39
|
+
output = await workflow(ctx, input);
|
|
40
|
+
returned = true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!(error instanceof RunSuspended || error instanceof RunCancelled))
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (ctx.cancelled()) {
|
|
47
|
+
await journal.setState(runId, "cancelled");
|
|
48
|
+
registered.delete(runId);
|
|
49
|
+
return returned ? { state: "cancelled", output: output } : { state: "cancelled" };
|
|
50
|
+
}
|
|
51
|
+
const suspension = ctx.suspension();
|
|
52
|
+
if (!suspension) {
|
|
53
|
+
await journal.setState(runId, "completed");
|
|
54
|
+
registered.delete(runId);
|
|
55
|
+
return { state: "completed", output: output };
|
|
56
|
+
}
|
|
57
|
+
await journal.setState(runId, "waiting");
|
|
58
|
+
// A signal or timeout that landed while the run was stopping must not be lost.
|
|
59
|
+
const wait = await journal.getWait(runId, suspension.name);
|
|
60
|
+
const ready = (await journal.getRun(runId))?.cancelRequested === true ||
|
|
61
|
+
(await journal.getSignal(runId, suspension.name)) !== undefined ||
|
|
62
|
+
(wait?.deadline != null && now() >= wait.deadline);
|
|
63
|
+
if (ready) {
|
|
64
|
+
if (await journal.claimWaiting(runId, lease()))
|
|
65
|
+
continue;
|
|
66
|
+
// Another worker took the run; report what this execution produced before it stopped.
|
|
67
|
+
return returned ? { state: "waiting", output: output } : { state: "waiting" };
|
|
68
|
+
}
|
|
69
|
+
if (suspension.deadline !== null) {
|
|
70
|
+
const delay = Math.max(0, suspension.deadline - now());
|
|
71
|
+
const timer = setTimeout(() => resumeHere(runId).catch(() => undefined), delay);
|
|
72
|
+
timer.unref();
|
|
73
|
+
timers.set(runId, timer);
|
|
74
|
+
}
|
|
75
|
+
return returned ? { state: "waiting", output: output } : { state: "waiting" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await journal.setState(runId, "failed");
|
|
80
|
+
registered.delete(runId);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
clearInterval(heartbeat);
|
|
85
|
+
controllers.delete(runId);
|
|
86
|
+
active.delete(runId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Continues a waiting run this process started, once its wait can end. */
|
|
90
|
+
async function resumeHere(runId) {
|
|
91
|
+
const known = registered.get(runId);
|
|
92
|
+
if (!known || active.has(runId))
|
|
93
|
+
return;
|
|
94
|
+
active.add(runId);
|
|
95
|
+
try {
|
|
96
|
+
const run = await journal.getRun(runId);
|
|
97
|
+
if (run?.state !== "waiting") {
|
|
98
|
+
active.delete(runId);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!(await journal.claimWaiting(runId, lease()))) {
|
|
102
|
+
active.delete(runId);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
active.delete(runId);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
// Everything the run did is in the event log; its outcome here has no reader.
|
|
111
|
+
execute(runId, known.workflow, known.input).catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
async function start(runId, workflow, input) {
|
|
114
|
+
const inputJson = toJson(input);
|
|
115
|
+
if (!(await journal.begin(runId, inputJson, lease()))) {
|
|
116
|
+
throw new Error(`Run ${runId} already exists.`);
|
|
117
|
+
}
|
|
118
|
+
active.add(runId);
|
|
119
|
+
const result = execute(runId, workflow, JSON.parse(inputJson));
|
|
120
|
+
// The handle's reader observes the failure; this keeps an unread handle from crashing the process.
|
|
121
|
+
result.catch(() => undefined);
|
|
122
|
+
return { runId, result: () => result };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
start,
|
|
126
|
+
async run(runId, workflow, input) {
|
|
127
|
+
return (await start(runId, workflow, input)).result();
|
|
128
|
+
},
|
|
129
|
+
async resume(runId, workflow) {
|
|
130
|
+
const run = await journal.getRun(runId);
|
|
131
|
+
if (!run)
|
|
132
|
+
throw new Error(`Run ${runId} is unknown.`);
|
|
133
|
+
if (run.state === "completed" || run.state === "cancelled")
|
|
134
|
+
throw new Error(`Run ${runId} already finished.`);
|
|
135
|
+
if (run.state === "running") {
|
|
136
|
+
const stale = (await journal.listStale(workerId, now())).find((item) => item.runId === runId);
|
|
137
|
+
if (!stale || !(await journal.claim(runId, lease(), stale.held)))
|
|
138
|
+
throw new Error(`Run ${runId} is held by a live worker or its lease changed.`);
|
|
139
|
+
}
|
|
140
|
+
else if (!(await (run.state === "waiting"
|
|
141
|
+
? journal.claimWaiting(runId, lease())
|
|
142
|
+
: journal.claim(runId, lease())))) {
|
|
143
|
+
throw new Error(`Run ${runId} could not be claimed.`);
|
|
144
|
+
}
|
|
145
|
+
active.add(runId);
|
|
146
|
+
return execute(runId, workflow, JSON.parse(run.inputJson));
|
|
147
|
+
},
|
|
148
|
+
async signal(runId, name, value) {
|
|
149
|
+
const run = await journal.getRun(runId);
|
|
150
|
+
if (!run)
|
|
151
|
+
return "unknown";
|
|
152
|
+
if (["completed", "failed", "cancelled"].includes(run.state))
|
|
153
|
+
return "ignored";
|
|
154
|
+
const wait = await journal.getWait(runId, name);
|
|
155
|
+
if (wait?.outcomeJson != null)
|
|
156
|
+
return "ignored";
|
|
157
|
+
if (wait?.deadline != null && now() >= wait.deadline) {
|
|
158
|
+
// The deadline passed first: the wait ends as a timeout and this decision is late.
|
|
159
|
+
await journal.resolveWait(runId, name, JSON.stringify({ kind: "timeout" }));
|
|
160
|
+
resumeHere(runId).catch(() => undefined);
|
|
161
|
+
return "ignored";
|
|
162
|
+
}
|
|
163
|
+
if (!(await journal.putSignal(runId, name, toJson(value))))
|
|
164
|
+
return "ignored";
|
|
165
|
+
resumeHere(runId).catch(() => undefined);
|
|
166
|
+
return "accepted";
|
|
167
|
+
},
|
|
168
|
+
async cancel(runId) {
|
|
169
|
+
const run = await journal.getRun(runId);
|
|
170
|
+
if (!run)
|
|
171
|
+
return "unknown";
|
|
172
|
+
if (!(await journal.requestCancel(runId)))
|
|
173
|
+
return "ignored";
|
|
174
|
+
const running = controllers.get(runId);
|
|
175
|
+
if (running)
|
|
176
|
+
running.abort();
|
|
177
|
+
else
|
|
178
|
+
resumeHere(runId).catch(() => undefined);
|
|
179
|
+
return "accepted";
|
|
180
|
+
},
|
|
181
|
+
async recover(resolve) {
|
|
182
|
+
for (const { runId, deadline } of await journal.listWaiting()) {
|
|
183
|
+
if (active.has(runId) || registered.has(runId))
|
|
184
|
+
continue;
|
|
185
|
+
const run = await journal.getRun(runId);
|
|
186
|
+
if (!run || run.state !== "waiting")
|
|
187
|
+
continue;
|
|
188
|
+
const input = JSON.parse(run.inputJson);
|
|
189
|
+
const workflow = resolve(runId, input);
|
|
190
|
+
if (!workflow)
|
|
191
|
+
continue;
|
|
192
|
+
registered.set(runId, { workflow, input });
|
|
193
|
+
if (deadline !== null) {
|
|
194
|
+
const timer = setTimeout(() => resumeHere(runId).catch(() => undefined), Math.max(0, deadline - now()));
|
|
195
|
+
timer.unref();
|
|
196
|
+
timers.set(runId, timer);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const candidates = [
|
|
200
|
+
...(await journal.listStale(workerId, now())).map((item) => ({ ...item, waiting: false })),
|
|
201
|
+
...(await journal.listReady(now())).map((item) => ({ ...item, waiting: true })),
|
|
202
|
+
];
|
|
203
|
+
const recovered = [];
|
|
204
|
+
for (const { runId, held, waiting } of candidates) {
|
|
205
|
+
if (active.has(runId))
|
|
206
|
+
continue;
|
|
207
|
+
const run = await journal.getRun(runId);
|
|
208
|
+
if (!run)
|
|
209
|
+
continue;
|
|
210
|
+
const input = JSON.parse(run.inputJson);
|
|
211
|
+
const workflow = resolve(runId, input);
|
|
212
|
+
if (!workflow ||
|
|
213
|
+
!(await (waiting
|
|
214
|
+
? journal.claimWaiting(runId, lease(), held)
|
|
215
|
+
: journal.claim(runId, lease(), held))))
|
|
216
|
+
continue;
|
|
217
|
+
active.add(runId);
|
|
218
|
+
recovered.push(execute(runId, workflow, input).then((outcome) => ({
|
|
219
|
+
runId,
|
|
220
|
+
state: outcome.state,
|
|
221
|
+
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
222
|
+
}), (error) => ({
|
|
223
|
+
runId,
|
|
224
|
+
state: "failed",
|
|
225
|
+
error: error instanceof Error ? error.message : String(error),
|
|
226
|
+
})));
|
|
227
|
+
}
|
|
228
|
+
return Promise.all(recovered);
|
|
229
|
+
},
|
|
230
|
+
async status(runId) {
|
|
231
|
+
const run = await journal.getRun(runId);
|
|
232
|
+
return run && { state: run.state };
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class ProjectChangedError extends Error {
|
|
2
|
+
constructor(what: "step" | "event", index: number, recorded: string, current: string);
|
|
3
|
+
}
|
|
4
|
+
/** Thrown by `ctx.wait` when the run must stop and wait. Not a failure. */
|
|
5
|
+
export declare class RunSuspended extends Error {
|
|
6
|
+
readonly waitName: string;
|
|
7
|
+
constructor(waitName: string);
|
|
8
|
+
}
|
|
9
|
+
/** Thrown by `ctx.step` and `ctx.wait` once the run is cancelled. Not a failure. */
|
|
10
|
+
export declare class RunCancelled extends Error {
|
|
11
|
+
constructor();
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class ProjectChangedError extends Error {
|
|
2
|
+
constructor(what, index, recorded, current) {
|
|
3
|
+
super(`Project changed during run: ${what} ${index + 1} was "${recorded}", now "${current}".`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
/** Thrown by `ctx.wait` when the run must stop and wait. Not a failure. */
|
|
7
|
+
export class RunSuspended extends Error {
|
|
8
|
+
waitName;
|
|
9
|
+
constructor(waitName) {
|
|
10
|
+
super(`Run is waiting for ${waitName}.`);
|
|
11
|
+
this.waitName = waitName;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Thrown by `ctx.step` and `ctx.wait` once the run is cancelled. Not a failure. */
|
|
15
|
+
export class RunCancelled extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super("Run was cancelled.");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createEngine, defaultLeaseMs, type EngineOptions } from "./engine.js";
|
|
2
|
+
export { ProjectChangedError, RunCancelled, RunSuspended } from "./errors.js";
|
|
3
|
+
export type { Journal, JournalRun, JournalStep, JournalWait, Lease } from "./journal.js";
|
|
4
|
+
export { createMemoryJournal } from "./memory-journal.js";
|
|
5
|
+
export { createSqliteEngine, type SqliteEngineOptions } from "./sqlite-engine.js";
|
|
6
|
+
export { createSqliteJournal } from "./sqlite-journal.js";
|
|
7
|
+
export type { CancelResult, Engine, ExecutionState, ExecutionStatus, RecoveredRun, RunContext, RunHandle, RunOutcome, SignalResult, StepAttempt, StepOptions, WaitOptions, WaitOutcome, Workflow, } from "./types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createEngine, defaultLeaseMs } from "./engine.js";
|
|
2
|
+
export { ProjectChangedError, RunCancelled, RunSuspended } from "./errors.js";
|
|
3
|
+
export { createMemoryJournal } from "./memory-journal.js";
|
|
4
|
+
export { createSqliteEngine } from "./sqlite-engine.js";
|
|
5
|
+
export { createSqliteJournal } from "./sqlite-journal.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExecutionState } from "./types.js";
|
|
2
|
+
export interface JournalRun {
|
|
3
|
+
runId: string;
|
|
4
|
+
state: ExecutionState;
|
|
5
|
+
inputJson: string;
|
|
6
|
+
cancelRequested: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface JournalStep {
|
|
9
|
+
name: string;
|
|
10
|
+
resultJson: string;
|
|
11
|
+
}
|
|
12
|
+
/** A worker's hold on a running run. `until` is epoch milliseconds. */
|
|
13
|
+
export interface Lease {
|
|
14
|
+
workerId: string;
|
|
15
|
+
until: number;
|
|
16
|
+
}
|
|
17
|
+
export interface JournalWait {
|
|
18
|
+
/** Epoch milliseconds, or null for a wait with no timeout. */
|
|
19
|
+
deadline: number | null;
|
|
20
|
+
/** Set once the wait ended; null while it is open. */
|
|
21
|
+
outcomeJson: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** The engine's private record of where each run resumes. It never says what happened; events do. */
|
|
24
|
+
export interface Journal {
|
|
25
|
+
/** Creates the run, held by `lease`. Returns false when the id is already taken. */
|
|
26
|
+
begin(runId: string, inputJson: string, lease: Lease): Promise<boolean>;
|
|
27
|
+
getRun(runId: string): Promise<JournalRun | undefined>;
|
|
28
|
+
setState(runId: string, state: ExecutionState): Promise<void>;
|
|
29
|
+
/** Extends the lease if `lease.workerId` still holds the run. */
|
|
30
|
+
renew(runId: string, lease: Lease): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Makes `lease.workerId` the holder and sets the run running. With `expected`, it only does so
|
|
33
|
+
* when that holder still has the run, so two recoverers cannot both win.
|
|
34
|
+
*/
|
|
35
|
+
claim(runId: string, lease: Lease, expected?: Lease): Promise<boolean>;
|
|
36
|
+
/** Atomically claims a waiting run; only one contender can transition it to running. */
|
|
37
|
+
claimWaiting(runId: string, lease: Lease, expected?: Lease): Promise<boolean>;
|
|
38
|
+
/** Running runs another worker held and lost: its lease ran out or its process is gone. */
|
|
39
|
+
listStale(workerId: string, now: number): Promise<Array<{
|
|
40
|
+
runId: string;
|
|
41
|
+
held: Lease;
|
|
42
|
+
}>>;
|
|
43
|
+
/** Waiting runs that can move on now: a wait has a signal or passed its deadline, or cancel was requested. */
|
|
44
|
+
listReady(now: number): Promise<Array<{
|
|
45
|
+
runId: string;
|
|
46
|
+
held: Lease;
|
|
47
|
+
}>>;
|
|
48
|
+
listWaiting(): Promise<Array<{
|
|
49
|
+
runId: string;
|
|
50
|
+
deadline: number | null;
|
|
51
|
+
}>>;
|
|
52
|
+
/** Marks the run cancelled-on-next-chance. Returns false when it already finished or was marked. */
|
|
53
|
+
requestCancel(runId: string): Promise<boolean>;
|
|
54
|
+
getWait(runId: string, name: string): Promise<JournalWait | undefined>;
|
|
55
|
+
/** Opens the wait with its deadline. A second open changes nothing, so a replay keeps the deadline. */
|
|
56
|
+
openWait(runId: string, name: string, deadline: number | null): Promise<void>;
|
|
57
|
+
/** Ends the wait. Returns false when it had already ended. */
|
|
58
|
+
resolveWait(runId: string, name: string, outcomeJson: string): Promise<boolean>;
|
|
59
|
+
/** Stores a signal. Returns false when one is already stored for the name. */
|
|
60
|
+
putSignal(runId: string, name: string, valueJson: string): Promise<boolean>;
|
|
61
|
+
getSignal(runId: string, name: string): Promise<string | undefined>;
|
|
62
|
+
getStep(runId: string, index: number): Promise<JournalStep | undefined>;
|
|
63
|
+
/** Records a step once. A second write at the same index changes nothing. */
|
|
64
|
+
putStep(runId: string, index: number, step: JournalStep): Promise<void>;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export function createMemoryJournal() {
|
|
2
|
+
const runs = new Map();
|
|
3
|
+
const steps = new Map();
|
|
4
|
+
const waits = new Map();
|
|
5
|
+
const signals = new Map();
|
|
6
|
+
return {
|
|
7
|
+
async begin(runId, inputJson, lease) {
|
|
8
|
+
if (runs.has(runId))
|
|
9
|
+
return false;
|
|
10
|
+
runs.set(runId, { runId, state: "running", inputJson, cancelRequested: false, lease });
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
async getRun(runId) {
|
|
14
|
+
const run = runs.get(runId);
|
|
15
|
+
return (run && {
|
|
16
|
+
runId,
|
|
17
|
+
state: run.state,
|
|
18
|
+
inputJson: run.inputJson,
|
|
19
|
+
cancelRequested: run.cancelRequested,
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
async setState(runId, state) {
|
|
23
|
+
const run = runs.get(runId);
|
|
24
|
+
if (run)
|
|
25
|
+
run.state = state;
|
|
26
|
+
},
|
|
27
|
+
async renew(runId, lease) {
|
|
28
|
+
const run = runs.get(runId);
|
|
29
|
+
if (run?.lease.workerId === lease.workerId)
|
|
30
|
+
run.lease = lease;
|
|
31
|
+
},
|
|
32
|
+
async claim(runId, lease, expected) {
|
|
33
|
+
const run = runs.get(runId);
|
|
34
|
+
if (!run)
|
|
35
|
+
return false;
|
|
36
|
+
if (expected &&
|
|
37
|
+
(run.lease.workerId !== expected.workerId || run.lease.until !== expected.until)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
run.lease = lease;
|
|
41
|
+
run.state = "running";
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
async claimWaiting(runId, lease, expected) {
|
|
45
|
+
const run = runs.get(runId);
|
|
46
|
+
if (!run || run.state !== "waiting")
|
|
47
|
+
return false;
|
|
48
|
+
if (expected &&
|
|
49
|
+
(run.lease.workerId !== expected.workerId || run.lease.until !== expected.until))
|
|
50
|
+
return false;
|
|
51
|
+
run.lease = lease;
|
|
52
|
+
run.state = "running";
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
async listStale(workerId, now) {
|
|
56
|
+
return [...runs.values()]
|
|
57
|
+
.filter((run) => run.state === "running" && run.lease.workerId !== workerId && run.lease.until < now)
|
|
58
|
+
.map((run) => ({ runId: run.runId, held: run.lease }));
|
|
59
|
+
},
|
|
60
|
+
async listReady(now) {
|
|
61
|
+
return [...runs.values()]
|
|
62
|
+
.filter((run) => {
|
|
63
|
+
if (run.state !== "waiting")
|
|
64
|
+
return false;
|
|
65
|
+
if (run.cancelRequested)
|
|
66
|
+
return true;
|
|
67
|
+
return [...waits.entries()].some(([key, wait]) => {
|
|
68
|
+
if (!key.startsWith(`${run.runId}\u0000`) || wait.outcomeJson !== null)
|
|
69
|
+
return false;
|
|
70
|
+
return signals.has(key) || (wait.deadline !== null && wait.deadline <= now);
|
|
71
|
+
});
|
|
72
|
+
})
|
|
73
|
+
.map((run) => ({ runId: run.runId, held: run.lease }));
|
|
74
|
+
},
|
|
75
|
+
async requestCancel(runId) {
|
|
76
|
+
const run = runs.get(runId);
|
|
77
|
+
if (!run || run.cancelRequested || ["completed", "failed", "cancelled"].includes(run.state)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
run.cancelRequested = true;
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
async listWaiting() {
|
|
84
|
+
return [...runs.values()]
|
|
85
|
+
.filter((run) => run.state === "waiting")
|
|
86
|
+
.flatMap((run) => [...waits.entries()]
|
|
87
|
+
.filter(([key, wait]) => key.startsWith(`${run.runId}\0`) && wait.outcomeJson === null)
|
|
88
|
+
.map(([, wait]) => ({ runId: run.runId, deadline: wait.deadline })));
|
|
89
|
+
},
|
|
90
|
+
async getWait(runId, name) {
|
|
91
|
+
const wait = waits.get(`${runId}\u0000${name}`);
|
|
92
|
+
return wait && { ...wait };
|
|
93
|
+
},
|
|
94
|
+
async openWait(runId, name, deadline) {
|
|
95
|
+
const key = `${runId}\u0000${name}`;
|
|
96
|
+
if (!waits.has(key))
|
|
97
|
+
waits.set(key, { deadline, outcomeJson: null });
|
|
98
|
+
},
|
|
99
|
+
async resolveWait(runId, name, outcomeJson) {
|
|
100
|
+
const wait = waits.get(`${runId}\u0000${name}`);
|
|
101
|
+
if (!wait || wait.outcomeJson !== null)
|
|
102
|
+
return false;
|
|
103
|
+
wait.outcomeJson = outcomeJson;
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
async putSignal(runId, name, valueJson) {
|
|
107
|
+
const key = `${runId}\u0000${name}`;
|
|
108
|
+
if (signals.has(key))
|
|
109
|
+
return false;
|
|
110
|
+
signals.set(key, valueJson);
|
|
111
|
+
return true;
|
|
112
|
+
},
|
|
113
|
+
async getSignal(runId, name) {
|
|
114
|
+
return signals.get(`${runId}\u0000${name}`);
|
|
115
|
+
},
|
|
116
|
+
async getStep(runId, index) {
|
|
117
|
+
return steps.get(`${runId}:${index}`);
|
|
118
|
+
},
|
|
119
|
+
async putStep(runId, index, step) {
|
|
120
|
+
const key = `${runId}:${index}`;
|
|
121
|
+
if (!steps.has(key))
|
|
122
|
+
steps.set(key, step);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type EngineOptions } from "./engine.js";
|
|
2
|
+
import type { Engine } from "./types.js";
|
|
3
|
+
export interface SqliteEngineOptions extends Omit<EngineOptions, "journal"> {
|
|
4
|
+
databasePath: string;
|
|
5
|
+
}
|
|
6
|
+
/** The local engine: runs survive a restart because the journal lives in the project's SQLite file. */
|
|
7
|
+
export declare function createSqliteEngine({ databasePath, ...options }: SqliteEngineOptions): Engine;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createEngine } from "./engine.js";
|
|
2
|
+
import { createSqliteJournal } from "./sqlite-journal.js";
|
|
3
|
+
/** The local engine: runs survive a restart because the journal lives in the project's SQLite file. */
|
|
4
|
+
export function createSqliteEngine({ databasePath, ...options }) {
|
|
5
|
+
return createEngine({ ...options, journal: createSqliteJournal({ databasePath }) });
|
|
6
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { openDatabase } from "../storage/sqlite/open.js";
|
|
2
|
+
import { acquireOwner, ownerIsAlive } from "../storage/sqlite/owner-lock.js";
|
|
3
|
+
function leaseOf(row) {
|
|
4
|
+
return { workerId: row.worker_id, until: row.lease_until };
|
|
5
|
+
}
|
|
6
|
+
/** Keeps the journal in the project's SQLite file, in tables private to the engine. */
|
|
7
|
+
export function createSqliteJournal({ databasePath }) {
|
|
8
|
+
const database = openDatabase(databasePath);
|
|
9
|
+
function readRun(runId) {
|
|
10
|
+
return database.prepare("SELECT * FROM engine_runs WHERE run_id = ?").get(runId);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
async begin(runId, inputJson, lease) {
|
|
14
|
+
const owner = acquireOwner();
|
|
15
|
+
const result = database
|
|
16
|
+
.prepare("INSERT OR IGNORE INTO engine_runs (run_id, state, input_json, worker_id, lease_until, owner_token, owner_path) VALUES (?, 'running', ?, ?, ?, ?, ?)")
|
|
17
|
+
.run(runId, inputJson, lease.workerId, lease.until, owner.token, owner.path);
|
|
18
|
+
return result.changes === 1;
|
|
19
|
+
},
|
|
20
|
+
async getRun(runId) {
|
|
21
|
+
const row = readRun(runId);
|
|
22
|
+
return (row && {
|
|
23
|
+
runId,
|
|
24
|
+
state: row.state,
|
|
25
|
+
inputJson: row.input_json,
|
|
26
|
+
cancelRequested: row.cancel_requested === 1,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
async setState(runId, state) {
|
|
30
|
+
database.prepare("UPDATE engine_runs SET state = ? WHERE run_id = ?").run(state, runId);
|
|
31
|
+
},
|
|
32
|
+
async renew(runId, lease) {
|
|
33
|
+
database
|
|
34
|
+
.prepare("UPDATE engine_runs SET lease_until = ? WHERE run_id = ? AND worker_id = ?")
|
|
35
|
+
.run(lease.until, runId, lease.workerId);
|
|
36
|
+
},
|
|
37
|
+
async claim(runId, lease, expected) {
|
|
38
|
+
const owner = acquireOwner();
|
|
39
|
+
const result = expected
|
|
40
|
+
? database
|
|
41
|
+
.prepare("UPDATE engine_runs SET state = 'running', worker_id = ?, lease_until = ?, owner_token = ?, owner_path = ? WHERE run_id = ? AND worker_id = ? AND lease_until = ?")
|
|
42
|
+
.run(lease.workerId, lease.until, owner.token, owner.path, runId, expected.workerId, expected.until)
|
|
43
|
+
: database
|
|
44
|
+
.prepare("UPDATE engine_runs SET state = 'running', worker_id = ?, lease_until = ?, owner_token = ?, owner_path = ? WHERE run_id = ?")
|
|
45
|
+
.run(lease.workerId, lease.until, owner.token, owner.path, runId);
|
|
46
|
+
return result.changes === 1;
|
|
47
|
+
},
|
|
48
|
+
async claimWaiting(runId, lease, expected) {
|
|
49
|
+
const owner = acquireOwner();
|
|
50
|
+
const sql = `UPDATE engine_runs SET state = 'running', worker_id = ?, lease_until = ?, owner_token = ?, owner_path = ? WHERE run_id = ? AND state = 'waiting'`;
|
|
51
|
+
const result = expected
|
|
52
|
+
? database
|
|
53
|
+
.prepare(`${sql} AND worker_id = ? AND lease_until = ?`)
|
|
54
|
+
.run(lease.workerId, lease.until, owner.token, owner.path, runId, expected.workerId, expected.until)
|
|
55
|
+
: database.prepare(sql).run(lease.workerId, lease.until, owner.token, owner.path, runId);
|
|
56
|
+
return result.changes === 1;
|
|
57
|
+
},
|
|
58
|
+
async listStale(workerId, now) {
|
|
59
|
+
const rows = database
|
|
60
|
+
.prepare("SELECT * FROM engine_runs WHERE state = 'running' AND worker_id != ?")
|
|
61
|
+
.all(workerId);
|
|
62
|
+
// A dead process is recovered at the next start; a live one only after its lease runs out.
|
|
63
|
+
return rows
|
|
64
|
+
.filter((row) => row.lease_until < now || !ownerIsAlive(row.owner_token, row.owner_path))
|
|
65
|
+
.map((row) => ({ runId: row.run_id, held: leaseOf(row) }));
|
|
66
|
+
},
|
|
67
|
+
async listReady(now) {
|
|
68
|
+
const rows = database
|
|
69
|
+
.prepare(`
|
|
70
|
+
SELECT DISTINCT r.* FROM engine_runs r
|
|
71
|
+
JOIN engine_waits w ON w.run_id = r.run_id AND w.outcome_json IS NULL
|
|
72
|
+
WHERE r.state = 'waiting' AND (
|
|
73
|
+
r.cancel_requested = 1 OR w.deadline <= ? OR EXISTS (
|
|
74
|
+
SELECT 1 FROM engine_signals s WHERE s.run_id = w.run_id AND s.name = w.name
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
`)
|
|
78
|
+
.all(now);
|
|
79
|
+
return rows.map((row) => ({ runId: row.run_id, held: leaseOf(row) }));
|
|
80
|
+
},
|
|
81
|
+
async listWaiting() {
|
|
82
|
+
const rows = database
|
|
83
|
+
.prepare(`SELECT r.run_id, w.deadline FROM engine_runs r
|
|
84
|
+
JOIN engine_waits w ON w.run_id = r.run_id AND w.outcome_json IS NULL
|
|
85
|
+
WHERE r.state = 'waiting'`)
|
|
86
|
+
.all();
|
|
87
|
+
return rows.map((row) => ({ runId: row.run_id, deadline: row.deadline }));
|
|
88
|
+
},
|
|
89
|
+
async requestCancel(runId) {
|
|
90
|
+
const result = database
|
|
91
|
+
.prepare("UPDATE engine_runs SET cancel_requested = 1 WHERE run_id = ? AND cancel_requested = 0 AND state IN ('running', 'waiting')")
|
|
92
|
+
.run(runId);
|
|
93
|
+
return result.changes === 1;
|
|
94
|
+
},
|
|
95
|
+
async getWait(runId, name) {
|
|
96
|
+
const row = database
|
|
97
|
+
.prepare("SELECT deadline, outcome_json FROM engine_waits WHERE run_id = ? AND name = ?")
|
|
98
|
+
.get(runId, name);
|
|
99
|
+
return row && { deadline: row.deadline, outcomeJson: row.outcome_json };
|
|
100
|
+
},
|
|
101
|
+
async openWait(runId, name, deadline) {
|
|
102
|
+
database
|
|
103
|
+
.prepare("INSERT OR IGNORE INTO engine_waits (run_id, name, deadline) VALUES (?, ?, ?)")
|
|
104
|
+
.run(runId, name, deadline);
|
|
105
|
+
},
|
|
106
|
+
async resolveWait(runId, name, outcomeJson) {
|
|
107
|
+
const result = database
|
|
108
|
+
.prepare("UPDATE engine_waits SET outcome_json = ? WHERE run_id = ? AND name = ? AND outcome_json IS NULL")
|
|
109
|
+
.run(outcomeJson, runId, name);
|
|
110
|
+
return result.changes === 1;
|
|
111
|
+
},
|
|
112
|
+
async putSignal(runId, name, valueJson) {
|
|
113
|
+
const result = database
|
|
114
|
+
.prepare("INSERT OR IGNORE INTO engine_signals (run_id, name, value_json) VALUES (?, ?, ?)")
|
|
115
|
+
.run(runId, name, valueJson);
|
|
116
|
+
return result.changes === 1;
|
|
117
|
+
},
|
|
118
|
+
async getSignal(runId, name) {
|
|
119
|
+
const row = database
|
|
120
|
+
.prepare("SELECT value_json FROM engine_signals WHERE run_id = ? AND name = ?")
|
|
121
|
+
.get(runId, name);
|
|
122
|
+
return row?.value_json;
|
|
123
|
+
},
|
|
124
|
+
async getStep(runId, index) {
|
|
125
|
+
const row = database
|
|
126
|
+
.prepare("SELECT name, result_json FROM engine_steps WHERE run_id = ? AND step_index = ?")
|
|
127
|
+
.get(runId, index);
|
|
128
|
+
return row && { name: row.name, resultJson: row.result_json };
|
|
129
|
+
},
|
|
130
|
+
async putStep(runId, index, step) {
|
|
131
|
+
database
|
|
132
|
+
.prepare("INSERT OR IGNORE INTO engine_steps (run_id, step_index, name, result_json) VALUES (?, ?, ?, ?)")
|
|
133
|
+
.run(runId, index, step.name, step.resultJson);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|