@llblab/pi-actors 0.32.0 → 0.34.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 (162) hide show
  1. package/AGENTS.md +23 -11
  2. package/BACKLOG.md +2 -30
  3. package/CHANGELOG.md +30 -1
  4. package/dist/index.js +10 -10
  5. package/dist/lib/async-runs.d.ts +23 -75
  6. package/dist/lib/async-runs.js +49 -801
  7. package/dist/lib/command-templates.d.ts +3 -3
  8. package/dist/lib/command-templates.js +3 -3
  9. package/dist/lib/config.d.ts +2 -2
  10. package/dist/lib/config.js +5 -5
  11. package/dist/lib/{output.d.ts → execution-output.d.ts} +3 -3
  12. package/dist/lib/{output.js → execution-output.js} +3 -3
  13. package/dist/lib/execution.js +1 -1
  14. package/dist/lib/{actor-inspector-tui.d.ts → inspector.d.ts} +3 -2
  15. package/dist/lib/{actor-inspector-tui.js → inspector.js} +3 -2
  16. package/dist/lib/mailbox-loop.d.ts +3 -3
  17. package/dist/lib/mailbox-loop.js +3 -3
  18. package/dist/lib/{actor-messages.d.ts → messages.d.ts} +3 -3
  19. package/dist/lib/{actor-messages.js → messages.js} +3 -3
  20. package/dist/lib/paths.d.ts +1 -1
  21. package/dist/lib/paths.js +3 -3
  22. package/dist/lib/{actor-recipe-context.d.ts → recipes-context.d.ts} +3 -2
  23. package/dist/lib/{actor-recipe-context.js → recipes-context.js} +2 -1
  24. package/dist/lib/{recipe-discovery.d.ts → recipes-discovery.d.ts} +9 -9
  25. package/dist/lib/{recipe-discovery.js → recipes-discovery.js} +17 -17
  26. package/dist/lib/registry.d.ts +1 -1
  27. package/dist/lib/registry.js +22 -22
  28. package/dist/lib/{actor-rooms.d.ts → rooms.d.ts} +4 -4
  29. package/dist/lib/{actor-rooms.js → rooms.js} +3 -3
  30. package/dist/lib/runs-artifacts.d.ts +21 -0
  31. package/dist/lib/runs-artifacts.js +68 -0
  32. package/dist/lib/runs-control.d.ts +13 -0
  33. package/dist/lib/runs-control.js +59 -0
  34. package/dist/lib/runs-identity.d.ts +5 -0
  35. package/dist/lib/runs-identity.js +11 -0
  36. package/dist/lib/runs-index.d.ts +18 -0
  37. package/dist/lib/runs-index.js +119 -0
  38. package/dist/lib/runs-mailbox.d.ts +25 -0
  39. package/dist/lib/runs-mailbox.js +146 -0
  40. package/dist/lib/runs-messages.d.ts +15 -0
  41. package/dist/lib/runs-messages.js +179 -0
  42. package/dist/lib/runs-outbox.d.ts +41 -0
  43. package/dist/lib/runs-outbox.js +87 -0
  44. package/dist/lib/runs-process.d.ts +7 -0
  45. package/dist/lib/runs-process.js +36 -0
  46. package/dist/lib/runs-retention.d.ts +8 -0
  47. package/dist/lib/runs-retention.js +56 -0
  48. package/dist/lib/runs-start.d.ts +9 -0
  49. package/dist/lib/runs-start.js +67 -0
  50. package/dist/lib/runs-status.d.ts +8 -0
  51. package/dist/lib/runs-status.js +60 -0
  52. package/dist/lib/runtime.d.ts +2 -2
  53. package/dist/lib/runtime.js +5 -5
  54. package/dist/lib/schema.d.ts +9 -0
  55. package/dist/lib/schema.js +40 -0
  56. package/dist/lib/tools-access.d.ts +19 -0
  57. package/dist/lib/tools-access.js +44 -0
  58. package/dist/lib/tools-inspect.d.ts +11 -0
  59. package/dist/lib/tools-inspect.js +534 -0
  60. package/dist/lib/tools-local.d.ts +14 -0
  61. package/dist/lib/tools-local.js +156 -0
  62. package/dist/lib/tools-mailbox.d.ts +8 -0
  63. package/dist/lib/tools-mailbox.js +48 -0
  64. package/dist/lib/tools-message.d.ts +9 -0
  65. package/dist/lib/tools-message.js +346 -0
  66. package/dist/lib/tools-register.d.ts +18 -0
  67. package/dist/lib/tools-register.js +40 -0
  68. package/dist/lib/tools-response.d.ts +21 -0
  69. package/dist/lib/tools-response.js +219 -0
  70. package/dist/lib/tools-spawn.d.ts +12 -0
  71. package/dist/lib/tools-spawn.js +159 -0
  72. package/dist/lib/tools.d.ts +13 -39
  73. package/dist/lib/tools.js +11 -1497
  74. package/dist/recipes/utility-coordinator-lock-snapshot.json +1 -1
  75. package/dist/scripts/actor-worker.mjs +186 -7
  76. package/dist/scripts/async-runner.mjs +171 -8
  77. package/dist/scripts/build-dist.mjs +1 -1
  78. package/dist/scripts/conformance.mjs +34 -18
  79. package/dist/scripts/coordinator.mjs +733 -11
  80. package/dist/scripts/locker.mjs +345 -16
  81. package/dist/scripts/recipe-utils.mjs +478 -14
  82. package/dist/scripts/validate-recipe.mjs +110 -10
  83. package/dist/skills/actors/SKILL.md +2 -2
  84. package/dist/skills/swarm/SKILL.md +2 -2
  85. package/docs/recipe-library.md +1 -1
  86. package/docs/template-recipes.md +1 -1
  87. package/docs/tool-registry.md +3 -3
  88. package/index.ts +12 -19
  89. package/lib/async-runs.ts +124 -1016
  90. package/lib/command-templates.ts +3 -3
  91. package/lib/config.ts +7 -7
  92. package/lib/{output.ts → execution-output.ts} +3 -3
  93. package/lib/execution.ts +5 -1
  94. package/lib/{actor-inspector-tui.ts → inspector.ts} +4 -3
  95. package/lib/mailbox-loop.ts +3 -3
  96. package/lib/{actor-messages.ts → messages.ts} +3 -3
  97. package/lib/paths.ts +3 -3
  98. package/lib/{actor-recipe-context.ts → recipes-context.ts} +3 -2
  99. package/lib/{recipe-discovery.ts → recipes-discovery.ts} +30 -30
  100. package/lib/{recipe-usage.ts → recipes-usage.ts} +2 -2
  101. package/lib/registry.ts +37 -24
  102. package/lib/{actor-rooms.ts → rooms.ts} +4 -4
  103. package/lib/runs-artifacts.ts +96 -0
  104. package/lib/runs-control.ts +85 -0
  105. package/lib/runs-identity.ts +14 -0
  106. package/lib/runs-index.ts +156 -0
  107. package/lib/runs-mailbox.ts +208 -0
  108. package/lib/runs-messages.ts +252 -0
  109. package/lib/runs-outbox.ts +144 -0
  110. package/lib/runs-process.ts +51 -0
  111. package/lib/runs-retention.ts +77 -0
  112. package/lib/runs-start.ts +98 -0
  113. package/lib/runs-status.ts +78 -0
  114. package/lib/runtime.ts +6 -6
  115. package/lib/schema.ts +55 -0
  116. package/lib/tools-access.ts +68 -0
  117. package/lib/tools-inspect.ts +735 -0
  118. package/lib/tools-local.ts +234 -0
  119. package/lib/tools-mailbox.ts +56 -0
  120. package/lib/tools-message.ts +483 -0
  121. package/lib/tools-register.ts +64 -0
  122. package/lib/tools-response.ts +267 -0
  123. package/lib/tools-spawn.ts +240 -0
  124. package/lib/tools.ts +26 -2051
  125. package/package.json +1 -1
  126. package/recipes/utility-coordinator-lock-snapshot.json +1 -1
  127. package/scripts/actor-worker.mjs +186 -7
  128. package/scripts/async-runner.mjs +171 -8
  129. package/scripts/build-dist.mjs +1 -1
  130. package/scripts/conformance.mjs +34 -18
  131. package/scripts/coordinator.mjs +733 -11
  132. package/scripts/locker.mjs +345 -16
  133. package/scripts/recipe-utils.mjs +478 -14
  134. package/scripts/validate-recipe.mjs +110 -10
  135. package/skills/actors/SKILL.md +2 -2
  136. package/skills/swarm/SKILL.md +2 -2
  137. package/dist/lib/actor-worker.d.ts +0 -16
  138. package/dist/lib/actor-worker.js +0 -134
  139. package/dist/lib/async-runner.d.ts +0 -5
  140. package/dist/lib/async-runner.js +0 -134
  141. package/dist/lib/conformance.d.ts +0 -12
  142. package/dist/lib/conformance.js +0 -28
  143. package/dist/lib/coordinator.d.ts +0 -5
  144. package/dist/lib/coordinator.js +0 -660
  145. package/dist/lib/locker.d.ts +0 -5
  146. package/dist/lib/locker.js +0 -310
  147. package/dist/lib/recipe-utils.d.ts +0 -5
  148. package/dist/lib/recipe-utils.js +0 -385
  149. package/dist/lib/validate-recipe.d.ts +0 -6
  150. package/dist/lib/validate-recipe.js +0 -104
  151. package/lib/actor-worker.ts +0 -174
  152. package/lib/async-runner.ts +0 -173
  153. package/lib/conformance.ts +0 -46
  154. package/lib/coordinator.ts +0 -742
  155. package/lib/locker.ts +0 -340
  156. package/lib/recipe-utils.ts +0 -486
  157. package/lib/validate-recipe.ts +0 -110
  158. /package/dist/lib/{recipe-references.d.ts → recipes-references.d.ts} +0 -0
  159. /package/dist/lib/{recipe-references.js → recipes-references.js} +0 -0
  160. /package/dist/lib/{recipe-usage.d.ts → recipes-usage.d.ts} +0 -0
  161. /package/dist/lib/{recipe-usage.js → recipes-usage.js} +0 -0
  162. /package/lib/{recipe-references.ts → recipes-references.ts} +0 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Async run artifact declarations and manifest resolution.
3
+ * Owns artifact path template expansion and filesystem-backed artifact metadata.
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { readFileSync } from "node:fs";
8
+
9
+ import { substituteCommandTemplateToken } from "./command-templates.ts";
10
+
11
+ export type RunArtifactDeclaration =
12
+ | string
13
+ | { path: string; kind?: string; media_type?: string; required?: boolean };
14
+
15
+ export interface RunArtifactManifestEntry {
16
+ exists: boolean;
17
+ kind?: string;
18
+ media_type?: string;
19
+ path: string;
20
+ required?: boolean;
21
+ sha256?: string;
22
+ size?: number;
23
+ }
24
+
25
+ export function resolveArtifactPaths(
26
+ artifacts: Record<string, RunArtifactDeclaration> | undefined,
27
+ values: Record<string, unknown>,
28
+ ): Record<string, RunArtifactDeclaration> | undefined {
29
+ if (!artifacts) return undefined;
30
+ const resolved: Record<string, RunArtifactDeclaration> = {};
31
+ for (const [key, value] of Object.entries(artifacts)) {
32
+ if (!key.trim()) continue;
33
+ if (typeof value === "string") {
34
+ resolved[key] = substituteCommandTemplateToken(
35
+ value,
36
+ values,
37
+ `recipe artifacts.${key}`,
38
+ );
39
+ } else if (
40
+ value &&
41
+ typeof value === "object" &&
42
+ typeof value.path === "string"
43
+ ) {
44
+ resolved[key] = {
45
+ ...value,
46
+ path: substituteCommandTemplateToken(
47
+ value.path,
48
+ values,
49
+ `recipe artifacts.${key}.path`,
50
+ ),
51
+ };
52
+ }
53
+ }
54
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
55
+ }
56
+
57
+ export function resolveArtifactManifest(
58
+ artifacts: Record<string, RunArtifactDeclaration> | undefined,
59
+ ): Record<string, RunArtifactManifestEntry> | undefined {
60
+ if (!artifacts) return undefined;
61
+ const manifest: Record<string, RunArtifactManifestEntry> = {};
62
+ for (const [name, artifact] of Object.entries(artifacts)) {
63
+ const declaration =
64
+ typeof artifact === "string" ? { path: artifact } : artifact;
65
+ if (!declaration?.path) continue;
66
+ try {
67
+ const content = readFileSync(declaration.path);
68
+ manifest[name] = {
69
+ exists: true,
70
+ ...(declaration.kind ? { kind: declaration.kind } : {}),
71
+ ...(declaration.media_type
72
+ ? { media_type: declaration.media_type }
73
+ : {}),
74
+ path: declaration.path,
75
+ ...(declaration.required !== undefined
76
+ ? { required: declaration.required }
77
+ : {}),
78
+ sha256: createHash("sha256").update(content).digest("hex"),
79
+ size: content.byteLength,
80
+ };
81
+ } catch {
82
+ manifest[name] = {
83
+ exists: false,
84
+ ...(declaration.kind ? { kind: declaration.kind } : {}),
85
+ ...(declaration.media_type
86
+ ? { media_type: declaration.media_type }
87
+ : {}),
88
+ path: declaration.path,
89
+ ...(declaration.required !== undefined
90
+ ? { required: declaration.required }
91
+ : {}),
92
+ };
93
+ }
94
+ }
95
+ return Object.keys(manifest).length > 0 ? manifest : undefined;
96
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Async run process control primitives.
3
+ * Owns platform signal planning, owned-process signalling, and terminal control markers.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { join } from "node:path";
8
+
9
+ import { writeJsonAtomic } from "./file-state.ts";
10
+
11
+ export interface RunProcessSignalPlan {
12
+ args?: string[];
13
+ command?: string;
14
+ signalTarget: "processGroup" | "process" | "processTree";
15
+ }
16
+
17
+ export function getRunProcessSignalPlan(
18
+ pid: number,
19
+ signal: NodeJS.Signals,
20
+ runtimePlatform: NodeJS.Platform = process.platform,
21
+ ): RunProcessSignalPlan {
22
+ if (runtimePlatform === "win32") {
23
+ return {
24
+ args: [
25
+ "/PID",
26
+ String(pid),
27
+ "/T",
28
+ ...(signal === "SIGKILL" ? ["/F"] : []),
29
+ ],
30
+ command: "taskkill",
31
+ signalTarget: "processTree",
32
+ };
33
+ }
34
+ return { signalTarget: "processGroup" };
35
+ }
36
+
37
+ export function signalOwnedRunProcess(
38
+ pid: number,
39
+ signal: NodeJS.Signals,
40
+ ): RunProcessSignalPlan {
41
+ const plan = getRunProcessSignalPlan(pid, signal);
42
+ if (plan.command && plan.args) {
43
+ const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
44
+ if (result.status !== 0) {
45
+ throw new Error(
46
+ result.stderr?.trim() ||
47
+ result.stdout?.trim() ||
48
+ `${plan.command} failed`,
49
+ );
50
+ }
51
+ return plan;
52
+ }
53
+ try {
54
+ process.kill(-pid, signal);
55
+ return { signalTarget: "processGroup" };
56
+ } catch {
57
+ process.kill(pid, signal);
58
+ return { signalTarget: "process" };
59
+ }
60
+ }
61
+
62
+ export function markTerminalHandled(
63
+ stateDir: string,
64
+ details: Record<string, unknown>,
65
+ ): void {
66
+ writeJsonAtomic(join(stateDir, "terminal-handled.json"), {
67
+ ...details,
68
+ ts: new Date().toISOString(),
69
+ });
70
+ }
71
+
72
+ export function buildTerminalProgress(
73
+ existing: Record<string, unknown> | undefined,
74
+ phase: "cancelled" | "killed",
75
+ ): Record<string, unknown> {
76
+ const progress = existing ?? {};
77
+ const { activeSubagents: _activeSubagents, ...rest } = progress;
78
+ return {
79
+ ...rest,
80
+ completed: typeof progress.completed === "number" ? progress.completed : 0,
81
+ failures: Array.isArray(progress.failures) ? progress.failures : [],
82
+ phase,
83
+ updatedAt: new Date().toISOString(),
84
+ };
85
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Async run identity helpers.
3
+ * Owns run id normalization shared by run lifecycle and retention paths.
4
+ */
5
+
6
+ export function safeRunId(value: string | undefined): string {
7
+ const run = (value || `run-${Date.now()}`).trim();
8
+ if (!/^[A-Za-z0-9_.-]+$/.test(run)) {
9
+ throw new Error(
10
+ "Run id may contain only letters, numbers, dot, underscore, and dash.",
11
+ );
12
+ }
13
+ return run;
14
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Async run state index.
3
+ * Owns recursive run-state discovery, index rebuild/read, and status filtering.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+
9
+ import { writeJsonAtomic } from "./file-state.ts";
10
+
11
+ export interface RunStateIndexEntry {
12
+ ownerId?: string;
13
+ recipe?: string;
14
+ run: string;
15
+ state_dir: string;
16
+ status: string;
17
+ tool?: string;
18
+ updated_at?: string;
19
+ }
20
+
21
+ export type RunStatusReader = (runOrDir: string) => Record<string, unknown>;
22
+
23
+ function matchesStatusFilter(
24
+ status: unknown,
25
+ filter: string | undefined,
26
+ ): boolean {
27
+ if (!filter || filter === "all") return true;
28
+ if (filter === "active") return status === "running";
29
+ if (filter === "terminal") return status !== "running";
30
+ return status === filter;
31
+ }
32
+
33
+ export function listRunStateDirs(
34
+ stateRoot: string,
35
+ depth = 0,
36
+ seen = new Set<string>(),
37
+ ): string[] {
38
+ if (!existsSync(stateRoot) || seen.has(resolve(stateRoot))) return [];
39
+ seen.add(resolve(stateRoot));
40
+ let entries: import("node:fs").Dirent[];
41
+ try {
42
+ entries = readdirSync(stateRoot, { withFileTypes: true });
43
+ } catch {
44
+ return [];
45
+ }
46
+ const result: string[] = [];
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) continue;
49
+ const child = join(stateRoot, entry.name);
50
+ if (existsSync(join(child, "run.json"))) result.push(child);
51
+ if (depth + 1 < 8) result.push(...listRunStateDirs(child, depth + 1, seen));
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function runIndexPath(stateRoot: string): string {
57
+ return join(stateRoot, "index.json");
58
+ }
59
+
60
+ function indexEntryFromStatus(
61
+ status: Record<string, unknown>,
62
+ ): RunStateIndexEntry {
63
+ const progress =
64
+ status.progress && typeof status.progress === "object"
65
+ ? (status.progress as Record<string, unknown>)
66
+ : {};
67
+ return {
68
+ ...(typeof status.ownerId === "string" ? { ownerId: status.ownerId } : {}),
69
+ ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
70
+ run: String(status.run),
71
+ state_dir: String(status.state_dir),
72
+ status: String(status.status),
73
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
74
+ updated_at:
75
+ typeof progress.updatedAt === "string"
76
+ ? progress.updatedAt
77
+ : typeof status.createdAt === "string"
78
+ ? status.createdAt
79
+ : new Date(0).toISOString(),
80
+ };
81
+ }
82
+
83
+ export function rebuildRunStateIndex(
84
+ stateRoot: string,
85
+ getRunStatus: RunStatusReader,
86
+ ): RunStateIndexEntry[] {
87
+ mkdirSync(stateRoot, { recursive: true });
88
+ const entries = listRunStateDirs(stateRoot)
89
+ .flatMap((stateDir) => {
90
+ try {
91
+ return [indexEntryFromStatus(getRunStatus(stateDir))];
92
+ } catch {
93
+ return [];
94
+ }
95
+ })
96
+ .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
97
+ writeJsonAtomic(runIndexPath(stateRoot), {
98
+ entries,
99
+ rebuilt_at: new Date().toISOString(),
100
+ });
101
+ return entries;
102
+ }
103
+
104
+ export function readRunStateIndex(
105
+ stateRoot: string,
106
+ readJson: (path: string) => Record<string, unknown> | undefined,
107
+ ): RunStateIndexEntry[] | undefined {
108
+ const index = readJson(runIndexPath(stateRoot));
109
+ if (!index || typeof index !== "object") return undefined;
110
+ const entries = (index as Record<string, unknown>).entries;
111
+ if (!Array.isArray(entries)) return undefined;
112
+ const valid = entries.filter((entry): entry is RunStateIndexEntry => {
113
+ const record =
114
+ entry && typeof entry === "object"
115
+ ? (entry as Record<string, unknown>)
116
+ : {};
117
+ return (
118
+ typeof record.run === "string" &&
119
+ typeof record.state_dir === "string" &&
120
+ typeof record.status === "string"
121
+ );
122
+ });
123
+ if (valid.some((entry) => !existsSync(join(entry.state_dir, "run.json"))))
124
+ return undefined;
125
+ const indexedDirs = new Set(valid.map((entry) => resolve(entry.state_dir)));
126
+ const stateDirs = listRunStateDirs(stateRoot).map((stateDir) =>
127
+ resolve(stateDir),
128
+ );
129
+ if (stateDirs.some((stateDir) => !indexedDirs.has(stateDir)))
130
+ return undefined;
131
+ return valid;
132
+ }
133
+
134
+ function compactIndexEntry(entry: RunStateIndexEntry): Record<string, unknown> {
135
+ return {
136
+ run: entry.run,
137
+ state_dir: entry.state_dir,
138
+ status: entry.status,
139
+ ...(entry.tool ? { tool: entry.tool } : {}),
140
+ ...(entry.recipe ? { recipe: entry.recipe } : {}),
141
+ };
142
+ }
143
+
144
+ export function listRuns(
145
+ stateRoot: string,
146
+ getRunStatus: RunStatusReader,
147
+ readJson: (path: string) => Record<string, unknown> | undefined,
148
+ statusFilter?: string,
149
+ ): Array<Record<string, unknown>> {
150
+ if (!existsSync(stateRoot)) return [];
151
+ const indexed = readRunStateIndex(stateRoot, readJson);
152
+ const entries = indexed ?? rebuildRunStateIndex(stateRoot, getRunStatus);
153
+ return entries
154
+ .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
155
+ .map(compactIndexEntry);
156
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Async run mailbox state.
3
+ * Owns durable run inbox records, claim locks, and handler status transitions.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { readJsonlFileResilient } from "./state-readers.ts";
11
+
12
+ const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
13
+ const STALE_INBOX_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
14
+
15
+ export type RunInboxStatus =
16
+ | "queued"
17
+ | "sent"
18
+ | "claimed"
19
+ | "handled"
20
+ | "failed";
21
+
22
+ export type RunInboxMessage = Record<string, unknown> & {
23
+ id?: string;
24
+ status?: RunInboxStatus | string;
25
+ };
26
+
27
+ export interface ProcessRunInboxResult {
28
+ claimed: number;
29
+ failed: number;
30
+ handled: number;
31
+ }
32
+
33
+ export function runInboxFile(stateDir: string): string {
34
+ return join(stateDir, "inbox.jsonl");
35
+ }
36
+
37
+ export function parseRunInboxLine(line: string): RunInboxMessage | undefined {
38
+ try {
39
+ return JSON.parse(line) as RunInboxMessage;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ export function readRunInboxMessagesFromStateDir(
46
+ stateDir: string,
47
+ ): RunInboxMessage[] {
48
+ return readJsonlFileResilient<RunInboxMessage>(runInboxFile(stateDir))
49
+ .records;
50
+ }
51
+
52
+ function sleepSync(ms: number): void {
53
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
54
+ }
55
+
56
+ function acquireRunInboxLock(stateDir: string): () => void {
57
+ const lockDir = join(stateDir, ".inbox.lock");
58
+ const started = Date.now();
59
+ while (true) {
60
+ try {
61
+ mkdirSync(lockDir, { recursive: false });
62
+ writeFileSync(
63
+ join(lockDir, "owner.json"),
64
+ `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
65
+ "utf8",
66
+ );
67
+ return () => rmSync(lockDir, { recursive: true, force: true });
68
+ } catch (error) {
69
+ try {
70
+ const stat = statSync(lockDir);
71
+ if (Date.now() - stat.mtimeMs > STALE_INBOX_LOCK_MAX_AGE_MS) {
72
+ rmSync(lockDir, { recursive: true, force: true });
73
+ continue;
74
+ }
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
79
+ throw new Error("Run inbox lock timed out.", { cause: error });
80
+ }
81
+ sleepSync(10);
82
+ }
83
+ }
84
+ }
85
+
86
+ function writeRunInboxMessages(
87
+ stateDir: string,
88
+ messages: RunInboxMessage[],
89
+ ): void {
90
+ writeFileSync(
91
+ runInboxFile(stateDir),
92
+ messages.length
93
+ ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
94
+ : "",
95
+ "utf8",
96
+ );
97
+ }
98
+
99
+ export function updateRunInboxMessageStatusInStateDir(
100
+ stateDir: string,
101
+ id: string,
102
+ nextStatus: RunInboxStatus,
103
+ metadata: Record<string, unknown> = {},
104
+ ): boolean {
105
+ const releaseLock = acquireRunInboxLock(stateDir);
106
+ try {
107
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
108
+ const timestampKey = `${nextStatus}_at`;
109
+ let changed = false;
110
+ const updated = messages.map((message) => {
111
+ if (message.id !== id) return message;
112
+ changed = true;
113
+ return {
114
+ ...message,
115
+ ...metadata,
116
+ [timestampKey]: new Date().toISOString(),
117
+ status: nextStatus,
118
+ };
119
+ });
120
+ if (changed) writeRunInboxMessages(stateDir, updated);
121
+ return changed;
122
+ } finally {
123
+ releaseLock();
124
+ }
125
+ }
126
+
127
+ export function claimRunInboxMessageInStateDir(
128
+ stateDir: string,
129
+ owner = "runtime",
130
+ statuses: string[] = ["queued"],
131
+ ): RunInboxMessage | undefined {
132
+ const releaseLock = acquireRunInboxLock(stateDir);
133
+ try {
134
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
135
+ const index = messages.findIndex((message) =>
136
+ statuses.includes(String(message.status ?? "queued")),
137
+ );
138
+ if (index < 0) return undefined;
139
+ const claimed = {
140
+ ...messages[index],
141
+ claimed_at: new Date().toISOString(),
142
+ claimed_by: owner,
143
+ id:
144
+ typeof messages[index].id === "string"
145
+ ? messages[index].id
146
+ : randomUUID(),
147
+ status: "claimed",
148
+ } satisfies RunInboxMessage;
149
+ messages[index] = claimed;
150
+ writeRunInboxMessages(stateDir, messages);
151
+ return claimed;
152
+ } finally {
153
+ releaseLock();
154
+ }
155
+ }
156
+
157
+ export function appendRunInboxMessageInStateDir(
158
+ stateDir: string,
159
+ record: Record<string, unknown>,
160
+ ): string {
161
+ const id = randomUUID();
162
+ const ts = new Date().toISOString();
163
+ const releaseLock = acquireRunInboxLock(stateDir);
164
+ try {
165
+ writeFileSync(
166
+ runInboxFile(stateDir),
167
+ `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
168
+ { flag: "a" },
169
+ );
170
+ } finally {
171
+ releaseLock();
172
+ }
173
+ return id;
174
+ }
175
+
176
+ export async function processRunInboxMessagesInStateDir(
177
+ stateDir: string,
178
+ handler: (message: RunInboxMessage) => Promise<void> | void,
179
+ options: { limit?: number; owner?: string; statuses?: string[] } = {},
180
+ ): Promise<ProcessRunInboxResult> {
181
+ const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
182
+ const limit = Math.max(1, Number(options.limit ?? 1));
183
+ const owner = options.owner ?? "runtime";
184
+ for (let index = 0; index < limit; index += 1) {
185
+ const message = claimRunInboxMessageInStateDir(
186
+ stateDir,
187
+ owner,
188
+ options.statuses,
189
+ );
190
+ if (!message?.id) break;
191
+ result.claimed += 1;
192
+ try {
193
+ await handler(message);
194
+ if (updateRunInboxMessageStatusInStateDir(stateDir, message.id, "handled")) {
195
+ result.handled += 1;
196
+ }
197
+ } catch (error) {
198
+ if (
199
+ updateRunInboxMessageStatusInStateDir(stateDir, message.id, "failed", {
200
+ error: error instanceof Error ? error.message : String(error),
201
+ })
202
+ ) {
203
+ result.failed += 1;
204
+ }
205
+ }
206
+ }
207
+ return result;
208
+ }