@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.
- package/AGENTS.md +23 -11
- package/BACKLOG.md +2 -30
- package/CHANGELOG.md +30 -1
- package/dist/index.js +10 -10
- package/dist/lib/async-runs.d.ts +23 -75
- package/dist/lib/async-runs.js +49 -801
- package/dist/lib/command-templates.d.ts +3 -3
- package/dist/lib/command-templates.js +3 -3
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +5 -5
- package/dist/lib/{output.d.ts → execution-output.d.ts} +3 -3
- package/dist/lib/{output.js → execution-output.js} +3 -3
- package/dist/lib/execution.js +1 -1
- package/dist/lib/{actor-inspector-tui.d.ts → inspector.d.ts} +3 -2
- package/dist/lib/{actor-inspector-tui.js → inspector.js} +3 -2
- package/dist/lib/mailbox-loop.d.ts +3 -3
- package/dist/lib/mailbox-loop.js +3 -3
- package/dist/lib/{actor-messages.d.ts → messages.d.ts} +3 -3
- package/dist/lib/{actor-messages.js → messages.js} +3 -3
- package/dist/lib/paths.d.ts +1 -1
- package/dist/lib/paths.js +3 -3
- package/dist/lib/{actor-recipe-context.d.ts → recipes-context.d.ts} +3 -2
- package/dist/lib/{actor-recipe-context.js → recipes-context.js} +2 -1
- package/dist/lib/{recipe-discovery.d.ts → recipes-discovery.d.ts} +9 -9
- package/dist/lib/{recipe-discovery.js → recipes-discovery.js} +17 -17
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +22 -22
- package/dist/lib/{actor-rooms.d.ts → rooms.d.ts} +4 -4
- package/dist/lib/{actor-rooms.js → rooms.js} +3 -3
- package/dist/lib/runs-artifacts.d.ts +21 -0
- package/dist/lib/runs-artifacts.js +68 -0
- package/dist/lib/runs-control.d.ts +13 -0
- package/dist/lib/runs-control.js +59 -0
- package/dist/lib/runs-identity.d.ts +5 -0
- package/dist/lib/runs-identity.js +11 -0
- package/dist/lib/runs-index.d.ts +18 -0
- package/dist/lib/runs-index.js +119 -0
- package/dist/lib/runs-mailbox.d.ts +25 -0
- package/dist/lib/runs-mailbox.js +146 -0
- package/dist/lib/runs-messages.d.ts +15 -0
- package/dist/lib/runs-messages.js +179 -0
- package/dist/lib/runs-outbox.d.ts +41 -0
- package/dist/lib/runs-outbox.js +87 -0
- package/dist/lib/runs-process.d.ts +7 -0
- package/dist/lib/runs-process.js +36 -0
- package/dist/lib/runs-retention.d.ts +8 -0
- package/dist/lib/runs-retention.js +56 -0
- package/dist/lib/runs-start.d.ts +9 -0
- package/dist/lib/runs-start.js +67 -0
- package/dist/lib/runs-status.d.ts +8 -0
- package/dist/lib/runs-status.js +60 -0
- package/dist/lib/runtime.d.ts +2 -2
- package/dist/lib/runtime.js +5 -5
- package/dist/lib/schema.d.ts +9 -0
- package/dist/lib/schema.js +40 -0
- package/dist/lib/tools-access.d.ts +19 -0
- package/dist/lib/tools-access.js +44 -0
- package/dist/lib/tools-inspect.d.ts +11 -0
- package/dist/lib/tools-inspect.js +534 -0
- package/dist/lib/tools-local.d.ts +14 -0
- package/dist/lib/tools-local.js +156 -0
- package/dist/lib/tools-mailbox.d.ts +8 -0
- package/dist/lib/tools-mailbox.js +48 -0
- package/dist/lib/tools-message.d.ts +9 -0
- package/dist/lib/tools-message.js +346 -0
- package/dist/lib/tools-register.d.ts +18 -0
- package/dist/lib/tools-register.js +40 -0
- package/dist/lib/tools-response.d.ts +21 -0
- package/dist/lib/tools-response.js +219 -0
- package/dist/lib/tools-spawn.d.ts +12 -0
- package/dist/lib/tools-spawn.js +159 -0
- package/dist/lib/tools.d.ts +13 -39
- package/dist/lib/tools.js +11 -1497
- package/dist/recipes/utility-coordinator-lock-snapshot.json +1 -1
- package/dist/scripts/actor-worker.mjs +186 -7
- package/dist/scripts/async-runner.mjs +171 -8
- package/dist/scripts/build-dist.mjs +1 -1
- package/dist/scripts/conformance.mjs +34 -18
- package/dist/scripts/coordinator.mjs +733 -11
- package/dist/scripts/locker.mjs +345 -16
- package/dist/scripts/recipe-utils.mjs +478 -14
- package/dist/scripts/validate-recipe.mjs +110 -10
- package/dist/skills/actors/SKILL.md +2 -2
- package/dist/skills/swarm/SKILL.md +2 -2
- package/docs/recipe-library.md +1 -1
- package/docs/template-recipes.md +1 -1
- package/docs/tool-registry.md +3 -3
- package/index.ts +12 -19
- package/lib/async-runs.ts +124 -1016
- package/lib/command-templates.ts +3 -3
- package/lib/config.ts +7 -7
- package/lib/{output.ts → execution-output.ts} +3 -3
- package/lib/execution.ts +5 -1
- package/lib/{actor-inspector-tui.ts → inspector.ts} +4 -3
- package/lib/mailbox-loop.ts +3 -3
- package/lib/{actor-messages.ts → messages.ts} +3 -3
- package/lib/paths.ts +3 -3
- package/lib/{actor-recipe-context.ts → recipes-context.ts} +3 -2
- package/lib/{recipe-discovery.ts → recipes-discovery.ts} +30 -30
- package/lib/{recipe-usage.ts → recipes-usage.ts} +2 -2
- package/lib/registry.ts +37 -24
- package/lib/{actor-rooms.ts → rooms.ts} +4 -4
- package/lib/runs-artifacts.ts +96 -0
- package/lib/runs-control.ts +85 -0
- package/lib/runs-identity.ts +14 -0
- package/lib/runs-index.ts +156 -0
- package/lib/runs-mailbox.ts +208 -0
- package/lib/runs-messages.ts +252 -0
- package/lib/runs-outbox.ts +144 -0
- package/lib/runs-process.ts +51 -0
- package/lib/runs-retention.ts +77 -0
- package/lib/runs-start.ts +98 -0
- package/lib/runs-status.ts +78 -0
- package/lib/runtime.ts +6 -6
- package/lib/schema.ts +55 -0
- package/lib/tools-access.ts +68 -0
- package/lib/tools-inspect.ts +735 -0
- package/lib/tools-local.ts +234 -0
- package/lib/tools-mailbox.ts +56 -0
- package/lib/tools-message.ts +483 -0
- package/lib/tools-register.ts +64 -0
- package/lib/tools-response.ts +267 -0
- package/lib/tools-spawn.ts +240 -0
- package/lib/tools.ts +26 -2051
- package/package.json +1 -1
- package/recipes/utility-coordinator-lock-snapshot.json +1 -1
- package/scripts/actor-worker.mjs +186 -7
- package/scripts/async-runner.mjs +171 -8
- package/scripts/build-dist.mjs +1 -1
- package/scripts/conformance.mjs +34 -18
- package/scripts/coordinator.mjs +733 -11
- package/scripts/locker.mjs +345 -16
- package/scripts/recipe-utils.mjs +478 -14
- package/scripts/validate-recipe.mjs +110 -10
- package/skills/actors/SKILL.md +2 -2
- package/skills/swarm/SKILL.md +2 -2
- package/dist/lib/actor-worker.d.ts +0 -16
- package/dist/lib/actor-worker.js +0 -134
- package/dist/lib/async-runner.d.ts +0 -5
- package/dist/lib/async-runner.js +0 -134
- package/dist/lib/conformance.d.ts +0 -12
- package/dist/lib/conformance.js +0 -28
- package/dist/lib/coordinator.d.ts +0 -5
- package/dist/lib/coordinator.js +0 -660
- package/dist/lib/locker.d.ts +0 -5
- package/dist/lib/locker.js +0 -310
- package/dist/lib/recipe-utils.d.ts +0 -5
- package/dist/lib/recipe-utils.js +0 -385
- package/dist/lib/validate-recipe.d.ts +0 -6
- package/dist/lib/validate-recipe.js +0 -104
- package/lib/actor-worker.ts +0 -174
- package/lib/async-runner.ts +0 -173
- package/lib/conformance.ts +0 -46
- package/lib/coordinator.ts +0 -742
- package/lib/locker.ts +0 -340
- package/lib/recipe-utils.ts +0 -486
- package/lib/validate-recipe.ts +0 -110
- /package/dist/lib/{recipe-references.d.ts → recipes-references.d.ts} +0 -0
- /package/dist/lib/{recipe-references.js → recipes-references.js} +0 -0
- /package/dist/lib/{recipe-usage.d.ts → recipes-usage.d.ts} +0 -0
- /package/dist/lib/{recipe-usage.js → recipes-usage.js} +0 -0
- /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
|
+
}
|