@llblab/pi-actors 0.39.0 → 0.40.1
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 +10 -3
- package/BACKLOG.md +4 -11
- package/CHANGELOG.md +51 -0
- package/README.md +5 -5
- package/dist/index.js +6 -4
- package/dist/lib/async-runs.d.ts +4 -0
- package/dist/lib/async-runs.js +112 -17
- package/dist/lib/command-templates.d.ts +9 -0
- package/dist/lib/command-templates.js +92 -11
- package/dist/lib/config.js +0 -5
- package/dist/lib/execution.d.ts +31 -0
- package/dist/lib/execution.js +145 -12
- package/dist/lib/file-state.d.ts +1 -0
- package/dist/lib/file-state.js +91 -3
- package/dist/lib/observability.js +5 -2
- package/dist/lib/prompts.d.ts +1 -2
- package/dist/lib/prompts.js +3 -4
- package/dist/lib/recipes-context.js +17 -9
- package/dist/lib/recipes-discovery.js +11 -5
- package/dist/lib/recipes-references.d.ts +1 -1
- package/dist/lib/recipes-references.js +4 -5
- package/dist/lib/recipes-usage.d.ts +2 -0
- package/dist/lib/recipes-usage.js +35 -21
- package/dist/lib/registry.d.ts +0 -2
- package/dist/lib/registry.js +33 -10
- package/dist/lib/runs-ownership.d.ts +7 -0
- package/dist/lib/runs-ownership.js +82 -0
- package/dist/lib/runs-process.d.ts +17 -2
- package/dist/lib/runs-process.js +99 -11
- package/dist/lib/runs-retention.d.ts +3 -0
- package/dist/lib/runs-retention.js +18 -3
- package/dist/lib/runs-start.d.ts +2 -2
- package/dist/lib/runs-start.js +51 -17
- package/dist/lib/runs-status.d.ts +1 -1
- package/dist/lib/runs-status.js +8 -6
- package/dist/lib/runtime.js +69 -13
- package/dist/lib/tools-inspect.d.ts +2 -0
- package/dist/lib/tools-inspect.js +39 -2
- package/dist/lib/tools-register.js +0 -1
- package/dist/lib/tools-spawn.js +3 -2
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +3 -0
- package/dist/pi-actors/index.js +1 -0
- package/dist/recipes/subagent-judge.json +2 -1
- package/dist/recipes/subagent-merge.json +2 -1
- package/dist/recipes/subagent-normalize.json +2 -1
- package/dist/recipes/subagent-review-coordinator.json +1 -1
- package/dist/recipes/subagent-review.json +2 -1
- package/dist/recipes/subagent-verify.json +2 -1
- package/dist/scripts/async-runner.mjs +274 -6
- package/dist/scripts/build-dist.mjs +14 -1
- package/dist/skills/actors/SKILL.md +15 -8
- package/dist/skills/swarm/SKILL.md +4 -2
- package/docs/actor-messages.md +1 -1
- package/docs/async-runs.md +14 -5
- package/docs/command-templates.md +4 -2
- package/docs/recipe-library.md +1 -0
- package/docs/template-recipes.md +5 -7
- package/docs/tool-registry.md +4 -2
- package/index.ts +18 -7
- package/lib/async-runs.ts +138 -19
- package/lib/command-templates.ts +132 -13
- package/lib/config.ts +0 -4
- package/lib/execution.ts +198 -13
- package/lib/file-state.ts +106 -3
- package/lib/observability.ts +8 -2
- package/lib/prompts.ts +3 -5
- package/lib/recipes-context.ts +17 -9
- package/lib/recipes-discovery.ts +10 -5
- package/lib/recipes-references.ts +5 -6
- package/lib/recipes-usage.ts +36 -20
- package/lib/registry.ts +43 -13
- package/lib/runs-ownership.ts +117 -0
- package/lib/runs-process.ts +138 -16
- package/lib/runs-retention.ts +22 -2
- package/lib/runs-start.ts +89 -31
- package/lib/runs-status.ts +15 -6
- package/lib/runtime.ts +64 -12
- package/lib/tools-inspect.ts +46 -4
- package/lib/tools-register.ts +0 -3
- package/lib/tools-spawn.ts +5 -5
- package/lib/tools.ts +8 -0
- package/package.json +2 -2
- package/recipes/subagent-judge.json +2 -1
- package/recipes/subagent-merge.json +2 -1
- package/recipes/subagent-normalize.json +2 -1
- package/recipes/subagent-review-coordinator.json +1 -1
- package/recipes/subagent-review.json +2 -1
- package/recipes/subagent-verify.json +2 -1
- package/scripts/async-runner.mjs +274 -6
- package/scripts/build-dist.mjs +14 -1
- package/skills/actors/SKILL.md +15 -8
- package/skills/swarm/SKILL.md +4 -2
package/lib/recipes-usage.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
9
10
|
|
|
10
|
-
import { writeJsonAtomic } from "./file-state.ts";
|
|
11
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
|
|
11
12
|
|
|
12
13
|
interface RecipesUsageRecord {
|
|
13
14
|
calls?: number;
|
|
@@ -47,6 +48,19 @@ function getRecipeFingerprint(raw: Record<string, unknown>): string {
|
|
|
47
48
|
|
|
48
49
|
export type RecipeLaunchKind = "direct" | "spawn" | "tool";
|
|
49
50
|
|
|
51
|
+
export function getRecipeUsagePath(path: string): string {
|
|
52
|
+
return join(dirname(path), ".usage", `${basename(path)}.json`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readRecipeUsage(path: string): Record<string, unknown> | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(readFileSync(getRecipeUsagePath(path), "utf8"));
|
|
58
|
+
return isRecord(value) ? value : undefined;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
function launchCounterKey(
|
|
51
65
|
kind: RecipeLaunchKind,
|
|
52
66
|
): "direct_calls" | "spawn_calls" | "tool_calls" {
|
|
@@ -59,19 +73,21 @@ export function recordRecipeLaunch(
|
|
|
59
73
|
kind: RecipeLaunchKind = "direct",
|
|
60
74
|
): boolean {
|
|
61
75
|
if (!existsSync(path)) return false;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
const usagePath = getRecipeUsagePath(path);
|
|
77
|
+
return withFileMutationLock(usagePath, () => {
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
80
|
+
if (!isRecord(raw)) return false;
|
|
81
|
+
const storedUsage = readRecipeUsage(path);
|
|
82
|
+
const usage: RecipesUsageRecord =
|
|
83
|
+
storedUsage ?? (isRecord(raw.usage) ? raw.usage : {});
|
|
84
|
+
const fingerprint = getRecipeFingerprint(raw);
|
|
85
|
+
const changed =
|
|
86
|
+
typeof usage.fingerprint === "string" &&
|
|
87
|
+
usage.fingerprint !== fingerprint;
|
|
88
|
+
const nowIso = now.toISOString();
|
|
89
|
+
const counterKey = launchCounterKey(kind);
|
|
90
|
+
writeJsonAtomic(usagePath, {
|
|
75
91
|
...usage,
|
|
76
92
|
calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
|
|
77
93
|
[counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
|
|
@@ -84,10 +100,10 @@ export function recordRecipeLaunch(
|
|
|
84
100
|
reset_reason: "recipe content fingerprint changed",
|
|
85
101
|
}
|
|
86
102
|
: {}),
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
103
|
+
});
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
93
109
|
}
|
package/lib/registry.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Owns register/update/delete validation, persistence, runtime side effects, and result payloads
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { dirname, join, relative, resolve } from "node:path";
|
|
9
9
|
|
|
10
10
|
import * as CommandTemplates from "./command-templates.ts";
|
|
11
11
|
import * as Config from "./config.ts";
|
|
12
12
|
import * as ExecutionOutput from "./execution-output.ts";
|
|
13
|
-
import { writeJsonAtomic } from "./file-state.ts";
|
|
13
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
|
|
14
14
|
import * as Identity from "./identity.ts";
|
|
15
15
|
import * as Paths from "./paths.ts";
|
|
16
16
|
import * as RecipesReferences from "./recipes-references.ts";
|
|
@@ -20,7 +20,6 @@ export interface RegisterToolInput {
|
|
|
20
20
|
name?: string;
|
|
21
21
|
description?: string;
|
|
22
22
|
async?: boolean;
|
|
23
|
-
state_dir?: string;
|
|
24
23
|
template?: CommandTemplates.CommandTemplateValue | null;
|
|
25
24
|
draft?: string;
|
|
26
25
|
args?: string;
|
|
@@ -36,7 +35,6 @@ export interface RegisterToolResultDetails {
|
|
|
36
35
|
draft?: string;
|
|
37
36
|
promoted?: boolean;
|
|
38
37
|
recipeName?: string;
|
|
39
|
-
state_dir?: string;
|
|
40
38
|
template?: CommandTemplates.CommandTemplateValue;
|
|
41
39
|
templateWarnings?: string[];
|
|
42
40
|
tool: string;
|
|
@@ -194,7 +192,6 @@ function persistToolRecipe<TContext>(
|
|
|
194
192
|
writeJsonAtomic(path, {
|
|
195
193
|
description: cfg.description,
|
|
196
194
|
...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
|
|
197
|
-
...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
|
|
198
195
|
...(cfg.storedArgs ? { args: cfg.storedArgs } : {}),
|
|
199
196
|
...(cfg.storedDefaults ? { defaults: cfg.storedDefaults } : {}),
|
|
200
197
|
...(cfg.recipe?.values ? { values: cfg.recipe.values } : {}),
|
|
@@ -238,6 +235,28 @@ function deleteTool<TContext>(
|
|
|
238
235
|
};
|
|
239
236
|
}
|
|
240
237
|
|
|
238
|
+
function readAuthoritativeStoredTool<TContext>(
|
|
239
|
+
deps: RegisterToolRuntimeDeps<TContext>,
|
|
240
|
+
name: string,
|
|
241
|
+
): { exists: boolean; tool?: Config.RegisteredTool } {
|
|
242
|
+
const path = getToolRecipePath(deps, name);
|
|
243
|
+
if (!existsSync(path)) return { exists: false };
|
|
244
|
+
try {
|
|
245
|
+
const normalized = Config.normalizeStoredTool(
|
|
246
|
+
name,
|
|
247
|
+
JSON.parse(readFileSync(path, "utf8")),
|
|
248
|
+
deps.reservedToolNames,
|
|
249
|
+
);
|
|
250
|
+
if (!normalized.cfg) return { exists: true };
|
|
251
|
+
return {
|
|
252
|
+
exists: true,
|
|
253
|
+
tool: { ...normalized.cfg, sourcePath: path },
|
|
254
|
+
};
|
|
255
|
+
} catch {
|
|
256
|
+
return { exists: true };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
241
260
|
function getInputTemplate(
|
|
242
261
|
value: CommandTemplates.CommandTemplateValue | null | undefined,
|
|
243
262
|
): CommandTemplates.CommandTemplateValue | null | undefined {
|
|
@@ -303,9 +322,6 @@ function buildConfig(
|
|
|
303
322
|
? {
|
|
304
323
|
name: inputRecipe,
|
|
305
324
|
...(typeof input.async === "boolean" ? { async: input.async } : {}),
|
|
306
|
-
...(typeof input.state_dir === "string" && input.state_dir.trim()
|
|
307
|
-
? { state_dir: input.state_dir.trim() }
|
|
308
|
-
: {}),
|
|
309
325
|
template: finalTemplate,
|
|
310
326
|
...(input.values && typeof input.values === "object"
|
|
311
327
|
? { values: input.values }
|
|
@@ -359,11 +375,11 @@ function buildConfig(
|
|
|
359
375
|
};
|
|
360
376
|
}
|
|
361
377
|
|
|
362
|
-
|
|
378
|
+
function executeRegisterToolUnlocked<TContext>(
|
|
363
379
|
params: unknown,
|
|
364
380
|
ctx: TContext,
|
|
365
381
|
deps: RegisterToolRuntimeDeps<TContext>,
|
|
366
|
-
):
|
|
382
|
+
): RegisterToolResult {
|
|
367
383
|
const input = params as RegisterToolInput;
|
|
368
384
|
if (!input.name) return listTools(deps);
|
|
369
385
|
const name = Identity.normalizeToolName(input.name);
|
|
@@ -382,10 +398,11 @@ export async function executeRegisterTool<TContext>(
|
|
|
382
398
|
if (templateProvided && (template === null || template === ""))
|
|
383
399
|
return deleteTool(name, ctx, deps);
|
|
384
400
|
const tools = deps.getTools();
|
|
385
|
-
const
|
|
401
|
+
const authoritative = readAuthoritativeStoredTool(deps, name);
|
|
402
|
+
const existing = authoritative.tool ?? tools.get(name);
|
|
386
403
|
const blocker = deps.getToolNameBlocker(name);
|
|
387
404
|
if (blocker) throw new Error(ExecutionOutput.formatToolText(blocker));
|
|
388
|
-
if (existing && !input.update) {
|
|
405
|
+
if ((authoritative.exists || existing) && !input.update) {
|
|
389
406
|
throw new Error(
|
|
390
407
|
ExecutionOutput.formatToolText(
|
|
391
408
|
`Tool "${name}" already registered. Use update=true to overwrite.`,
|
|
@@ -437,10 +454,23 @@ export async function executeRegisterTool<TContext>(
|
|
|
437
454
|
defaults: cfg.defaults,
|
|
438
455
|
...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
|
|
439
456
|
...(cfg.recipe?.name ? { recipeName: cfg.recipe.name } : {}),
|
|
440
|
-
...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
|
|
441
457
|
...(cfg.template ? { template: cfg.template } : {}),
|
|
442
458
|
...(templateWarnings.length > 0 ? { templateWarnings } : {}),
|
|
443
459
|
tool: name,
|
|
444
460
|
},
|
|
445
461
|
};
|
|
446
462
|
}
|
|
463
|
+
|
|
464
|
+
export async function executeRegisterTool<TContext>(
|
|
465
|
+
params: unknown,
|
|
466
|
+
ctx: TContext,
|
|
467
|
+
deps: RegisterToolRuntimeDeps<TContext>,
|
|
468
|
+
): Promise<RegisterToolResult> {
|
|
469
|
+
const input = params as RegisterToolInput;
|
|
470
|
+
if (!input.name) return executeRegisterToolUnlocked(params, ctx, deps);
|
|
471
|
+
const name = Identity.normalizeToolName(input.name);
|
|
472
|
+
if (!name) return executeRegisterToolUnlocked(params, ctx, deps);
|
|
473
|
+
return withFileMutationLock(getToolRecipePath(deps, name), () =>
|
|
474
|
+
executeRegisterToolUnlocked(params, ctx, deps),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async run state-directory ownership.
|
|
3
|
+
* Owns the marker proof required before launch and destructive retention.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { writeJsonAtomic } from "./file-state.ts";
|
|
18
|
+
|
|
19
|
+
export const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
|
|
20
|
+
|
|
21
|
+
interface RunStateOwnershipMarker {
|
|
22
|
+
created_at: string;
|
|
23
|
+
ownership_token: string;
|
|
24
|
+
run: string;
|
|
25
|
+
state_dir: string;
|
|
26
|
+
version: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markerPath(stateDir: string): string {
|
|
30
|
+
return join(stateDir, RUN_STATE_OWNERSHIP_FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function comparablePath(path: string): string {
|
|
34
|
+
const resolved = resolve(path);
|
|
35
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assertCanonicalDirectory(stateDir: string): string {
|
|
39
|
+
const resolved = resolve(stateDir);
|
|
40
|
+
if (lstatSync(resolved).isSymbolicLink()) {
|
|
41
|
+
throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
|
|
42
|
+
}
|
|
43
|
+
const canonical = realpathSync(resolved);
|
|
44
|
+
if (comparablePath(canonical) !== comparablePath(resolved)) {
|
|
45
|
+
throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
|
|
46
|
+
}
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readMarker(stateDir: string): RunStateOwnershipMarker | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(readFileSync(markerPath(stateDir), "utf8")) as Partial<RunStateOwnershipMarker>;
|
|
53
|
+
if (
|
|
54
|
+
value.version !== 1 ||
|
|
55
|
+
typeof value.run !== "string" ||
|
|
56
|
+
typeof value.state_dir !== "string" ||
|
|
57
|
+
typeof value.ownership_token !== "string" ||
|
|
58
|
+
!value.ownership_token
|
|
59
|
+
) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return value as RunStateOwnershipMarker;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertMarkerMatches(
|
|
69
|
+
marker: RunStateOwnershipMarker | undefined,
|
|
70
|
+
stateDir: string,
|
|
71
|
+
run: string,
|
|
72
|
+
): RunStateOwnershipMarker {
|
|
73
|
+
if (!marker) {
|
|
74
|
+
throw new Error(`Run state ownership marker is missing or invalid: ${stateDir}`);
|
|
75
|
+
}
|
|
76
|
+
if (marker.state_dir !== stateDir || marker.run !== run) {
|
|
77
|
+
throw new Error(`Run state ownership marker does not match run ${run}: ${stateDir}`);
|
|
78
|
+
}
|
|
79
|
+
return marker;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function claimRunStateDirectory(stateDir: string, run: string): string {
|
|
83
|
+
const resolved = resolve(stateDir);
|
|
84
|
+
if (existsSync(resolved) && lstatSync(resolved).isSymbolicLink()) {
|
|
85
|
+
throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
|
|
86
|
+
}
|
|
87
|
+
mkdirSync(resolved, { recursive: true });
|
|
88
|
+
const canonical = assertCanonicalDirectory(resolved);
|
|
89
|
+
const existing = readMarker(canonical);
|
|
90
|
+
if (existing) {
|
|
91
|
+
assertMarkerMatches(existing, canonical, run);
|
|
92
|
+
return canonical;
|
|
93
|
+
}
|
|
94
|
+
const existingEntries = readdirSync(canonical).filter(
|
|
95
|
+
(entry) => entry !== ".start.lock",
|
|
96
|
+
);
|
|
97
|
+
if (existingEntries.length > 0) {
|
|
98
|
+
throw new Error(`Refusing to claim existing non-run directory: ${canonical}`);
|
|
99
|
+
}
|
|
100
|
+
writeJsonAtomic(markerPath(canonical), {
|
|
101
|
+
created_at: new Date().toISOString(),
|
|
102
|
+
ownership_token: randomUUID(),
|
|
103
|
+
run,
|
|
104
|
+
state_dir: canonical,
|
|
105
|
+
version: 1,
|
|
106
|
+
} satisfies RunStateOwnershipMarker);
|
|
107
|
+
return canonical;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function assertOwnedRunStateDirectory(
|
|
111
|
+
stateDir: string,
|
|
112
|
+
run: string,
|
|
113
|
+
): string {
|
|
114
|
+
const canonical = assertCanonicalDirectory(stateDir);
|
|
115
|
+
assertMarkerMatches(readMarker(canonical), canonical, run);
|
|
116
|
+
return canonical;
|
|
117
|
+
}
|
package/lib/runs-process.ts
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Async run process identity helpers.
|
|
3
|
-
* Owns liveness
|
|
3
|
+
* Owns cross-platform liveness and stable runner process identity proofs.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
7
8
|
import { platform } from "node:os";
|
|
8
9
|
import { resolve } from "node:path";
|
|
9
10
|
|
|
11
|
+
export interface RunProcessIdentity {
|
|
12
|
+
command: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
platform: NodeJS.Platform;
|
|
15
|
+
start_time: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type RunProcessIdentityStatus =
|
|
19
|
+
| "valid"
|
|
20
|
+
| "dead_pid"
|
|
21
|
+
| "owner_mismatch"
|
|
22
|
+
| "unsupported_proof";
|
|
23
|
+
|
|
24
|
+
export interface RunProcessIdentityResult {
|
|
25
|
+
status: RunProcessIdentityStatus;
|
|
26
|
+
valid: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ProcessIdentityReader = (
|
|
30
|
+
pid: number,
|
|
31
|
+
runtimePlatform: NodeJS.Platform,
|
|
32
|
+
) => RunProcessIdentity | undefined;
|
|
33
|
+
|
|
10
34
|
export function isAlive(pid: number): boolean {
|
|
11
35
|
try {
|
|
12
36
|
process.kill(pid, 0);
|
|
@@ -16,25 +40,123 @@ export function isAlive(pid: number): boolean {
|
|
|
16
40
|
}
|
|
17
41
|
}
|
|
18
42
|
|
|
19
|
-
|
|
43
|
+
function readLinuxIdentity(pid: number): RunProcessIdentity | undefined {
|
|
44
|
+
if (!existsSync(`/proc/${pid}`)) return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
47
|
+
const close = stat.lastIndexOf(")");
|
|
48
|
+
const fields = stat.slice(close + 2).trim().split(/\s+/);
|
|
49
|
+
const startTime = fields[19];
|
|
50
|
+
if (!startTime) return undefined;
|
|
51
|
+
return {
|
|
52
|
+
command: readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ").trim(),
|
|
53
|
+
cwd: readlinkSync(`/proc/${pid}/cwd`),
|
|
54
|
+
platform: "linux",
|
|
55
|
+
start_time: startTime,
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readDarwinIdentity(pid: number): RunProcessIdentity | undefined {
|
|
63
|
+
const result = spawnSync(
|
|
64
|
+
"ps",
|
|
65
|
+
["-p", String(pid), "-o", "lstart=", "-o", "command="],
|
|
66
|
+
{ encoding: "utf8" },
|
|
67
|
+
);
|
|
68
|
+
if (result.status !== 0) return undefined;
|
|
69
|
+
const match = result.stdout.trim().match(/^(.{24})\s+(.+)$/s);
|
|
70
|
+
if (!match) return undefined;
|
|
71
|
+
return {
|
|
72
|
+
command: match[2].trim(),
|
|
73
|
+
platform: "darwin",
|
|
74
|
+
start_time: match[1].trim(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readWindowsIdentity(pid: number): RunProcessIdentity | undefined {
|
|
79
|
+
const command = [
|
|
80
|
+
`$p = Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\";`,
|
|
81
|
+
"if ($null -eq $p) { exit 3 };",
|
|
82
|
+
"$p | Select-Object CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress",
|
|
83
|
+
].join(" ");
|
|
84
|
+
const result = spawnSync(
|
|
85
|
+
"powershell.exe",
|
|
86
|
+
["-NoProfile", "-NonInteractive", "-Command", command],
|
|
87
|
+
{ encoding: "utf8", windowsHide: true },
|
|
88
|
+
);
|
|
89
|
+
if (result.status !== 0) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(result.stdout) as Record<string, unknown>;
|
|
92
|
+
if (typeof value.CreationDate !== "string") return undefined;
|
|
93
|
+
const executable = typeof value.ExecutablePath === "string" ? value.ExecutablePath : "";
|
|
94
|
+
const commandLine = typeof value.CommandLine === "string" ? value.CommandLine : "";
|
|
95
|
+
return {
|
|
96
|
+
command: `${executable}\n${commandLine}`.trim(),
|
|
97
|
+
platform: "win32",
|
|
98
|
+
start_time: value.CreationDate,
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readProcessIdentity(
|
|
106
|
+
pid: number,
|
|
107
|
+
runtimePlatform: NodeJS.Platform = platform(),
|
|
108
|
+
): RunProcessIdentity | undefined {
|
|
109
|
+
if (runtimePlatform === "linux") return readLinuxIdentity(pid);
|
|
110
|
+
if (runtimePlatform === "darwin") return readDarwinIdentity(pid);
|
|
111
|
+
if (runtimePlatform === "win32") return readWindowsIdentity(pid);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function captureRunProcessIdentity(
|
|
20
116
|
pid: number,
|
|
21
117
|
cwd: string,
|
|
22
118
|
stateDir: string,
|
|
23
119
|
runnerPath: string,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
|
|
120
|
+
runtimePlatform: NodeJS.Platform = platform(),
|
|
121
|
+
reader: ProcessIdentityReader = readProcessIdentity,
|
|
122
|
+
): RunProcessIdentity | undefined {
|
|
123
|
+
const identity = reader(pid, runtimePlatform);
|
|
124
|
+
if (!identity) return undefined;
|
|
125
|
+
if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const resolvedCwd = resolve(cwd);
|
|
129
|
+
const canonicalCwd = existsSync(resolvedCwd)
|
|
130
|
+
? realpathSync.native(resolvedCwd)
|
|
131
|
+
: resolvedCwd;
|
|
132
|
+
const expectedCwd =
|
|
133
|
+
runtimePlatform === "win32" ? canonicalCwd.toLowerCase() : canonicalCwd;
|
|
134
|
+
const actualCwd =
|
|
135
|
+
runtimePlatform === "win32" && identity.cwd
|
|
136
|
+
? identity.cwd.toLowerCase()
|
|
137
|
+
: identity.cwd;
|
|
138
|
+
if (actualCwd && actualCwd !== expectedCwd) return undefined;
|
|
139
|
+
return identity;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function verifyRunProcessIdentity(
|
|
143
|
+
pid: number,
|
|
144
|
+
expected: RunProcessIdentity | undefined,
|
|
145
|
+
runtimePlatform: NodeJS.Platform = platform(),
|
|
146
|
+
reader: ProcessIdentityReader = readProcessIdentity,
|
|
147
|
+
alive: (pid: number) => boolean = isAlive,
|
|
148
|
+
): RunProcessIdentityResult {
|
|
149
|
+
if (!alive(pid)) return { status: "dead_pid", valid: false };
|
|
150
|
+
if (!expected || expected.platform !== runtimePlatform) {
|
|
151
|
+
return { status: "unsupported_proof", valid: false };
|
|
37
152
|
}
|
|
153
|
+
const current = reader(pid, runtimePlatform);
|
|
154
|
+
if (!current) return { status: "unsupported_proof", valid: false };
|
|
155
|
+
const valid =
|
|
156
|
+
current.start_time === expected.start_time &&
|
|
157
|
+
current.command === expected.command &&
|
|
158
|
+
current.cwd === expected.cwd;
|
|
159
|
+
return { status: valid ? "valid" : "owner_mismatch", valid };
|
|
38
160
|
}
|
|
39
161
|
|
|
40
162
|
export function isWithinRunnerIdentityGrace(
|
package/lib/runs-retention.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Owns terminal-run archive and prune filesystem behavior.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
7
8
|
import { basename, dirname, join } from "node:path";
|
|
8
9
|
|
|
@@ -12,6 +13,16 @@ import {
|
|
|
12
13
|
type RunArtifactDeclaration,
|
|
13
14
|
} from "./runs-artifacts.ts";
|
|
14
15
|
import { safeRunId } from "./runs-identity.ts";
|
|
16
|
+
import { assertOwnedRunStateDirectory } from "./runs-ownership.ts";
|
|
17
|
+
|
|
18
|
+
function retainedArtifactFilename(name: string, path: string): string {
|
|
19
|
+
const readableName = name.replace(/[^A-Za-z0-9_.-]+/g, "_") || "artifact";
|
|
20
|
+
const identity = createHash("sha256")
|
|
21
|
+
.update(`${name}\0${path}`)
|
|
22
|
+
.digest("hex")
|
|
23
|
+
.slice(0, 8);
|
|
24
|
+
return `${readableName}-${identity}--${basename(path)}`;
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
function archivePathFor(run: string, stateDir: string): string {
|
|
17
28
|
const archiveRoot = join(dirname(stateDir), "archived");
|
|
@@ -27,6 +38,7 @@ export function archiveTerminalRun(
|
|
|
27
38
|
): Record<string, unknown> {
|
|
28
39
|
const stateDir = String(status.state_dir);
|
|
29
40
|
const run = String(status.run ?? basename(stateDir));
|
|
41
|
+
assertOwnedRunStateDirectory(stateDir, run);
|
|
30
42
|
const archiveDir = archivePathFor(run, stateDir);
|
|
31
43
|
renameSync(stateDir, archiveDir);
|
|
32
44
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -45,9 +57,11 @@ export function archiveTerminalRun(
|
|
|
45
57
|
export function pruneTerminalRun(
|
|
46
58
|
status: Record<string, unknown>,
|
|
47
59
|
options: { preserveArtifacts?: boolean } = {},
|
|
60
|
+
deps: { copyArtifact?: typeof cpSync } = {},
|
|
48
61
|
): Record<string, unknown> {
|
|
49
62
|
const stateDir = String(status.state_dir);
|
|
50
63
|
const run = String(status.run ?? basename(stateDir));
|
|
64
|
+
assertOwnedRunStateDirectory(stateDir, run);
|
|
51
65
|
const manifest = resolveArtifactManifest(
|
|
52
66
|
status.artifacts as Record<string, RunArtifactDeclaration> | undefined,
|
|
53
67
|
);
|
|
@@ -61,8 +75,14 @@ export function pruneTerminalRun(
|
|
|
61
75
|
mkdirSync(preserveRoot, { recursive: true });
|
|
62
76
|
for (const [name, artifact] of Object.entries(manifest)) {
|
|
63
77
|
if (!artifact.exists) continue;
|
|
64
|
-
const target = join(
|
|
65
|
-
|
|
78
|
+
const target = join(
|
|
79
|
+
preserveRoot,
|
|
80
|
+
retainedArtifactFilename(name, artifact.path),
|
|
81
|
+
);
|
|
82
|
+
(deps.copyArtifact ?? cpSync)(artifact.path, target, {
|
|
83
|
+
force: true,
|
|
84
|
+
preserveTimestamps: true,
|
|
85
|
+
});
|
|
66
86
|
preserved[name] = target;
|
|
67
87
|
}
|
|
68
88
|
}
|