@plico-ai/runtime 0.0.0-reserved.0 → 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.
Files changed (187) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +9 -3
  3. package/dist/artifacts.d.ts +10 -0
  4. package/dist/artifacts.js +106 -0
  5. package/dist/engine/context.d.ts +16 -0
  6. package/dist/engine/context.js +77 -0
  7. package/dist/engine/engine.d.ts +12 -0
  8. package/dist/engine/engine.js +235 -0
  9. package/dist/engine/errors.d.ts +12 -0
  10. package/dist/engine/errors.js +19 -0
  11. package/dist/engine/index.d.ts +7 -0
  12. package/dist/engine/index.js +5 -0
  13. package/dist/engine/journal.d.ts +65 -0
  14. package/dist/engine/journal.js +1 -0
  15. package/dist/engine/memory-journal.d.ts +2 -0
  16. package/dist/engine/memory-journal.js +125 -0
  17. package/dist/engine/sqlite-engine.d.ts +7 -0
  18. package/dist/engine/sqlite-engine.js +6 -0
  19. package/dist/engine/sqlite-journal.d.ts +5 -0
  20. package/dist/engine/sqlite-journal.js +136 -0
  21. package/dist/engine/types.d.ts +97 -0
  22. package/dist/engine/types.js +1 -0
  23. package/dist/env.d.ts +7 -0
  24. package/dist/env.js +19 -0
  25. package/dist/errors.d.ts +18 -0
  26. package/dist/errors.js +40 -0
  27. package/dist/evals/discover.d.ts +3 -0
  28. package/dist/evals/discover.js +49 -0
  29. package/dist/evals/index.d.ts +3 -0
  30. package/dist/evals/index.js +3 -0
  31. package/dist/evals/promote.d.ts +7 -0
  32. package/dist/evals/promote.js +32 -0
  33. package/dist/evals/run-evals.d.ts +2 -0
  34. package/dist/evals/run-evals.js +69 -0
  35. package/dist/events.d.ts +28 -0
  36. package/dist/events.js +104 -0
  37. package/dist/guards.d.ts +1 -0
  38. package/dist/guards.js +3 -0
  39. package/dist/index.d.ts +18 -0
  40. package/dist/index.js +17 -0
  41. package/dist/instructions.d.ts +4 -0
  42. package/dist/instructions.js +23 -0
  43. package/dist/policy/approval.d.ts +8 -0
  44. package/dist/policy/approval.js +27 -0
  45. package/dist/providers/ai-sdk.d.ts +10 -0
  46. package/dist/providers/ai-sdk.js +154 -0
  47. package/dist/providers/configured.d.ts +17 -0
  48. package/dist/providers/configured.js +60 -0
  49. package/dist/providers/openai-compatible.d.ts +8 -0
  50. package/dist/providers/openai-compatible.js +14 -0
  51. package/dist/providers/project-module.d.ts +7 -0
  52. package/dist/providers/project-module.js +29 -0
  53. package/dist/providers/registry.d.ts +20 -0
  54. package/dist/providers/registry.js +53 -0
  55. package/dist/readiness/check-readiness.d.ts +2 -0
  56. package/dist/readiness/check-readiness.js +33 -0
  57. package/dist/readiness/checks/approval-policy.d.ts +5 -0
  58. package/dist/readiness/checks/approval-policy.js +19 -0
  59. package/dist/readiness/checks/artifact-access.d.ts +3 -0
  60. package/dist/readiness/checks/artifact-access.js +5 -0
  61. package/dist/readiness/checks/artifacts.d.ts +6 -0
  62. package/dist/readiness/checks/artifacts.js +22 -0
  63. package/dist/readiness/checks/env.d.ts +3 -0
  64. package/dist/readiness/checks/env.js +10 -0
  65. package/dist/readiness/checks/evals.d.ts +2 -0
  66. package/dist/readiness/checks/evals.js +53 -0
  67. package/dist/readiness/checks/persistence.d.ts +3 -0
  68. package/dist/readiness/checks/persistence.js +61 -0
  69. package/dist/readiness/checks/project.d.ts +7 -0
  70. package/dist/readiness/checks/project.js +21 -0
  71. package/dist/readiness/checks/provider.d.ts +2 -0
  72. package/dist/readiness/checks/provider.js +20 -0
  73. package/dist/readiness/checks/server-config.d.ts +2 -0
  74. package/dist/readiness/checks/server-config.js +13 -0
  75. package/dist/readiness/checks/studio.d.ts +2 -0
  76. package/dist/readiness/checks/studio.js +16 -0
  77. package/dist/readiness/checks/thread-store.d.ts +3 -0
  78. package/dist/readiness/checks/thread-store.js +12 -0
  79. package/dist/readiness/checks/tools.d.ts +3 -0
  80. package/dist/readiness/checks/tools.js +42 -0
  81. package/dist/readiness/diagnostic.d.ts +2 -0
  82. package/dist/readiness/diagnostic.js +7 -0
  83. package/dist/readiness/index.d.ts +1 -0
  84. package/dist/readiness/index.js +1 -0
  85. package/dist/readiness/open-database.d.ts +8 -0
  86. package/dist/readiness/open-database.js +15 -0
  87. package/dist/run/artifact-tool-context.d.ts +19 -0
  88. package/dist/run/artifact-tool-context.js +74 -0
  89. package/dist/run/finalize.d.ts +16 -0
  90. package/dist/run/finalize.js +11 -0
  91. package/dist/run/index.d.ts +1 -0
  92. package/dist/run/index.js +1 -0
  93. package/dist/run/pinned-instructions.d.ts +18 -0
  94. package/dist/run/pinned-instructions.js +36 -0
  95. package/dist/run/record-tools.d.ts +8 -0
  96. package/dist/run/record-tools.js +21 -0
  97. package/dist/run/run-project.d.ts +17 -0
  98. package/dist/run/run-project.js +222 -0
  99. package/dist/run/strategies/approval.d.ts +9 -0
  100. package/dist/run/strategies/approval.js +42 -0
  101. package/dist/run/strategies/default-dry.d.ts +3 -0
  102. package/dist/run/strategies/default-dry.js +5 -0
  103. package/dist/run/strategies/provider.d.ts +6 -0
  104. package/dist/run/strategies/provider.js +193 -0
  105. package/dist/run/strategies/scripted.d.ts +5 -0
  106. package/dist/run/strategies/scripted.js +106 -0
  107. package/dist/run/tool-fingerprint.d.ts +2 -0
  108. package/dist/run/tool-fingerprint.js +26 -0
  109. package/dist/run/tool-step.d.ts +4 -0
  110. package/dist/run/tool-step.js +62 -0
  111. package/dist/run/tracing.d.ts +12 -0
  112. package/dist/run/tracing.js +92 -0
  113. package/dist/script.d.ts +4 -0
  114. package/dist/script.js +51 -0
  115. package/dist/storage/index.d.ts +1 -0
  116. package/dist/storage/index.js +1 -0
  117. package/dist/storage/metadata.d.ts +5 -0
  118. package/dist/storage/metadata.js +26 -0
  119. package/dist/storage/sqlite/append-event.d.ts +3 -0
  120. package/dist/storage/sqlite/append-event.js +108 -0
  121. package/dist/storage/sqlite/driver.d.ts +9 -0
  122. package/dist/storage/sqlite/driver.js +13 -0
  123. package/dist/storage/sqlite/index.d.ts +1 -0
  124. package/dist/storage/sqlite/index.js +1 -0
  125. package/dist/storage/sqlite/open.d.ts +3 -0
  126. package/dist/storage/sqlite/open.js +54 -0
  127. package/dist/storage/sqlite/owner-lock.d.ts +5 -0
  128. package/dist/storage/sqlite/owner-lock.js +55 -0
  129. package/dist/storage/sqlite/project-thread.d.ts +4 -0
  130. package/dist/storage/sqlite/project-thread.js +33 -0
  131. package/dist/storage/sqlite/queries.d.ts +9 -0
  132. package/dist/storage/sqlite/queries.js +134 -0
  133. package/dist/storage/sqlite/recover-interrupted.d.ts +2 -0
  134. package/dist/storage/sqlite/recover-interrupted.js +46 -0
  135. package/dist/storage/sqlite/schema.d.ts +10 -0
  136. package/dist/storage/sqlite/schema.js +205 -0
  137. package/dist/storage/sqlite/store.d.ts +2 -0
  138. package/dist/storage/sqlite/store.js +51 -0
  139. package/dist/storage/sqlite/thread-log.d.ts +4 -0
  140. package/dist/storage/sqlite/thread-log.js +9 -0
  141. package/dist/storage/sqlite/thread-title.d.ts +24 -0
  142. package/dist/storage/sqlite/thread-title.js +85 -0
  143. package/dist/storage/sqlite/threads.d.ts +47 -0
  144. package/dist/storage/sqlite/threads.js +156 -0
  145. package/dist/threads/stopwords.d.ts +1 -0
  146. package/dist/threads/stopwords.js +16 -0
  147. package/dist/threads/suggest-title.d.ts +9 -0
  148. package/dist/threads/suggest-title.js +102 -0
  149. package/dist/threads/titler.d.ts +10 -0
  150. package/dist/threads/titler.js +63 -0
  151. package/dist/threads/words.d.ts +12 -0
  152. package/dist/threads/words.js +39 -0
  153. package/dist/tools/artifact-model-tool-operations.d.ts +29 -0
  154. package/dist/tools/artifact-model-tool-operations.js +124 -0
  155. package/dist/tools/artifact-model-tools.d.ts +2 -0
  156. package/dist/tools/artifact-model-tools.js +101 -0
  157. package/dist/tools/discover.d.ts +4 -0
  158. package/dist/tools/discover.js +34 -0
  159. package/dist/tools/index.d.ts +3 -0
  160. package/dist/tools/index.js +3 -0
  161. package/dist/tools/load.d.ts +2 -0
  162. package/dist/tools/load.js +83 -0
  163. package/dist/tools/normalize.d.ts +2 -0
  164. package/dist/tools/normalize.js +48 -0
  165. package/dist/tools/schema.d.ts +7 -0
  166. package/dist/tools/schema.js +101 -0
  167. package/dist/tools/skill-model-tool.d.ts +7 -0
  168. package/dist/tools/skill-model-tool.js +38 -0
  169. package/dist/types/artifacts.d.ts +5 -0
  170. package/dist/types/artifacts.js +1 -0
  171. package/dist/types/evals.d.ts +16 -0
  172. package/dist/types/evals.js +1 -0
  173. package/dist/types/index.d.ts +10 -0
  174. package/dist/types/index.js +1 -0
  175. package/dist/types/provider.d.ts +45 -0
  176. package/dist/types/provider.js +1 -0
  177. package/dist/types/readiness.d.ts +9 -0
  178. package/dist/types/readiness.js +1 -0
  179. package/dist/types/run.d.ts +28 -0
  180. package/dist/types/run.js +1 -0
  181. package/dist/types/script.d.ts +16 -0
  182. package/dist/types/script.js +1 -0
  183. package/dist/types/store.d.ts +24 -0
  184. package/dist/types/store.js +1 -0
  185. package/dist/types/tools.d.ts +15 -0
  186. package/dist/types/tools.js +1 -0
  187. package/package.json +67 -8
package/NOTICE ADDED
@@ -0,0 +1,4 @@
1
+ Plico
2
+ Copyright 2026 Plico contributors
3
+
4
+ This product includes software developed by the Plico contributors.
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # @plico-ai/runtime
2
2
 
3
- The Plico run engine, event log and tools.
3
+ The Plico runtime: linear agent runs, the durable SQLite event log, tool execution, scripted and provider-backed models, evals, artifacts, approvals and readiness checks.
4
4
 
5
- This is a pre-release placeholder. The first developer preview is in progress at
6
- https://github.com/ITZSHOAIB/plico. Docs: https://plico.sohab.dev
5
+ Most projects use it through [`@plico-ai/cli`](../cli). Import it directly to embed runs in your own process:
6
+
7
+ ```ts
8
+ import { loadProject } from "@plico-ai/core";
9
+ import { runProject } from "@plico-ai/runtime";
10
+ ```
11
+
12
+ Requires Node `>=22.18`. Wire types come from `@plico-ai/contracts`.
@@ -0,0 +1,10 @@
1
+ import type { ArtifactContent, ArtifactFile, ArtifactFileMetadata, ArtifactSnapshotDetail, ArtifactWorkspace, PlicoEvent } from "./types/index.js";
2
+ export declare function encodeArtifactContent(content: string | Uint8Array): ArtifactContent;
3
+ export declare function decodeArtifactContent(content: ArtifactContent): string | Uint8Array;
4
+ export declare function artifactContentBytes(content: ArtifactContent): Uint8Array;
5
+ export declare function hashArtifactContent(content: ArtifactContent): string;
6
+ export declare function artifactFileMetadata(file: ArtifactFile): ArtifactFileMetadata;
7
+ /** Returns a historical file version using the same replay rules as the workspace projection. */
8
+ export declare function readArtifactVersion(events: PlicoEvent[], path: string, targetVersion: number): ArtifactFile | undefined;
9
+ export declare function buildArtifactWorkspace(runId: string, events: PlicoEvent[]): ArtifactWorkspace | undefined;
10
+ export declare function readArtifactSnapshot(events: PlicoEvent[], snapshotId: string): ArtifactSnapshotDetail | undefined;
@@ -0,0 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ export function encodeArtifactContent(content) {
3
+ return typeof content === "string"
4
+ ? content
5
+ : { encoding: "base64", data: Buffer.from(content).toString("base64") };
6
+ }
7
+ export function decodeArtifactContent(content) {
8
+ if (typeof content === "string")
9
+ return content;
10
+ return decodeBase64(content.data);
11
+ }
12
+ export function artifactContentBytes(content) {
13
+ return typeof content === "string" ? Buffer.from(content, "utf8") : decodeBase64(content.data);
14
+ }
15
+ function decodeBase64(data) {
16
+ const bytes = Buffer.from(data, "base64");
17
+ if (bytes.toString("base64") !== data)
18
+ throw new Error("Artifact content has invalid base64 data.");
19
+ return bytes;
20
+ }
21
+ export function hashArtifactContent(content) {
22
+ return createHash("sha256").update(artifactContentBytes(content)).digest("hex");
23
+ }
24
+ export function artifactFileMetadata(file) {
25
+ return {
26
+ path: file.path,
27
+ sizeBytes: artifactContentBytes(file.content).byteLength,
28
+ mimeType: file.mimeType,
29
+ version: file.version,
30
+ contentHash: file.contentHash,
31
+ };
32
+ }
33
+ function applyFileWritten(files, versions, event) {
34
+ const path = event.payload.path;
35
+ const version = event.payload.version ?? (files.get(path)?.version ?? 0) + 1;
36
+ const contentHash = event.payload.contentHash ?? hashArtifactContent(event.payload.content);
37
+ const file = {
38
+ path,
39
+ content: event.payload.content,
40
+ mimeType: event.payload.mimeType,
41
+ version,
42
+ contentHash,
43
+ };
44
+ files.set(path, file);
45
+ versions.push({
46
+ path,
47
+ mimeType: file.mimeType,
48
+ version,
49
+ contentHash,
50
+ runId: event.runId,
51
+ createdAt: event.timestamp,
52
+ });
53
+ return file;
54
+ }
55
+ /** Returns a historical file version using the same replay rules as the workspace projection. */
56
+ export function readArtifactVersion(events, path, targetVersion) {
57
+ const files = new Map();
58
+ const versions = [];
59
+ for (const event of events) {
60
+ if (event.type !== "artifact.file.written")
61
+ continue;
62
+ const file = applyFileWritten(files, versions, event);
63
+ if (file.path === path && file.version === targetVersion)
64
+ return file;
65
+ }
66
+ return undefined;
67
+ }
68
+ function toSnapshot(event) {
69
+ return {
70
+ snapshotId: event.payload.snapshotId,
71
+ runId: event.runId,
72
+ fileCount: event.payload.fileCount,
73
+ createdAt: event.timestamp,
74
+ };
75
+ }
76
+ export function buildArtifactWorkspace(runId, events) {
77
+ const created = events.find((event) => event.type === "artifact.workspace.created");
78
+ if (!created)
79
+ return undefined;
80
+ const files = new Map();
81
+ const versions = [];
82
+ const snapshots = [];
83
+ for (const event of events) {
84
+ if (event.type === "artifact.file.written")
85
+ applyFileWritten(files, versions, event);
86
+ if (event.type === "artifact.snapshot.created")
87
+ snapshots.push(toSnapshot(event));
88
+ }
89
+ return {
90
+ workspaceId: created.payload.workspaceId,
91
+ runId,
92
+ files: [...files.values()],
93
+ versions,
94
+ snapshots,
95
+ createdAt: created.timestamp,
96
+ };
97
+ }
98
+ export function readArtifactSnapshot(events, snapshotId) {
99
+ const index = events.findIndex((event) => event.type === "artifact.snapshot.created" && event.payload.snapshotId === snapshotId);
100
+ const snapshot = events[index];
101
+ // This narrows the event for TypeScript and also returns early when the snapshot is missing.
102
+ if (snapshot?.type !== "artifact.snapshot.created")
103
+ return undefined;
104
+ const workspace = buildArtifactWorkspace(snapshot.runId, events.slice(0, index + 1));
105
+ return { ...toSnapshot(snapshot), files: workspace?.files ?? [] };
106
+ }
@@ -0,0 +1,16 @@
1
+ import type { Journal } from "./journal.js";
2
+ import type { RunContext } from "./types.js";
3
+ export interface Suspension {
4
+ name: string;
5
+ /** Epoch milliseconds, or null when the wait has no timeout. */
6
+ deadline: number | null;
7
+ }
8
+ export interface EngineContext extends RunContext {
9
+ /** Set once `wait` has stopped the run. */
10
+ suspension(): Suspension | undefined;
11
+ /** True once `step` or `wait` has stopped the run because it was cancelled. */
12
+ cancelled(): boolean;
13
+ }
14
+ export declare function toJson(value: unknown): string;
15
+ /** The context one execution of a workflow sees. Steps and waits replay from the journal. */
16
+ export declare function createContext(runId: string, journal: Journal, now: () => number, signal: AbortSignal): EngineContext;
@@ -0,0 +1,77 @@
1
+ import { ProjectChangedError, RunCancelled, RunSuspended } from "./errors.js";
2
+ export function toJson(value) {
3
+ return JSON.stringify(value === undefined ? null : value);
4
+ }
5
+ /** The context one execution of a workflow sees. Steps and waits replay from the journal. */
6
+ export function createContext(runId, journal, now, signal) {
7
+ let nextStep = 0;
8
+ let suspended;
9
+ let stopped = false;
10
+ async function checkCancel() {
11
+ if (signal.aborted || (await journal.getRun(runId))?.cancelRequested) {
12
+ stopped = true;
13
+ throw new RunCancelled();
14
+ }
15
+ }
16
+ async function endWait(name, outcome) {
17
+ if (await journal.resolveWait(runId, name, JSON.stringify(outcome)))
18
+ return outcome;
19
+ // Someone ended it first; the first outcome stands.
20
+ const wait = await journal.getWait(runId, name);
21
+ return JSON.parse(wait?.outcomeJson ?? JSON.stringify(outcome));
22
+ }
23
+ return {
24
+ runId,
25
+ signal,
26
+ suspension: () => suspended,
27
+ cancelled: () => stopped,
28
+ async step(name, fn, options) {
29
+ const index = nextStep++;
30
+ const recorded = await journal.getStep(runId, index);
31
+ if (recorded) {
32
+ if (recorded.name !== name) {
33
+ throw new ProjectChangedError("step", index, recorded.name, name);
34
+ }
35
+ return JSON.parse(recorded.resultJson);
36
+ }
37
+ await checkCancel();
38
+ const retries = options?.retries ?? 0;
39
+ for (let attempt = 0;; attempt++) {
40
+ try {
41
+ const resultJson = toJson(await fn({ attempt, signal }));
42
+ await journal.putStep(runId, index, { name, resultJson });
43
+ return JSON.parse(resultJson);
44
+ }
45
+ catch (error) {
46
+ // A step cut short by cancel is not recorded and not retried.
47
+ if (signal.aborted) {
48
+ stopped = true;
49
+ throw new RunCancelled();
50
+ }
51
+ if (attempt >= retries || (options?.shouldRetry && !options.shouldRetry(error)))
52
+ throw error;
53
+ }
54
+ }
55
+ },
56
+ async wait(name, options) {
57
+ await checkCancel();
58
+ // The deadline is stored when the wait is first reached, so a restart keeps it.
59
+ await journal.openWait(runId, name, options?.timeoutMs === undefined ? null : now() + options.timeoutMs);
60
+ const wait = await journal.getWait(runId, name);
61
+ if (!wait)
62
+ throw new Error(`Wait ${name} could not be opened.`);
63
+ if (wait.outcomeJson !== null)
64
+ return JSON.parse(wait.outcomeJson);
65
+ const signal = await journal.getSignal(runId, name);
66
+ if (signal !== undefined) {
67
+ const outcome = await endWait(name, { kind: "signal", value: JSON.parse(signal) });
68
+ return outcome;
69
+ }
70
+ if (wait.deadline !== null && now() >= wait.deadline) {
71
+ return (await endWait(name, { kind: "timeout" }));
72
+ }
73
+ suspended = { name, deadline: wait.deadline };
74
+ throw new RunSuspended(name);
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,12 @@
1
+ import type { Journal } from "./journal.js";
2
+ import type { Engine } from "./types.js";
3
+ export interface EngineOptions {
4
+ journal: Journal;
5
+ /** Names this engine's hold on runs. Must be unique per live process. */
6
+ workerId?: string;
7
+ /** How long a worker holds a run before another may take it. Renewed while the run executes. */
8
+ leaseMs?: number;
9
+ now?: () => number;
10
+ }
11
+ export declare const defaultLeaseMs = 30000;
12
+ export declare function createEngine({ journal, workerId, leaseMs, now, }: EngineOptions): Engine;
@@ -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,2 @@
1
+ import type { Journal } from "./journal.js";
2
+ export declare function createMemoryJournal(): Journal;