pi-extended-teams 2.2.10 → 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.
Files changed (53) hide show
  1. package/README.md +182 -2
  2. package/extensions/agents/read-agent-report.ts +11 -2
  3. package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
  4. package/extensions/agents/read-agent.ts +387 -65
  5. package/extensions/agents/write-agent.ts +3 -0
  6. package/extensions/events/register-events.ts +22 -10
  7. package/extensions/index.ts +132 -38
  8. package/extensions/internal/pi-check-operations.ts +24 -0
  9. package/extensions/internal/session-files.ts +10 -2
  10. package/extensions/runtime/teammate-interrupt.ts +11 -5
  11. package/extensions/runtime/types.ts +4 -0
  12. package/extensions/team/lifecycle.ts +26 -0
  13. package/extensions/team/session-cost.ts +155 -0
  14. package/extensions/tools/agent-communication-tools.ts +24 -8
  15. package/extensions/tools/agent-status-tool.ts +50 -7
  16. package/extensions/tools/coordination-tools.ts +78 -8
  17. package/extensions/tools/task-runtime-tools.ts +23 -4
  18. package/extensions/tools/team-tools.ts +287 -40
  19. package/extensions/ui/activity-colors.ts +59 -0
  20. package/extensions/ui/agent-follow-view.ts +15 -11
  21. package/extensions/ui/agent-navigation.ts +35 -1
  22. package/extensions/ui/checkpoints-command.ts +28 -0
  23. package/extensions/ui/onboarding-command.ts +218 -0
  24. package/extensions/ui/status-widget.ts +22 -14
  25. package/package.json +1 -1
  26. package/skills/teams.md +16 -0
  27. package/src/orchestration/index.ts +19 -1
  28. package/src/orchestration/status-projection.ts +2 -3
  29. package/src/orchestration/types.ts +9 -1
  30. package/src/results/check-journal.ts +149 -0
  31. package/src/results/check-policy.ts +86 -0
  32. package/src/results/check-runner.ts +110 -0
  33. package/src/results/checkpoint-assignment.ts +60 -0
  34. package/src/results/checkpoint-policy.ts +20 -0
  35. package/src/results/checkpoint-report.ts +109 -0
  36. package/src/results/checkpoint-retention.ts +15 -0
  37. package/src/results/completion-group-delivery.ts +66 -0
  38. package/src/results/completion-group-recovery.ts +61 -0
  39. package/src/results/completion-group-wake.ts +62 -0
  40. package/src/results/completion-group.ts +290 -0
  41. package/src/results/continuation-recipient.ts +37 -0
  42. package/src/results/durable-json.ts +29 -0
  43. package/src/results/repair-policy.ts +24 -0
  44. package/src/results/report-result.ts +77 -0
  45. package/src/results/source-identity.ts +115 -0
  46. package/src/results/specialist-checkpoint.ts +227 -0
  47. package/src/results/verification-controller.ts +308 -0
  48. package/src/utils/messaging.ts +47 -10
  49. package/src/utils/models.ts +13 -0
  50. package/src/utils/paths.ts +4 -0
  51. package/src/utils/report-events.ts +95 -10
  52. package/src/utils/teams.ts +3 -1
  53. package/src/utils/write-queue.ts +3 -0
@@ -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
+ }
@@ -0,0 +1,308 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { Type, type Static } from "@sinclair/typebox";
5
+ import { Value } from "@sinclair/typebox/value";
6
+ import * as paths from "../utils/paths";
7
+ import { withLock } from "../utils/lock";
8
+ import { AssignedCheckSchema, CheckJournal, assignmentBinding, checkIdentity, type AssignedCheck, type CheckRecord } from "./check-journal";
9
+ import { normalizeCheckPolicy, readCheckEvidence, verifyAssignedChecks, type CheckDefinition, type CheckEvidence } from "./check-policy";
10
+ import { observeCheckSource, runAssignedCheck, type CheckRunnerOptions } from "./check-runner";
11
+ import { syncPathAndParents, writeJsonDurably } from "./durable-json";
12
+ import { normalizeReportedTaskDetails, type ReportResult } from "./report-result";
13
+ import { normalizeRepairPolicy, type RepairPolicy, type RepairResult, type RepairState } from "./repair-policy";
14
+
15
+ const text = Type.String({ minLength: 1 });
16
+ const stageStates = ["running", "not-needed", "requested", "repaired", "exhausted", "cancelled", "declined", "blocked"] as const;
17
+ const StageSchema = Type.Object({
18
+ submissionId: text, signature: text, token: text,
19
+ attempt: Type.Integer({ minimum: 1, maximum: 6 }),
20
+ state: Type.Union(stageStates.map(state => Type.Literal(state))),
21
+ checkIds: Type.Array(Type.String({ pattern: "^check:[a-f0-9]{64}$" })),
22
+ }, { additionalProperties: false });
23
+ const LedgerSchema = Type.Object({
24
+ version: Type.Literal(1), assignments: Type.Array(AssignedCheckSchema, { minItems: 1 }),
25
+ maxAttempts: Type.Integer({ minimum: 1, maximum: 5 }), cancelled: Type.Boolean(),
26
+ stages: Type.Array(StageSchema, { maxItems: 6 }),
27
+ }, { additionalProperties: false });
28
+ type Stage = Static<typeof StageSchema>;
29
+ type Ledger = Static<typeof LedgerSchema>;
30
+
31
+ export interface VerificationControllerOptions {
32
+ teamName: string;
33
+ result: ReportResult;
34
+ cwd: string;
35
+ checks?: CheckDefinition[];
36
+ repair?: RepairPolicy;
37
+ }
38
+ export interface RepairRequest { id: string; attempt: number; checks: CheckRecord[] }
39
+ export interface VerificationDecision { result: ReportResult; checks: CheckRecord[]; request?: RepairRequest }
40
+
41
+ function identity(value: unknown): string {
42
+ return `repair:${crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
43
+ }
44
+
45
+ function repairJournalPath(teamName: string, reportId: string): string {
46
+ return path.join(paths.teamDir(teamName), "repairs", `${identity(reportId).slice(7)}.json`);
47
+ }
48
+
49
+ function readLedger(file: string): Ledger | undefined {
50
+ let contents: string;
51
+ try { contents = fs.readFileSync(file, "utf8"); }
52
+ catch (error) {
53
+ if (typeof error === "object" && error !== null && Reflect.get(error, "code") === "ENOENT") return undefined;
54
+ throw error;
55
+ }
56
+ let value: unknown;
57
+ try { value = JSON.parse(contents); } catch { throw new Error("Corrupt repair ledger JSON."); }
58
+ return parseLedger(value);
59
+ }
60
+
61
+ function parseLedger(value: unknown): Ledger {
62
+ if (!Value.Check(LedgerSchema, value)) throw new Error("Corrupt or incompatible repair ledger.");
63
+ const names = value.assignments.map(assignment => assignment.name);
64
+ if (new Set(names).size !== names.length || value.assignments.some(assignment => assignment.attempt !== 1)
65
+ || value.stages.length > value.maxAttempts + 1
66
+ || new Set(value.stages.map(stage => stage.submissionId)).size !== value.stages.length) throw new Error("Corrupt repair assignment binding.");
67
+ for (const [index, stage] of value.stages.entries()) {
68
+ if (stage.attempt !== index + 1 || (index > 0 && value.stages[index - 1].state !== "requested")
69
+ || stage.checkIds.length !== names.length || stage.checkIds.some((id, item) =>
70
+ !Array.from({ length: stage.attempt }, (_, attempt) => checkIdentity({ ...value.assignments[item], attempt: attempt + 1 })).includes(id))) {
71
+ throw new Error("Corrupt repair stage binding.");
72
+ }
73
+ }
74
+ return structuredClone(value);
75
+ }
76
+
77
+ export class VerificationController {
78
+ readonly journalPath: string;
79
+ private readonly context: VerificationControllerOptions;
80
+ private readonly checks: CheckDefinition[] | undefined;
81
+ private readonly repair: RepairPolicy | undefined;
82
+ private readonly assignments: AssignedCheck[];
83
+ private observedLedger = false;
84
+
85
+ constructor(options: VerificationControllerOptions) {
86
+ this.context = structuredClone(options);
87
+ this.checks = normalizeCheckPolicy(options.checks);
88
+ this.repair = normalizeRepairPolicy(options.repair);
89
+ if (this.repair && !this.checks?.length) throw new Error("Repair policy requires explicitly assigned checks.");
90
+ this.assignments = (this.checks ?? []).map(check => ({
91
+ ...check, inputs: check.inputs ? [...new Set(check.inputs)].sort() : undefined,
92
+ taskId: options.result.taskId, runId: options.result.runId, reportId: options.result.reportId,
93
+ cwd: options.cwd, attempt: 1,
94
+ }));
95
+ this.journalPath = repairJournalPath(options.teamName, options.result.reportId);
96
+ }
97
+
98
+ static async observe(teamName: string, result: ReportResult, signal?: AbortSignal): Promise<VerificationDecision> {
99
+ const uncertain = async (error: unknown): Promise<VerificationDecision> => {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ const evidence = await readCheckEvidence(teamName, result, signal).catch(() => ({ verification: result.verification, checks: [] }));
102
+ return { result: { ...structuredClone(result), verification: { ...evidence.verification, state: "pending", error: message },
103
+ repair: { controllerId: identity(result.reportId), journalPath: repairJournalPath(teamName, result.reportId), state: "pending", outcome: "blocked", error: message } }, checks: evidence.checks };
104
+ };
105
+ let ledger: Ledger | undefined;
106
+ try { ledger = readLedger(repairJournalPath(teamName, result.reportId)); }
107
+ catch (error) { return uncertain(error); }
108
+ if (!ledger && !result.repair) {
109
+ const evidence = await readCheckEvidence(teamName, result, signal);
110
+ return { result: { ...structuredClone(result), verification: evidence.verification }, checks: evidence.checks };
111
+ }
112
+ try {
113
+ if (!ledger) throw new Error(`Referenced repair ledger is unavailable.${result.verification.error ? ` ${result.verification.error}` : ""}`);
114
+ if (ledger.assignments.some(assignment => assignment.taskId !== result.taskId || assignment.runId !== result.runId || assignment.reportId !== result.reportId)) {
115
+ throw new Error("Repair ledger belongs to another task/run binding.");
116
+ }
117
+ const controller = new VerificationController({ teamName, result, cwd: ledger.assignments[0].cwd,
118
+ checks: ledger.assignments.map(({ name, command, timeoutSeconds, inputs }) => ({ name, command, timeoutSeconds, inputs })),
119
+ repair: { maxAttempts: ledger.maxAttempts } });
120
+ const current = controller.read();
121
+ if (!current) throw new Error("Referenced repair ledger is unavailable.");
122
+ if (result.verification.error && ["running", "requested"].includes(current.stages.at(-1)?.state ?? "")) {
123
+ return uncertain(result.verification.error);
124
+ }
125
+ return await controller.decision(current, current.stages.at(-1), undefined, signal);
126
+ } catch (error) { return uncertain(error); }
127
+ }
128
+
129
+ pendingRepair(error: unknown): RepairResult | undefined {
130
+ if (!this.repair) return undefined;
131
+ return {
132
+ ...this.context.result.repair,
133
+ controllerId: identity(this.context.result.reportId), journalPath: this.journalPath,
134
+ state: "pending", outcome: "blocked", maxAttempts: this.repair.maxAttempts,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ };
137
+ }
138
+
139
+ private read(): Ledger | undefined {
140
+ const ledger = readLedger(this.journalPath);
141
+ if (!ledger) {
142
+ const result = this.context.result;
143
+ if (this.observedLedger || result.repair || (this.repair && (result.verification.checkIds?.length || result.verification.error))) {
144
+ throw new Error(`Referenced repair ledger is unavailable; inspect ${this.journalPath}.${result.verification.error ? ` ${result.verification.error}` : ""}`);
145
+ }
146
+ return undefined;
147
+ }
148
+ this.observedLedger = true;
149
+ if (ledger.maxAttempts !== this.repair?.maxAttempts
150
+ || JSON.stringify(ledger.assignments.map(assignmentBinding)) !== JSON.stringify(this.assignments.map(assignmentBinding))) {
151
+ throw new Error("Repair policy or assignment binding has changed.");
152
+ }
153
+ return ledger;
154
+ }
155
+
156
+ private async update<T>(change: (ledger: Ledger) => { value: T; changed: boolean }): Promise<T> {
157
+ fs.mkdirSync(path.dirname(this.journalPath), { recursive: true, mode: 0o700 });
158
+ return withLock(this.journalPath, async () => {
159
+ let ledger = this.read();
160
+ if (!ledger) {
161
+ const journal = new CheckJournal(this.context.teamName);
162
+ for (const assignment of this.assignments) {
163
+ if (await journal.read(checkIdentity(assignment))) throw new Error(`Referenced repair ledger is unavailable; inspect ${this.journalPath}.`);
164
+ }
165
+ ledger = { version: 1, assignments: this.assignments, maxAttempts: this.repair!.maxAttempts, cancelled: false, stages: [] };
166
+ }
167
+ const changed = change(ledger);
168
+ this.observedLedger = true;
169
+ if (changed.changed) writeJsonDurably(this.journalPath, parseLedger(ledger));
170
+ else syncPathAndParents(this.journalPath);
171
+ return structuredClone(changed.value);
172
+ });
173
+ }
174
+
175
+ async cancel(): Promise<void> {
176
+ if (!this.repair) {
177
+ this.read();
178
+ return;
179
+ }
180
+ await this.update(ledger => {
181
+ const last = ledger.stages.at(-1);
182
+ const terminal = last && !["running", "requested"].includes(last.state);
183
+ const changed = !ledger.cancelled && !terminal;
184
+ if (changed) ledger.cancelled = true;
185
+ return { value: undefined, changed };
186
+ });
187
+ }
188
+
189
+ async cancelAndRequireSettled(): Promise<void> {
190
+ const verificationError = this.context.result.verification.error;
191
+ await this.cancel();
192
+ const ledger = this.read();
193
+ const stage = ledger?.stages.at(-1);
194
+ if (stage?.state === "running" || (stage?.state === "requested" && verificationError)) {
195
+ throw new Error(`Repair verification remains unresolved; inspect ${this.journalPath}.${verificationError ? ` ${verificationError}` : ""}`);
196
+ }
197
+ const journal = new CheckJournal(this.context.teamName);
198
+ for (const [index, id] of (stage?.checkIds ?? []).entries()) {
199
+ const record = await journal.read(id);
200
+ if (record && assignmentBinding(record.assignment) !== assignmentBinding({ ...this.assignments[index], attempt: record.assignment.attempt })) {
201
+ throw new Error(`Repair evidence binding is invalid; inspect ${this.journalPath}.`);
202
+ }
203
+ if (record?.state === "claimed") throw new Error(`Repair check ownership remains unresolved; inspect ${this.journalPath}.`);
204
+ }
205
+ }
206
+
207
+ private async evidence(checkIds: string[], signal?: AbortSignal): Promise<CheckEvidence> {
208
+ return readCheckEvidence(this.context.teamName, {
209
+ ...this.context.result, verification: { state: "pending", checkIds },
210
+ }, signal);
211
+ }
212
+
213
+ private async decision(ledger: Ledger, stage: Stage | undefined, evidence?: CheckEvidence, signal?: AbortSignal): Promise<VerificationDecision> {
214
+ evidence ??= await this.evidence(stage?.checkIds ?? [], signal);
215
+ const state: RepairState = stage?.state === "running" || evidence.verification.state === "pending"
216
+ ? "pending" : ledger.cancelled ? "cancelled" : stage?.state ?? "pending";
217
+ if (state === "pending") evidence.verification.state = "pending";
218
+ const requested = ledger.stages.filter(item => item.state === "requested");
219
+ const repair: RepairResult = {
220
+ controllerId: identity(this.context.result.reportId), journalPath: this.journalPath, state, maxAttempts: ledger.maxAttempts,
221
+ attemptsUsed: requested.length, requestIds: requested.map(item => identity([this.context.result.reportId, item.attempt])),
222
+ ...(["pending", "exhausted", "cancelled", "declined", "blocked"].includes(state) ? { outcome: "blocked" as const } : {}),
223
+ };
224
+ return {
225
+ result: { ...structuredClone(this.context.result), verification: evidence.verification, repair }, checks: evidence.checks,
226
+ ...(state === "requested" && stage ? { request: {
227
+ id: identity([this.context.result.reportId, stage.attempt]), attempt: stage.attempt,
228
+ checks: evidence.checks.filter(check => check.state !== "passed"),
229
+ } } : {}),
230
+ };
231
+ }
232
+
233
+ private async runPlan(stage: Stage, previous: Stage | undefined, options: CheckRunnerOptions): Promise<CheckEvidence> {
234
+ const journal = new CheckJournal(this.context.teamName);
235
+ const prior = previous ? await Promise.all(previous.checkIds.map(id => journal.read(id))) : [];
236
+ for (const [index, record] of prior.entries()) {
237
+ if (!record || assignmentBinding(record.assignment) !== assignmentBinding({ ...this.assignments[index], attempt: record.assignment.attempt })) {
238
+ throw new Error("Prior repair evidence has an incompatible assignment binding.");
239
+ }
240
+ }
241
+ if (prior.some(record => record?.state === "claimed")) return this.evidence(previous!.checkIds, options.signal);
242
+ const checkIds = [...stage.checkIds];
243
+ for (const [index, assignment] of this.assignments.entries()) {
244
+ if (options.signal?.aborted) break;
245
+ const observed = prior[index] && await observeCheckSource(prior[index]!, options.signal);
246
+ if (observed?.state === "passed") { checkIds[index] = observed.checkId; continue; }
247
+ const record = await runAssignedCheck(journal, { ...assignment, attempt: stage.attempt }, options);
248
+ checkIds[index] = record.checkId;
249
+ if (record.state === "claimed" || options.signal?.aborted) break;
250
+ }
251
+ return this.evidence(checkIds, options.signal);
252
+ }
253
+
254
+ async verify(submissionId: string, options: CheckRunnerOptions): Promise<VerificationDecision> {
255
+ if (!this.repair) {
256
+ this.read();
257
+ const evidence = await verifyAssignedChecks(this.context.teamName, this.context.result, this.context.cwd, this.checks, options);
258
+ const result = { ...structuredClone(this.context.result), verification: evidence.verification };
259
+ delete result.repair;
260
+ return { result, checks: evidence.checks };
261
+ }
262
+ if (typeof submissionId !== "string" || !submissionId.trim()) throw new Error("Repair verification requires a runtime submission identity.");
263
+ if (options.signal?.aborted) await this.cancel();
264
+ const signature = identity(normalizeReportedTaskDetails(this.context.result));
265
+ const reservation = await this.update(ledger => {
266
+ const existing = ledger.stages.find(stage => stage.submissionId === submissionId);
267
+ if (existing && existing.signature !== signature) throw new Error("Repair submission identity binding has changed.");
268
+ const previous = ledger.stages.at(-1);
269
+ if (ledger.cancelled || existing || (previous && previous.state !== "requested")) {
270
+ return { value: { ledger, stage: previous ?? existing, previous: undefined as Stage | undefined, granted: false }, changed: false };
271
+ }
272
+ const attempt = (previous?.attempt ?? 0) + 1;
273
+ const declined = previous && ["blocked", "failed", "cancelled"].includes(this.context.result.outcome ?? "");
274
+ const stage: Stage = {
275
+ submissionId, signature, token: crypto.randomUUID(), attempt,
276
+ state: declined ? "declined" : "running",
277
+ checkIds: declined ? [...previous.checkIds] : this.assignments.map(assignment => checkIdentity({ ...assignment, attempt })),
278
+ };
279
+ ledger.stages.push(stage);
280
+ return { value: { ledger, stage, previous, granted: !declined }, changed: true };
281
+ });
282
+ if (!reservation.granted || !reservation.stage) {
283
+ const observed = await this.decision(reservation.ledger, reservation.stage, undefined, options.signal);
284
+ if (!options.signal?.aborted) return observed;
285
+ await this.cancel();
286
+ const cancelled = this.read();
287
+ if (!cancelled) throw new Error("Repair ledger disappeared during cancellation.");
288
+ return this.decision(cancelled, cancelled.stages.at(-1), undefined, options.signal);
289
+ }
290
+ const evidence = await this.runPlan(reservation.stage, reservation.previous, options);
291
+ const finished = await this.update(ledger => {
292
+ const stage = ledger.stages.find(item => item.token === reservation.stage!.token);
293
+ if (!stage || stage.state !== "running") throw new Error("Repair reservation changed before completion.");
294
+ stage.checkIds = evidence.verification.checkIds ?? [];
295
+ if (options.signal?.aborted || evidence.checks.some(check => check.state === "cancelled")) ledger.cancelled = true;
296
+ const repairable = evidence.checks.length === this.assignments.length
297
+ && evidence.checks.every(check => ["passed", "failed", "stale", "timed-out"].includes(check.state));
298
+ if (evidence.verification.state === "pending") stage.state = "running";
299
+ else if (ledger.cancelled) stage.state = "cancelled";
300
+ else if (evidence.verification.state === "passed") stage.state = stage.attempt === 1 ? "not-needed" : "repaired";
301
+ else if (["blocked", "failed", "cancelled"].includes(this.context.result.outcome ?? "")) stage.state = "declined";
302
+ else if (!repairable) stage.state = "blocked";
303
+ else stage.state = stage.attempt <= ledger.maxAttempts ? "requested" : "exhausted";
304
+ return { value: { ledger, stage }, changed: true };
305
+ });
306
+ return this.decision(finished.ledger, finished.stage, evidence);
307
+ }
308
+ }