pi-extended-teams 2.2.9 → 2.3.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/README.md +182 -2
- package/extensions/agents/read-agent-report.ts +11 -2
- package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
- package/extensions/agents/read-agent.ts +387 -65
- package/extensions/agents/write-agent.ts +3 -0
- package/extensions/events/register-events.ts +22 -10
- package/extensions/index.ts +132 -38
- package/extensions/internal/pi-check-operations.ts +24 -0
- package/extensions/internal/session-files.ts +10 -2
- package/extensions/runtime/teammate-interrupt.ts +11 -5
- package/extensions/runtime/types.ts +4 -0
- package/extensions/team/lifecycle.ts +26 -0
- package/extensions/team/session-cost.ts +155 -0
- package/extensions/tools/agent-communication-tools.ts +24 -8
- package/extensions/tools/agent-status-tool.ts +50 -7
- package/extensions/tools/coordination-tools.ts +78 -8
- package/extensions/tools/task-runtime-tools.ts +23 -4
- package/extensions/tools/team-tools.ts +287 -40
- package/extensions/ui/activity-colors.ts +59 -0
- package/extensions/ui/agent-follow-view.ts +22 -12
- package/extensions/ui/agent-navigation.ts +35 -1
- package/extensions/ui/checkpoints-command.ts +28 -0
- package/extensions/ui/extensions-command.ts +5 -1
- package/extensions/ui/onboarding-command.ts +218 -0
- package/extensions/ui/status-widget.ts +22 -14
- package/package.json +1 -1
- package/skills/teams.md +16 -0
- package/src/orchestration/index.ts +19 -1
- package/src/orchestration/status-projection.ts +2 -3
- package/src/orchestration/types.ts +9 -1
- package/src/results/check-journal.ts +149 -0
- package/src/results/check-policy.ts +86 -0
- package/src/results/check-runner.ts +110 -0
- package/src/results/checkpoint-assignment.ts +60 -0
- package/src/results/checkpoint-policy.ts +20 -0
- package/src/results/checkpoint-report.ts +109 -0
- package/src/results/checkpoint-retention.ts +15 -0
- package/src/results/completion-group-delivery.ts +66 -0
- package/src/results/completion-group-recovery.ts +61 -0
- package/src/results/completion-group-wake.ts +62 -0
- package/src/results/completion-group.ts +290 -0
- package/src/results/continuation-recipient.ts +37 -0
- package/src/results/durable-json.ts +29 -0
- package/src/results/repair-policy.ts +24 -0
- package/src/results/report-result.ts +77 -0
- package/src/results/source-identity.ts +115 -0
- package/src/results/specialist-checkpoint.ts +227 -0
- package/src/results/verification-controller.ts +308 -0
- package/src/utils/messaging.ts +47 -10
- package/src/utils/models.ts +13 -0
- package/src/utils/paths.ts +4 -0
- package/src/utils/report-events.ts +95 -10
- package/src/utils/teams.ts +3 -1
- package/src/utils/write-queue.ts +3 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function syncPath(file: string): void {
|
|
6
|
+
const descriptor = fs.openSync(file, "r");
|
|
7
|
+
try { fs.fsyncSync(descriptor); }
|
|
8
|
+
finally { fs.closeSync(descriptor); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function syncPathAndParents(file: string): void {
|
|
12
|
+
for (let current = file; ; current = path.dirname(current)) {
|
|
13
|
+
syncPath(current);
|
|
14
|
+
if (path.dirname(current) === current) return;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeJsonDurably(file: string, value: unknown): void {
|
|
19
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomUUID()}.tmp`);
|
|
20
|
+
try {
|
|
21
|
+
fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
|
|
22
|
+
syncPath(temporary);
|
|
23
|
+
fs.renameSync(temporary, file);
|
|
24
|
+
syncPathAndParents(path.dirname(file));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
try { fs.unlinkSync(temporary); } catch {}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
|
|
4
|
+
export const RepairPolicySchema = Type.Object({
|
|
5
|
+
maxAttempts: Type.Integer({ minimum: 0, maximum: 5, description: "Maximum additional repair attempts after initial verification. Zero disables automatic repair. Only the lead or a trusted integration may authorize this policy." }),
|
|
6
|
+
}, { additionalProperties: false });
|
|
7
|
+
export type RepairPolicy = Static<typeof RepairPolicySchema>;
|
|
8
|
+
export type RepairState = "pending" | "not-needed" | "requested" | "repaired" | "exhausted" | "cancelled" | "declined" | "blocked";
|
|
9
|
+
export interface RepairResult {
|
|
10
|
+
controllerId: string;
|
|
11
|
+
journalPath?: string;
|
|
12
|
+
state: RepairState;
|
|
13
|
+
attemptsUsed?: number;
|
|
14
|
+
maxAttempts?: number;
|
|
15
|
+
requestIds?: string[];
|
|
16
|
+
outcome?: "blocked";
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeRepairPolicy(value: unknown): RepairPolicy | undefined {
|
|
21
|
+
if (value === undefined) return undefined;
|
|
22
|
+
if (!Value.Check(RepairPolicySchema, value)) throw new Error("Invalid repair policy.");
|
|
23
|
+
return value.maxAttempts ? structuredClone(value) : undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import type { RepairResult } from "./repair-policy";
|
|
4
|
+
|
|
5
|
+
const reference = Type.String({ minLength: 1 });
|
|
6
|
+
|
|
7
|
+
export const ReportedTaskDetailsSchema = Type.Object({
|
|
8
|
+
outcome: Type.Optional(Type.Union([
|
|
9
|
+
Type.Literal("succeeded"), Type.Literal("blocked"), Type.Literal("failed"), Type.Literal("cancelled"),
|
|
10
|
+
], { description: "Reported task outcome, separate from clean exit, verification, and lead acceptance. Omit when unspecified." })),
|
|
11
|
+
changedPaths: Type.Optional(Type.Array(reference)),
|
|
12
|
+
inspectedEvidence: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 2048, pattern: "\\S" }), { maxItems: 128 })),
|
|
13
|
+
artifacts: Type.Optional(Type.Array(Type.Object({
|
|
14
|
+
path: reference,
|
|
15
|
+
label: Type.Optional(Type.String()),
|
|
16
|
+
}, { additionalProperties: false }))),
|
|
17
|
+
findings: Type.Optional(Type.Array(Type.Object({
|
|
18
|
+
id: reference,
|
|
19
|
+
text: reference,
|
|
20
|
+
evidence: Type.Array(reference),
|
|
21
|
+
}, { additionalProperties: false }))),
|
|
22
|
+
questions: Type.Optional(Type.Array(reference)),
|
|
23
|
+
}, { additionalProperties: false });
|
|
24
|
+
|
|
25
|
+
export type ReportedTaskDetails = Static<typeof ReportedTaskDetailsSchema>;
|
|
26
|
+
export type TaskOutcome = NonNullable<ReportedTaskDetails["outcome"]>;
|
|
27
|
+
export type VerificationState = "not-requested" | "pending" | "passed" | "failed" | "stale";
|
|
28
|
+
|
|
29
|
+
export interface ReportResult extends ReportedTaskDetails {
|
|
30
|
+
version: 1;
|
|
31
|
+
taskId: string;
|
|
32
|
+
runId: string;
|
|
33
|
+
reportId: string;
|
|
34
|
+
checkpointId?: string;
|
|
35
|
+
verification: { state: VerificationState; checkIds?: string[]; error?: string };
|
|
36
|
+
acceptance: { state: "pending" | "accepted" | "rejected"; decidedAt?: number; reason?: string };
|
|
37
|
+
repair?: RepairResult;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function effectiveTaskOutcome(result: ReportResult): TaskOutcome | undefined {
|
|
41
|
+
return result.repair?.outcome ?? result.outcome;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeReportedTaskDetails(value: unknown): ReportedTaskDetails {
|
|
45
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
46
|
+
throw new Error("Reported task details must be an object.");
|
|
47
|
+
}
|
|
48
|
+
const input = value as Record<string, unknown>;
|
|
49
|
+
const details = Object.fromEntries(Object.keys(ReportedTaskDetailsSchema.properties)
|
|
50
|
+
.filter(key => input[key] !== undefined)
|
|
51
|
+
.map(key => [key, input[key]]));
|
|
52
|
+
if (!Value.Check(ReportedTaskDetailsSchema, details)) throw new Error("Invalid reported task details.");
|
|
53
|
+
const findingIds = details.findings?.map(finding => finding.id) ?? [];
|
|
54
|
+
if (new Set(findingIds).size !== findingIds.length) throw new Error("Reported finding IDs must be unique.");
|
|
55
|
+
return structuredClone(details);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createReportResult(
|
|
59
|
+
teamName: string,
|
|
60
|
+
agentName: string,
|
|
61
|
+
runId: string,
|
|
62
|
+
reported: ReportedTaskDetails,
|
|
63
|
+
): ReportResult {
|
|
64
|
+
if (![teamName, agentName, runId].every(value => typeof value === "string" && value.length > 0)) {
|
|
65
|
+
throw new Error("Report identity requires the bound team, agent and lifecycle run.");
|
|
66
|
+
}
|
|
67
|
+
const identity = [teamName, agentName, runId].map(encodeURIComponent).join(":");
|
|
68
|
+
return {
|
|
69
|
+
...normalizeReportedTaskDetails(reported),
|
|
70
|
+
version: 1,
|
|
71
|
+
taskId: `task:${identity}`,
|
|
72
|
+
runId,
|
|
73
|
+
reportId: `report:${identity}`,
|
|
74
|
+
verification: { state: "not-requested" },
|
|
75
|
+
acceptance: { state: "pending" },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
7
|
+
|
|
8
|
+
export const SourceIdentitySchema = Type.Object({
|
|
9
|
+
version: Type.Literal(1),
|
|
10
|
+
cwd: Type.String({ minLength: 1 }),
|
|
11
|
+
repositoryRoot: Type.String({ minLength: 1 }),
|
|
12
|
+
head: Type.Union([Type.String({ minLength: 1 }), Type.Null()]),
|
|
13
|
+
fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
14
|
+
inputs: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
15
|
+
fileCount: Type.Integer({ minimum: 0 }),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
export type SourceIdentity = Static<typeof SourceIdentitySchema>;
|
|
18
|
+
const execute = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
function relativeInput(root: string, absolute: string): string {
|
|
21
|
+
const relative = path.relative(root, absolute);
|
|
22
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
23
|
+
throw new Error(`Source input is outside the repository: ${absolute}`);
|
|
24
|
+
}
|
|
25
|
+
return relative.split(path.sep).join("/") || ".";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fileStamp(stat: fs.BigIntStats): string {
|
|
29
|
+
return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function captureSourceIdentity(
|
|
33
|
+
cwd: string,
|
|
34
|
+
inputs?: string[],
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
): Promise<SourceIdentity> {
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
if (inputs !== undefined && (!Array.isArray(inputs) || !inputs.length
|
|
39
|
+
|| inputs.some(input => typeof input !== "string" || !input.length || input.includes("\0")))) {
|
|
40
|
+
throw new Error("Source input scope must contain nonempty literal paths.");
|
|
41
|
+
}
|
|
42
|
+
const canonicalCwd = await fs.promises.realpath(cwd);
|
|
43
|
+
const git = async (args: string[], directory = canonicalCwd): Promise<string> => {
|
|
44
|
+
const { stdout } = await execute("git", ["-c", "core.fsmonitor=false", ...args], {
|
|
45
|
+
cwd: directory, encoding: "buffer", signal, timeout: 10_000, maxBuffer: 32 * 1024 * 1024,
|
|
46
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
47
|
+
});
|
|
48
|
+
const text = stdout.toString("utf8");
|
|
49
|
+
if (!Buffer.from(text).equals(stdout)) throw new Error("Source identity requires UTF-8 repository paths.");
|
|
50
|
+
return text;
|
|
51
|
+
};
|
|
52
|
+
const repositoryRoot = await fs.promises.realpath((await git(["rev-parse", "--show-toplevel"])).replace(/\n$/, ""));
|
|
53
|
+
const scopes = [...new Set(inputs?.map(input => relativeInput(repositoryRoot, path.resolve(canonicalCwd, input))) ?? ["."])].sort();
|
|
54
|
+
const selected = (file: string) => scopes.some(scope => scope === "." || file === scope || file.startsWith(`${scope}/`));
|
|
55
|
+
const index = await git(["ls-files", "--stage", "-z"], repositoryRoot);
|
|
56
|
+
const untracked = await git(["ls-files", "--others", "--exclude-standard", "-z"], repositoryRoot);
|
|
57
|
+
const entries = index.split("\0").filter(Boolean).map(entry => {
|
|
58
|
+
const separator = entry.indexOf("\t");
|
|
59
|
+
if (separator < 0) throw new Error("Invalid Git index listing.");
|
|
60
|
+
return { file: entry.slice(separator + 1), index: entry.slice(0, separator) };
|
|
61
|
+
});
|
|
62
|
+
const files = [...new Set([...entries.map(entry => entry.file), ...untracked.split("\0").filter(Boolean)])].filter(selected).sort();
|
|
63
|
+
for (const scope of scopes) {
|
|
64
|
+
if (inputs && !files.some(file => scope === "." || file === scope || file.startsWith(`${scope}/`))) {
|
|
65
|
+
throw new Error(`No tracked or nonignored source inputs captured for scope: ${scope}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let head: string | null;
|
|
69
|
+
try { head = (await git(["rev-parse", "--verify", "--quiet", "HEAD"], repositoryRoot)).trim(); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (typeof error !== "object" || error === null || Reflect.get(error, "code") !== 1) throw error;
|
|
72
|
+
head = null;
|
|
73
|
+
}
|
|
74
|
+
const digest = crypto.createHash("sha256");
|
|
75
|
+
digest.update(JSON.stringify({ version: 1, head, inputs: scopes, index: entries.filter(entry => selected(entry.file)) }));
|
|
76
|
+
const captured = new Set(files);
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
const absolute = path.resolve(repositoryRoot, file);
|
|
80
|
+
relativeInput(repositoryRoot, absolute);
|
|
81
|
+
let before: fs.BigIntStats;
|
|
82
|
+
try { before = await fs.promises.lstat(absolute, { bigint: true }); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (typeof error !== "object" || error === null || Reflect.get(error, "code") !== "ENOENT") throw error;
|
|
85
|
+
digest.update(JSON.stringify([file, "missing"]));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const realPath = await fs.promises.realpath(absolute);
|
|
89
|
+
const target = relativeInput(repositoryRoot, realPath);
|
|
90
|
+
if (before.isSymbolicLink()) {
|
|
91
|
+
if (!captured.has(target) || !(await fs.promises.stat(realPath)).isFile()) {
|
|
92
|
+
throw new Error(`Symlink target is not a captured regular source file: ${file}`);
|
|
93
|
+
}
|
|
94
|
+
digest.update(JSON.stringify([file, "symlink", await fs.promises.readlink(absolute)]));
|
|
95
|
+
} else {
|
|
96
|
+
if (!before.isFile() || realPath !== absolute) throw new Error(`Unsupported source input type or symlink ancestor: ${file}`);
|
|
97
|
+
const content = crypto.createHash("sha256");
|
|
98
|
+
for await (const chunk of fs.createReadStream(absolute, { signal })) content.update(chunk);
|
|
99
|
+
digest.update(JSON.stringify([file, Number(before.mode & 0o777n), content.digest("hex")]));
|
|
100
|
+
}
|
|
101
|
+
if (fileStamp(before) !== fileStamp(await fs.promises.lstat(absolute, { bigint: true }))) {
|
|
102
|
+
throw new Error(`Source input changed during capture: ${file}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (index !== await git(["ls-files", "--stage", "-z"], repositoryRoot)
|
|
106
|
+
|| untracked !== await git(["ls-files", "--others", "--exclude-standard", "-z"], repositoryRoot)) {
|
|
107
|
+
throw new Error("Source input inventory changed during capture.");
|
|
108
|
+
}
|
|
109
|
+
return { version: 1, cwd: canonicalCwd, repositoryRoot, head, fingerprint: digest.digest("hex"), inputs: scopes, fileCount: files.length };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function sameTestedSource(before: SourceIdentity, after: SourceIdentity): boolean {
|
|
113
|
+
return before.version === after.version && before.cwd === after.cwd
|
|
114
|
+
&& before.repositoryRoot === after.repositoryRoot && before.fingerprint === after.fingerprint;
|
|
115
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
6
|
+
import { Value } from "@sinclair/typebox/value";
|
|
7
|
+
import { checkpointFilesDir } from "../utils/paths";
|
|
8
|
+
import { FAVORITE_MODEL_SLOTS } from "../utils/settings";
|
|
9
|
+
import { withLock } from "../utils/lock";
|
|
10
|
+
import { normalizeCheckpointPolicy, StoredCheckpointPolicySchema } from "./checkpoint-policy";
|
|
11
|
+
import { SourceIdentitySchema } from "./source-identity";
|
|
12
|
+
import { createReportResult, type ReportResult } from "./report-result";
|
|
13
|
+
import { syncPathAndParents, writeJsonDurably } from "./durable-json";
|
|
14
|
+
|
|
15
|
+
export const MAX_CHECKPOINT_BYTES = 65_536;
|
|
16
|
+
export const MAX_CHECKPOINT_REPORT_PATH_LENGTH = 2048;
|
|
17
|
+
const day = 86_400_000;
|
|
18
|
+
const text = (maxLength = 4096) => Type.String({ minLength: 1, maxLength });
|
|
19
|
+
const identity = Type.String({ pattern: "^checkpoint:[a-f0-9]{64}$", maxLength: 75 });
|
|
20
|
+
const reportIdentity = Type.String({ pattern: "^report:[^:]+:[^:]+:[^:]+$", maxLength: 2048 });
|
|
21
|
+
const time = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
|
|
22
|
+
const authorSchema = Type.Object({
|
|
23
|
+
teamName: text(256), agentName: text(256), runId: text(256),
|
|
24
|
+
modelSlot: Type.Union(FAVORITE_MODEL_SLOTS.map(slot => Type.Literal(slot))),
|
|
25
|
+
}, { additionalProperties: false });
|
|
26
|
+
export type CheckpointAuthor = Static<typeof authorSchema>;
|
|
27
|
+
const recordSchema = Type.Object({
|
|
28
|
+
version: Type.Literal(1), state: Type.Literal("ready"), id: identity,
|
|
29
|
+
author: authorSchema, createdAt: time, expiresAt: time, parentId: Type.Optional(identity),
|
|
30
|
+
assignment: Type.Object({ original: text(16_384), current: text(16_384) }, { additionalProperties: false }),
|
|
31
|
+
policy: StoredCheckpointPolicySchema, reportId: reportIdentity,
|
|
32
|
+
reports: Type.Array(Type.Object({
|
|
33
|
+
id: reportIdentity, path: text(MAX_CHECKPOINT_REPORT_PATH_LENGTH),
|
|
34
|
+
source: Type.Object({ before: SourceIdentitySchema, after: SourceIdentitySchema }, { additionalProperties: false }),
|
|
35
|
+
verification: Type.Union((["not-requested", "pending", "passed", "failed", "stale"] as const).map(state => Type.Literal(state))),
|
|
36
|
+
acceptance: Type.Union((["pending", "accepted", "rejected"] as const).map(state => Type.Literal(state))),
|
|
37
|
+
leadDecisions: Type.Optional(Type.Array(text(), { maxItems: 32 })),
|
|
38
|
+
}, { additionalProperties: false }), { minItems: 1, maxItems: 16 }),
|
|
39
|
+
findings: Type.Array(Type.Object({
|
|
40
|
+
id: text(256), text: text(), evidence: Type.Array(text(2048), { maxItems: 32 }), reportId: reportIdentity,
|
|
41
|
+
}, { additionalProperties: false }), { maxItems: 100 }),
|
|
42
|
+
inspectedEvidence: Type.Array(Type.Object({ reference: text(2048), reportId: reportIdentity }, { additionalProperties: false }), { maxItems: 128 }),
|
|
43
|
+
questions: Type.Array(text(), { maxItems: 32 }),
|
|
44
|
+
}, { additionalProperties: false });
|
|
45
|
+
const retirementSchema = Type.Object({
|
|
46
|
+
version: Type.Literal(1), id: identity, retiredAt: time,
|
|
47
|
+
state: Type.Union([Type.Literal("deleted"), Type.Literal("expired")]),
|
|
48
|
+
}, { additionalProperties: false });
|
|
49
|
+
export type SpecialistCheckpoint = Static<typeof recordSchema>;
|
|
50
|
+
export type RetiredCheckpoint = Static<typeof retirementSchema>;
|
|
51
|
+
export type CheckpointRecord = SpecialistCheckpoint | RetiredCheckpoint;
|
|
52
|
+
|
|
53
|
+
const currentReportSchema = Type.Pick(recordSchema, ["findings", "inspectedEvidence", "questions"]);
|
|
54
|
+
|
|
55
|
+
export function checkpointReportPayload(result: ReportResult): Static<typeof currentReportSchema> {
|
|
56
|
+
const payload = {
|
|
57
|
+
findings: (result.findings ?? []).map(finding => ({ ...finding, reportId: result.reportId })),
|
|
58
|
+
inspectedEvidence: [...new Set(result.inspectedEvidence ?? [])].map(reference => ({ reference, reportId: result.reportId })),
|
|
59
|
+
questions: [...new Set(result.questions ?? [])],
|
|
60
|
+
};
|
|
61
|
+
const error = Value.Errors(currentReportSchema, payload).First();
|
|
62
|
+
if (error) throw new Error(`Checkpoint report ${error.path}: ${error.message}. Correct the report fields and resubmit report_and_exit.`);
|
|
63
|
+
return payload;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function checkpointId(author: CheckpointAuthor): string {
|
|
67
|
+
if (!Value.Check(authorSchema, author)) throw new Error("Invalid checkpoint author.");
|
|
68
|
+
return `checkpoint:${crypto.createHash("sha256").update(JSON.stringify([author.teamName, author.agentName, author.runId])).digest("hex")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function checkpointPath(id: string): string {
|
|
72
|
+
if (!Value.Check(identity, id)) throw new Error("Invalid checkpoint ID.");
|
|
73
|
+
return path.join(checkpointFilesDir(), `${id.slice("checkpoint:".length)}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isSpecialistCheckpoint(value: unknown): value is SpecialistCheckpoint {
|
|
77
|
+
if (!Value.Check(recordSchema, value)) return false;
|
|
78
|
+
const { author, reports, policy } = value;
|
|
79
|
+
const reportIds = new Set(reports.map(report => report.id));
|
|
80
|
+
const current = reports.find(report => report.id === value.reportId);
|
|
81
|
+
const source = current?.source.before;
|
|
82
|
+
const inputs = source ? [...new Set(policy.inputs.map(input =>
|
|
83
|
+
path.relative(source.repositoryRoot, path.resolve(source.cwd, input)).split(path.sep).join("/") || "."))].sort() : [];
|
|
84
|
+
return value.id === checkpointId(author) && value.parentId !== value.id
|
|
85
|
+
&& value.reportId === createReportResult(author.teamName, author.agentName, author.runId, {}).reportId
|
|
86
|
+
&& value.expiresAt === value.createdAt + policy.retentionDays * day
|
|
87
|
+
&& isDeepStrictEqual(policy, normalizeCheckpointPolicy(policy))
|
|
88
|
+
&& reportIds.size === reports.length && !!source && isDeepStrictEqual(inputs, source.inputs)
|
|
89
|
+
&& new Set(value.findings.map(finding => finding.id)).size === value.findings.length
|
|
90
|
+
&& [...value.findings, ...value.inspectedEvidence].every(item => reportIds.has(item.reportId))
|
|
91
|
+
&& reports.every(report => {
|
|
92
|
+
const { before, after } = report.source;
|
|
93
|
+
const relative = path.relative(before.repositoryRoot, before.cwd);
|
|
94
|
+
return path.isAbsolute(report.path) && path.isAbsolute(before.cwd) && path.isAbsolute(before.repositoryRoot)
|
|
95
|
+
&& !path.isAbsolute(relative) && !relative.split(path.sep).includes("..")
|
|
96
|
+
&& before.cwd === after.cwd && before.repositoryRoot === after.repositoryRoot
|
|
97
|
+
&& isDeepStrictEqual(before.inputs, after.inputs);
|
|
98
|
+
})
|
|
99
|
+
&& Buffer.byteLength(JSON.stringify(value, null, 2)) <= MAX_CHECKPOINT_BYTES;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rootAvailable(create = false): boolean {
|
|
103
|
+
const root = checkpointFilesDir();
|
|
104
|
+
for (let current = root; ; current = path.dirname(current)) {
|
|
105
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
106
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`Unsafe checkpoint directory: ${current}`);
|
|
107
|
+
if (path.dirname(current) === current) break;
|
|
108
|
+
}
|
|
109
|
+
if (!fs.existsSync(root)) {
|
|
110
|
+
if (!create) return false;
|
|
111
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
112
|
+
}
|
|
113
|
+
const stat = fs.lstatSync(root);
|
|
114
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)
|
|
115
|
+
|| (process.getuid && stat.uid !== process.getuid())) throw new Error(`Unsafe checkpoint directory: ${root}`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function requireRegularFile(file: string): fs.Stats {
|
|
120
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
121
|
+
if (!stat) throw new Error(`Checkpoint unavailable: ${file}. Request a new checkpoint.`);
|
|
122
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
|
|
123
|
+
|| (process.getuid && stat.uid !== process.getuid())) throw new Error(`Unsafe checkpoint file: ${file}`);
|
|
124
|
+
return stat;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readRecord(id: string): CheckpointRecord {
|
|
128
|
+
const file = checkpointPath(id);
|
|
129
|
+
requireRegularFile(file);
|
|
130
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
131
|
+
let raw: string;
|
|
132
|
+
try {
|
|
133
|
+
const stat = fs.fstatSync(fd);
|
|
134
|
+
if (!stat.isFile() || stat.nlink !== 1 || (process.getuid && stat.uid !== process.getuid())) throw new Error(`Unsafe checkpoint file: ${file}`);
|
|
135
|
+
if (stat.size > MAX_CHECKPOINT_BYTES) throw new Error(`Checkpoint is too large: ${file}. Delete it or create a smaller checkpoint.`);
|
|
136
|
+
const buffer = Buffer.alloc(MAX_CHECKPOINT_BYTES + 1);
|
|
137
|
+
let length = 0;
|
|
138
|
+
while (length < buffer.length) {
|
|
139
|
+
const bytes = fs.readSync(fd, buffer, length, buffer.length - length, length);
|
|
140
|
+
if (!bytes) break;
|
|
141
|
+
length += bytes;
|
|
142
|
+
}
|
|
143
|
+
if (length > MAX_CHECKPOINT_BYTES) throw new Error(`Checkpoint is too large: ${file}. Delete it or create a smaller checkpoint.`);
|
|
144
|
+
raw = buffer.subarray(0, length).toString("utf8");
|
|
145
|
+
} finally { fs.closeSync(fd); }
|
|
146
|
+
try {
|
|
147
|
+
const value: unknown = JSON.parse(raw);
|
|
148
|
+
if ((Value.Check(retirementSchema, value) || isSpecialistCheckpoint(value)) && value.id === id) return value;
|
|
149
|
+
} catch {}
|
|
150
|
+
throw new Error(`Checkpoint ${id} is corrupt or incompatible; inspect ${file} or delete it.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function readCheckpoint(id: string, now = Date.now()): SpecialistCheckpoint {
|
|
154
|
+
if (!Value.Check(time, now)) throw new Error("Invalid checkpoint observation time.");
|
|
155
|
+
const file = checkpointPath(id);
|
|
156
|
+
if (!rootAvailable()) throw new Error(`Checkpoint unavailable: ${file}. Request a new checkpoint.`);
|
|
157
|
+
const record = readRecord(id);
|
|
158
|
+
if (record.state !== "ready") throw new Error(`Checkpoint ${id} was ${record.state}. Request a new checkpoint.`);
|
|
159
|
+
if (record.expiresAt <= now) throw new Error(`Checkpoint ${id} has expired. Request a new checkpoint.`);
|
|
160
|
+
return structuredClone(record);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function saveCheckpoint(value: unknown): Promise<CheckpointRecord | undefined> {
|
|
164
|
+
if (value === undefined) return undefined;
|
|
165
|
+
if (!isSpecialistCheckpoint(value)) throw new Error("Invalid specialist checkpoint.");
|
|
166
|
+
const record = structuredClone(value);
|
|
167
|
+
if (record.parentId === undefined) delete record.parentId;
|
|
168
|
+
for (const report of record.reports) if (report.leadDecisions === undefined) delete report.leadDecisions;
|
|
169
|
+
const file = checkpointPath(record.id);
|
|
170
|
+
rootAvailable(true);
|
|
171
|
+
return withLock(file, async () => {
|
|
172
|
+
rootAvailable();
|
|
173
|
+
if (fs.lstatSync(file, { throwIfNoEntry: false })) {
|
|
174
|
+
const existing = readRecord(record.id);
|
|
175
|
+
if (existing.state === "ready" && !isDeepStrictEqual(existing, record)) throw new Error(`Checkpoint ${record.id} is immutable.`);
|
|
176
|
+
syncPathAndParents(file);
|
|
177
|
+
return structuredClone(existing);
|
|
178
|
+
}
|
|
179
|
+
writeJsonDurably(file, record);
|
|
180
|
+
return structuredClone(record);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function resyncCheckpoint(id: string): Promise<CheckpointRecord> {
|
|
185
|
+
const file = checkpointPath(id);
|
|
186
|
+
if (!rootAvailable()) throw new Error(`Checkpoint unavailable: ${file}. Request a new checkpoint.`);
|
|
187
|
+
return withLock(file, async () => {
|
|
188
|
+
rootAvailable();
|
|
189
|
+
const record = readRecord(id);
|
|
190
|
+
syncPathAndParents(file);
|
|
191
|
+
return structuredClone(record);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function retireCheckpoint(id: string, state: RetiredCheckpoint["state"], now = Date.now()): Promise<RetiredCheckpoint> {
|
|
196
|
+
const record: RetiredCheckpoint = { version: 1, id, state, retiredAt: now };
|
|
197
|
+
if (!Value.Check(retirementSchema, record)) throw new Error("Invalid checkpoint retirement.");
|
|
198
|
+
const file = checkpointPath(id);
|
|
199
|
+
if (!rootAvailable()) throw new Error(`Checkpoint unavailable: ${file}. Request a new checkpoint.`);
|
|
200
|
+
return withLock(file, async () => {
|
|
201
|
+
rootAvailable();
|
|
202
|
+
requireRegularFile(file);
|
|
203
|
+
let existing: CheckpointRecord | undefined;
|
|
204
|
+
try { existing = readRecord(id); }
|
|
205
|
+
catch (error) { if (state !== "deleted") throw error; }
|
|
206
|
+
if (existing && existing.state !== "ready") {
|
|
207
|
+
syncPathAndParents(file);
|
|
208
|
+
return existing;
|
|
209
|
+
}
|
|
210
|
+
if (state === "expired" && (!existing || existing.expiresAt > now)) throw new Error(`Checkpoint ${id} is not expired.`);
|
|
211
|
+
writeJsonDurably(file, record);
|
|
212
|
+
return structuredClone(record);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function listCheckpointRecords(): { records: CheckpointRecord[]; errors: { id: string; message: string }[] } {
|
|
217
|
+
const records: CheckpointRecord[] = [];
|
|
218
|
+
const errors: { id: string; message: string }[] = [];
|
|
219
|
+
if (!rootAvailable()) return { records, errors };
|
|
220
|
+
for (const name of fs.readdirSync(checkpointFilesDir()).sort()) {
|
|
221
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
222
|
+
const id = `checkpoint:${name.slice(0, -5)}`;
|
|
223
|
+
try { records.push(readRecord(id)); }
|
|
224
|
+
catch (error) { errors.push({ id, message: error instanceof Error ? error.message : String(error) }); }
|
|
225
|
+
}
|
|
226
|
+
return { records, errors };
|
|
227
|
+
}
|