@llblab/pi-actors 0.41.1 → 0.42.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 +11 -6
- package/BACKLOG.md +1 -65
- package/CHANGELOG.md +25 -0
- package/README.md +24 -4
- package/dist/index.js +25 -121
- package/dist/lib/async-runs.d.ts +37 -5
- package/dist/lib/async-runs.js +181 -51
- package/dist/lib/automatic-review-runtime.d.ts +18 -0
- package/dist/lib/automatic-review-runtime.js +96 -0
- package/dist/lib/command-templates.js +1 -1
- package/dist/lib/draft-consolidation-transaction.d.ts +65 -0
- package/dist/lib/draft-consolidation-transaction.js +610 -0
- package/dist/lib/draft-consolidation.d.ts +35 -0
- package/dist/lib/draft-consolidation.js +126 -0
- package/dist/lib/draft-review.d.ts +56 -0
- package/dist/lib/draft-review.js +254 -0
- package/dist/lib/draft-sleep.d.ts +65 -0
- package/dist/lib/draft-sleep.js +468 -0
- package/dist/lib/file-state.d.ts +8 -1
- package/dist/lib/file-state.js +115 -18
- package/dist/lib/inspector-actions.d.ts +16 -0
- package/dist/lib/inspector-actions.js +57 -0
- package/dist/lib/inspector-command.d.ts +7 -0
- package/dist/lib/inspector-command.js +37 -0
- package/dist/lib/inspector-overlay.d.ts +23 -3
- package/dist/lib/inspector-overlay.js +327 -70
- package/dist/lib/inspector.d.ts +9 -0
- package/dist/lib/inspector.js +49 -1
- package/dist/lib/observability.d.ts +13 -0
- package/dist/lib/observability.js +160 -6
- package/dist/lib/paths.d.ts +7 -0
- package/dist/lib/paths.js +28 -0
- package/dist/lib/recipes-discovery.js +32 -24
- package/dist/lib/recipes-usage.d.ts +18 -6
- package/dist/lib/recipes-usage.js +445 -34
- package/dist/lib/review-control.d.ts +14 -0
- package/dist/lib/review-control.js +111 -0
- package/dist/lib/review-diagnostics.d.ts +11 -0
- package/dist/lib/review-diagnostics.js +148 -0
- package/dist/lib/review-projection.d.ts +14 -0
- package/dist/lib/review-projection.js +170 -0
- package/dist/lib/run-ui-runtime.d.ts +18 -0
- package/dist/lib/run-ui-runtime.js +123 -0
- package/dist/lib/runs-artifacts.d.ts +1 -1
- package/dist/lib/runs-artifacts.js +1 -1
- package/dist/lib/runs-control.d.ts +10 -2
- package/dist/lib/runs-control.js +37 -7
- package/dist/lib/runs-identity.d.ts +1 -1
- package/dist/lib/runs-identity.js +1 -1
- package/dist/lib/runs-index.d.ts +11 -2
- package/dist/lib/runs-index.js +46 -23
- package/dist/lib/runs-mailbox.d.ts +1 -1
- package/dist/lib/runs-mailbox.js +1 -1
- package/dist/lib/runs-messages.d.ts +1 -1
- package/dist/lib/runs-messages.js +1 -1
- package/dist/lib/runs-outbox.d.ts +1 -1
- package/dist/lib/runs-outbox.js +1 -1
- package/dist/lib/runs-ownership.d.ts +1 -1
- package/dist/lib/runs-ownership.js +18 -4
- package/dist/lib/runs-parent-teardown.d.ts +51 -0
- package/dist/lib/runs-parent-teardown.js +172 -0
- package/dist/lib/runs-process.d.ts +1 -1
- package/dist/lib/runs-process.js +5 -4
- package/dist/lib/runs-retention.d.ts +1 -1
- package/dist/lib/runs-retention.js +1 -1
- package/dist/lib/runs-start.d.ts +5 -3
- package/dist/lib/runs-start.js +7 -48
- package/dist/lib/runs-status.d.ts +5 -3
- package/dist/lib/runs-status.js +7 -6
- package/dist/lib/runtime.d.ts +7 -1
- package/dist/lib/runtime.js +32 -18
- package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
- package/dist/lib/tool-review-lineage-transaction.js +597 -0
- package/dist/lib/tool-review-lineage.d.ts +24 -0
- package/dist/lib/tool-review-lineage.js +98 -0
- package/dist/lib/tool-review-scheduler.d.ts +80 -0
- package/dist/lib/tool-review-scheduler.js +494 -0
- package/dist/lib/tool-review-transaction.d.ts +50 -0
- package/dist/lib/tool-review-transaction.js +362 -0
- package/dist/lib/tool-review.d.ts +56 -0
- package/dist/lib/tool-review.js +197 -0
- package/dist/lib/tools-inspect.js +28 -4
- package/dist/lib/tools-local.js +21 -4
- package/dist/lib/tools-message.d.ts +1 -0
- package/dist/lib/tools-message.js +29 -17
- package/dist/lib/tools-spawn.js +14 -2
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +1 -0
- package/dist/recipes/draft-review.json +24 -0
- package/dist/recipes/tool-review.json +24 -0
- package/dist/scripts/async-runner.mjs +9 -9
- package/dist/scripts/build-dist.mjs +6 -1
- package/dist/scripts/conformance.mjs +6 -1
- package/dist/scripts/recipe-utils.mjs +3 -3
- package/dist/scripts/release-gates.mjs +165 -0
- package/dist/skills/actors/SKILL.md +9 -8
- package/dist/skills/swarm/SKILL.md +1 -1
- package/docs/actor-inspector.md +20 -11
- package/docs/async-runs.md +16 -2
- package/docs/recipe-library.md +7 -3
- package/docs/template-recipes.md +4 -11
- package/docs/tool-registry.md +13 -4
- package/index.ts +27 -142
- package/lib/async-runs.ts +283 -67
- package/lib/automatic-review-runtime.ts +135 -0
- package/lib/command-templates.ts +1 -1
- package/lib/draft-consolidation-transaction.ts +821 -0
- package/lib/draft-consolidation.ts +181 -0
- package/lib/draft-review.ts +325 -0
- package/lib/draft-sleep.ts +576 -0
- package/lib/file-state.ts +143 -19
- package/lib/inspector-actions.ts +79 -0
- package/lib/inspector-command.ts +54 -0
- package/lib/inspector-overlay.ts +377 -91
- package/lib/inspector.ts +73 -1
- package/lib/observability.ts +194 -5
- package/lib/paths.ts +43 -0
- package/lib/recipes-discovery.ts +34 -26
- package/lib/recipes-usage.ts +569 -40
- package/lib/review-control.ts +137 -0
- package/lib/review-diagnostics.ts +164 -0
- package/lib/review-projection.ts +200 -0
- package/lib/run-ui-runtime.ts +153 -0
- package/lib/runs-artifacts.ts +1 -1
- package/lib/runs-control.ts +69 -6
- package/lib/runs-identity.ts +1 -1
- package/lib/runs-index.ts +57 -21
- package/lib/runs-mailbox.ts +1 -1
- package/lib/runs-messages.ts +1 -1
- package/lib/runs-outbox.ts +1 -1
- package/lib/runs-ownership.ts +23 -4
- package/lib/runs-parent-teardown.ts +257 -0
- package/lib/runs-process.ts +5 -4
- package/lib/runs-retention.ts +1 -1
- package/lib/runs-start.ts +13 -68
- package/lib/runs-status.ts +17 -8
- package/lib/runtime.ts +34 -17
- package/lib/tool-review-lineage-transaction.ts +881 -0
- package/lib/tool-review-lineage.ts +145 -0
- package/lib/tool-review-scheduler.ts +635 -0
- package/lib/tool-review-transaction.ts +563 -0
- package/lib/tool-review.ts +270 -0
- package/lib/tools-inspect.ts +35 -4
- package/lib/tools-local.ts +29 -4
- package/lib/tools-message.ts +45 -30
- package/lib/tools-spawn.ts +22 -2
- package/lib/tools.ts +5 -0
- package/package.json +5 -3
- package/recipes/draft-review.json +24 -0
- package/recipes/tool-review.json +24 -0
- package/scripts/async-runner.mjs +9 -9
- package/scripts/build-dist.mjs +6 -1
- package/scripts/conformance.mjs +6 -1
- package/scripts/recipe-utils.mjs +3 -3
- package/scripts/release-gates.mjs +165 -0
- package/skills/actors/SKILL.md +9 -8
- package/skills/swarm/SKILL.md +1 -1
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic active-tool portfolio review admission.
|
|
3
|
+
* Zones: revision eligibility, immutable thirty-six-tool capture, persisted review state
|
|
4
|
+
* Owns exact portfolio admission; reviewer execution and recipe mutation remain separate domains.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
|
|
12
|
+
import * as Paths from "./paths.ts";
|
|
13
|
+
import * as RecipesDiscovery from "./recipes-discovery.ts";
|
|
14
|
+
import * as RecipesReferences from "./recipes-references.ts";
|
|
15
|
+
import * as RecipesUsage from "./recipes-usage.ts";
|
|
16
|
+
import * as ReviewProjection from "./review-projection.ts";
|
|
17
|
+
import * as ToolReviewLineage from "./tool-review-lineage.ts";
|
|
18
|
+
import * as ToolReviewLineageTransaction from "./tool-review-lineage-transaction.ts";
|
|
19
|
+
import * as ToolReviewTransaction from "./tool-review-transaction.ts";
|
|
20
|
+
import { findUnsafeRecipeReason } from "./draft-review.ts";
|
|
21
|
+
import {
|
|
22
|
+
parseToolReviewResult,
|
|
23
|
+
TOOL_REVIEW_THRESHOLD,
|
|
24
|
+
validateToolReviewInput,
|
|
25
|
+
validateToolReviewResult,
|
|
26
|
+
type ToolReviewDecision,
|
|
27
|
+
type ToolReviewInput,
|
|
28
|
+
type ToolReviewTool,
|
|
29
|
+
} from "./tool-review.ts";
|
|
30
|
+
|
|
31
|
+
export const TOOL_REVIEW_RESULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
export interface ToolReviewBatch {
|
|
34
|
+
inputPath: string;
|
|
35
|
+
reviewerInputPath: string;
|
|
36
|
+
reviewId: string;
|
|
37
|
+
toolNames: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ToolReviewAdmissionState {
|
|
41
|
+
approvedPath?: string;
|
|
42
|
+
attempts: number;
|
|
43
|
+
failedStage?: string;
|
|
44
|
+
lastError?: string;
|
|
45
|
+
lineageJournalPath?: string;
|
|
46
|
+
inputPath: string;
|
|
47
|
+
nextAction?: string;
|
|
48
|
+
phase: "approved" | "captured" | "completed" | "failed" | "launched" | "lineage_pending" | "processing_failed";
|
|
49
|
+
processingAttempts?: number;
|
|
50
|
+
reviewerInputPath: string;
|
|
51
|
+
reviewId: string;
|
|
52
|
+
runId?: string;
|
|
53
|
+
toolNames: string[];
|
|
54
|
+
transactionEvidencePath?: string;
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ToolReviewSchedulerDeps extends CaptureToolReviewOptions {
|
|
59
|
+
delayMs?: number;
|
|
60
|
+
hasActiveActors(): boolean;
|
|
61
|
+
launch(batch: ToolReviewBatch): { run: string };
|
|
62
|
+
process?(state: ToolReviewAdmissionState): ToolReviewProcessResult;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ToolReviewProcessResult {
|
|
66
|
+
approvedPath?: string;
|
|
67
|
+
error?: string;
|
|
68
|
+
outcome: "approved" | "pending" | "processing_failed" | "review_failed";
|
|
69
|
+
stage?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ToolReviewScheduler {
|
|
73
|
+
close(): void;
|
|
74
|
+
schedule(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CaptureToolReviewOptions {
|
|
78
|
+
batchRoot?(reviewId: string): string;
|
|
79
|
+
createReviewId?(): string;
|
|
80
|
+
now?(): Date;
|
|
81
|
+
recipeRoot?: string;
|
|
82
|
+
statePath?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sha256(path: string): string {
|
|
86
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readAdmissionState(path: string): ToolReviewAdmissionState | undefined {
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as ToolReviewAdmissionState;
|
|
92
|
+
return value && typeof value === "object" ? value : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function usageOrder(tool: ToolReviewTool): string {
|
|
99
|
+
const firstSeen = tool.usage?.first_seen;
|
|
100
|
+
return typeof firstSeen === "string" ? firstSeen : "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listEligibleToolReviewRecipes(
|
|
104
|
+
recipeRoot = Paths.getRecipeRoot(),
|
|
105
|
+
now = new Date(),
|
|
106
|
+
): ToolReviewTool[] {
|
|
107
|
+
const discovered = RecipesDiscovery.discoverRecipeSources([
|
|
108
|
+
{ root: recipeRoot, defaultTool: true, mutableUsage: true },
|
|
109
|
+
]);
|
|
110
|
+
return [...discovered.active.values()]
|
|
111
|
+
.filter((entry) => entry.active && entry.tool && !entry.invalid && !entry.disabled)
|
|
112
|
+
.flatMap((entry) => {
|
|
113
|
+
if (!RecipesUsage.ensureRecipeLineage(entry.path, now, recipeRoot)) return [];
|
|
114
|
+
if (RecipesUsage.isCurrentRecipeRevisionReviewed(entry.path, recipeRoot)) return [];
|
|
115
|
+
const recipe = RecipesReferences.readRawRecipeConfig(entry.path);
|
|
116
|
+
if (
|
|
117
|
+
!recipe ||
|
|
118
|
+
entry.riskLabels.includes("risk.secret_touching") ||
|
|
119
|
+
findUnsafeRecipeReason(recipe)
|
|
120
|
+
) return [];
|
|
121
|
+
return [{
|
|
122
|
+
name: entry.id,
|
|
123
|
+
path: entry.path,
|
|
124
|
+
recipe,
|
|
125
|
+
riskLabels: [...entry.riskLabels],
|
|
126
|
+
sha256: sha256(entry.path),
|
|
127
|
+
usage: RecipesUsage.readRecipeUsage(entry.path, recipeRoot),
|
|
128
|
+
valid: true,
|
|
129
|
+
} satisfies ToolReviewTool];
|
|
130
|
+
})
|
|
131
|
+
.sort((left, right) =>
|
|
132
|
+
usageOrder(left).localeCompare(usageOrder(right)) ||
|
|
133
|
+
left.name.localeCompare(right.name),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function captureToolReviewBatchLocked(
|
|
138
|
+
options: CaptureToolReviewOptions,
|
|
139
|
+
recipeRoot: string,
|
|
140
|
+
statePath: string,
|
|
141
|
+
now: () => Date,
|
|
142
|
+
): ToolReviewBatch | undefined {
|
|
143
|
+
const state = readAdmissionState(statePath);
|
|
144
|
+
if (
|
|
145
|
+
state?.phase === "captured" ||
|
|
146
|
+
state?.phase === "launched" ||
|
|
147
|
+
state?.phase === "processing_failed"
|
|
148
|
+
) return undefined;
|
|
149
|
+
const eligible = listEligibleToolReviewRecipes(recipeRoot, now());
|
|
150
|
+
if (eligible.length < TOOL_REVIEW_THRESHOLD) return undefined;
|
|
151
|
+
const reviewId = (options.createReviewId ?? randomUUID)();
|
|
152
|
+
const inputPath = join(
|
|
153
|
+
(options.batchRoot ?? Paths.getToolReviewBatchDir)(reviewId),
|
|
154
|
+
"input.json",
|
|
155
|
+
);
|
|
156
|
+
const tools = eligible.slice(0, TOOL_REVIEW_THRESHOLD);
|
|
157
|
+
const input: ToolReviewInput = {
|
|
158
|
+
createdAt: now().toISOString(),
|
|
159
|
+
reviewId,
|
|
160
|
+
tools,
|
|
161
|
+
};
|
|
162
|
+
const validation = validateToolReviewInput(input);
|
|
163
|
+
if (!validation.ok) {
|
|
164
|
+
throw new Error(`Tool review input rejected: ${validation.errors.join("; ")}`);
|
|
165
|
+
}
|
|
166
|
+
writeJsonAtomic(inputPath, input);
|
|
167
|
+
const reviewerInputPath = join(dirname(inputPath), "review-input.json");
|
|
168
|
+
writeJsonAtomic(reviewerInputPath, ReviewProjection.projectToolReviewInput(input));
|
|
169
|
+
const toolNames = tools.map((tool) => tool.name);
|
|
170
|
+
writeJsonAtomic(statePath, {
|
|
171
|
+
attempts: 0,
|
|
172
|
+
inputPath,
|
|
173
|
+
phase: "captured",
|
|
174
|
+
reviewerInputPath,
|
|
175
|
+
reviewId,
|
|
176
|
+
toolNames,
|
|
177
|
+
updatedAt: input.createdAt,
|
|
178
|
+
} satisfies ToolReviewAdmissionState);
|
|
179
|
+
return { inputPath, reviewerInputPath, reviewId, toolNames };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function captureToolReviewBatch(
|
|
183
|
+
options: CaptureToolReviewOptions = {},
|
|
184
|
+
): ToolReviewBatch | undefined {
|
|
185
|
+
const recipeRoot = options.recipeRoot ?? Paths.getRecipeRoot();
|
|
186
|
+
const statePath = options.statePath ?? Paths.getToolReviewStatePath();
|
|
187
|
+
const now = options.now ?? (() => new Date());
|
|
188
|
+
return withFileMutationLock(statePath, () =>
|
|
189
|
+
captureToolReviewBatchLocked(options, recipeRoot, statePath, now),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function approvedTargets(
|
|
194
|
+
input: ToolReviewInput,
|
|
195
|
+
decisions: ToolReviewDecision[],
|
|
196
|
+
recipeRoot: string,
|
|
197
|
+
): ToolReviewTransaction.ToolReviewApprovedTarget[] {
|
|
198
|
+
const targets = new Map<string, ToolReviewTransaction.ToolReviewApprovedTarget>();
|
|
199
|
+
const tools = new Map(input.tools.map((tool) => [tool.name, tool]));
|
|
200
|
+
const add = (
|
|
201
|
+
name: string,
|
|
202
|
+
path: string,
|
|
203
|
+
recipe: Record<string, unknown>,
|
|
204
|
+
lineage: ToolReviewTransaction.ToolReviewApprovedTarget["lineage"],
|
|
205
|
+
source: string,
|
|
206
|
+
): void => {
|
|
207
|
+
const existing = targets.get(path);
|
|
208
|
+
if (existing) {
|
|
209
|
+
if (lineage !== "merge" || existing.lineage !== "merge") {
|
|
210
|
+
throw new Error(`Duplicate tool review target: ${path}`);
|
|
211
|
+
}
|
|
212
|
+
existing.sources.push(source);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const sourceAtPath = input.tools.find((tool) => tool.path === path);
|
|
216
|
+
if (existsSync(path) && !sourceAtPath) {
|
|
217
|
+
throw new Error(`Tool review target appeared after capture: ${path}`);
|
|
218
|
+
}
|
|
219
|
+
targets.set(path, {
|
|
220
|
+
expectedSha256: sourceAtPath?.sha256 ?? null,
|
|
221
|
+
lineage,
|
|
222
|
+
name,
|
|
223
|
+
path,
|
|
224
|
+
recipe,
|
|
225
|
+
sources: [source],
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
for (const decision of decisions) {
|
|
229
|
+
const source = tools.get(decision.source)!;
|
|
230
|
+
if (decision.action === "keep") continue;
|
|
231
|
+
if (decision.action === "demote") {
|
|
232
|
+
add(
|
|
233
|
+
decision.source,
|
|
234
|
+
join(recipeRoot, "drafts", `${decision.source}.json`),
|
|
235
|
+
source.recipe,
|
|
236
|
+
"demote",
|
|
237
|
+
decision.source,
|
|
238
|
+
);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (decision.action === "evolve") {
|
|
242
|
+
const source = tools.get(decision.source)!;
|
|
243
|
+
add(
|
|
244
|
+
decision.target!,
|
|
245
|
+
join(recipeRoot, `${decision.target}.json`),
|
|
246
|
+
source.recipe,
|
|
247
|
+
"evolve",
|
|
248
|
+
decision.source,
|
|
249
|
+
);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (decision.action === "merge") {
|
|
253
|
+
const source = tools.get(decision.source)!;
|
|
254
|
+
add(
|
|
255
|
+
decision.target!,
|
|
256
|
+
join(recipeRoot, `${decision.target}.json`),
|
|
257
|
+
source.recipe,
|
|
258
|
+
"merge",
|
|
259
|
+
decision.source,
|
|
260
|
+
);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${decision.action} requires explicit operator-authored recipe mutation.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return [...targets.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface ProcessToolReviewDeps {
|
|
271
|
+
draftRoot?: string;
|
|
272
|
+
getRunStatus(runId: string): Record<string, unknown>;
|
|
273
|
+
now?(): Date;
|
|
274
|
+
recipeRoot?: string;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function processToolReviewResult(
|
|
278
|
+
state: ToolReviewAdmissionState,
|
|
279
|
+
deps: ProcessToolReviewDeps,
|
|
280
|
+
): ToolReviewProcessResult {
|
|
281
|
+
if (!state.runId) {
|
|
282
|
+
return { error: "review run id is unavailable", outcome: "review_failed", stage: "review_state" };
|
|
283
|
+
}
|
|
284
|
+
let status: Record<string, unknown>;
|
|
285
|
+
try {
|
|
286
|
+
status = deps.getRunStatus(state.runId);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
error: error instanceof Error ? error.message : String(error),
|
|
290
|
+
outcome: "review_failed",
|
|
291
|
+
stage: "review_state",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (status.status === "running") return { outcome: "pending" };
|
|
295
|
+
if (status.status !== "done" || typeof status.state_dir !== "string") {
|
|
296
|
+
return {
|
|
297
|
+
error: `review run ended with status ${String(status.status ?? "unknown")}`,
|
|
298
|
+
outcome: "review_failed",
|
|
299
|
+
stage: "review_run",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
let input: ToolReviewInput;
|
|
303
|
+
let result: ReturnType<typeof parseToolReviewResult>;
|
|
304
|
+
try {
|
|
305
|
+
input = JSON.parse(readFileSync(state.inputPath, "utf8")) as ToolReviewInput;
|
|
306
|
+
const stdoutPath = join(status.state_dir, "stdout.log");
|
|
307
|
+
if (statSync(stdoutPath).size > TOOL_REVIEW_RESULT_MAX_BYTES) {
|
|
308
|
+
throw new Error("Tool reviewer output exceeds the approval limit.");
|
|
309
|
+
}
|
|
310
|
+
result = ReviewProjection.restoreToolReviewResult(
|
|
311
|
+
input,
|
|
312
|
+
parseToolReviewResult(readFileSync(stdoutPath, "utf8")),
|
|
313
|
+
);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
error: error instanceof Error ? error.message : String(error),
|
|
317
|
+
outcome: "review_failed",
|
|
318
|
+
stage: "result_parse",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const validation = validateToolReviewResult(input, result);
|
|
322
|
+
if (!validation.ok) {
|
|
323
|
+
return {
|
|
324
|
+
error: validation.errors.join("; "),
|
|
325
|
+
outcome: "review_failed",
|
|
326
|
+
stage: "result_validation",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
for (const tool of input.tools) {
|
|
331
|
+
if (!existsSync(tool.path) || sha256(tool.path) !== tool.sha256) {
|
|
332
|
+
throw new Error(`Reviewed tool changed after capture: ${tool.name}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
336
|
+
const plan: ToolReviewTransaction.ToolReviewApprovedPlan = {
|
|
337
|
+
createdAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
338
|
+
decisions: result.decisions,
|
|
339
|
+
reviewId: state.reviewId,
|
|
340
|
+
sources: result.decisions.map((decision) => {
|
|
341
|
+
const tool = input.tools.find((candidate) => candidate.name === decision.source)!;
|
|
342
|
+
return {
|
|
343
|
+
action: decision.action,
|
|
344
|
+
name: tool.name,
|
|
345
|
+
path: tool.path,
|
|
346
|
+
sha256: tool.sha256,
|
|
347
|
+
};
|
|
348
|
+
}),
|
|
349
|
+
targets: approvedTargets(input, result.decisions, recipeRoot),
|
|
350
|
+
};
|
|
351
|
+
ToolReviewLineage.projectToolReviewLineage(plan);
|
|
352
|
+
if (Buffer.byteLength(JSON.stringify(plan)) > TOOL_REVIEW_RESULT_MAX_BYTES) {
|
|
353
|
+
throw new Error("Tool review approved plan exceeds the persistence limit.");
|
|
354
|
+
}
|
|
355
|
+
const approvedPath = join(dirname(state.inputPath), "approved.json");
|
|
356
|
+
writeJsonAtomic(approvedPath, plan);
|
|
357
|
+
return { approvedPath, outcome: "approved" };
|
|
358
|
+
} catch (error) {
|
|
359
|
+
return {
|
|
360
|
+
error: error instanceof Error ? error.message : String(error),
|
|
361
|
+
outcome: "processing_failed",
|
|
362
|
+
stage: "approval",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface ToolReviewBoundaryResult {
|
|
368
|
+
outcome: "completed" | "lineage_pending" | "none" | "processing_failed";
|
|
369
|
+
state?: ToolReviewAdmissionState;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function applyApprovedToolReviewAtSessionBoundary(
|
|
373
|
+
options: {
|
|
374
|
+
lifecycleHooks?: {
|
|
375
|
+
onCompleted?(): void;
|
|
376
|
+
onLineagePending?(): void;
|
|
377
|
+
onStateLocked?(): void;
|
|
378
|
+
};
|
|
379
|
+
now?(): Date;
|
|
380
|
+
recipeRoot?: string;
|
|
381
|
+
statePath?: string;
|
|
382
|
+
} = {},
|
|
383
|
+
): ToolReviewBoundaryResult {
|
|
384
|
+
const statePath = options.statePath ?? Paths.getToolReviewStatePath();
|
|
385
|
+
const recipeRoot = options.recipeRoot ?? Paths.getRecipeRoot();
|
|
386
|
+
return withFileMutationLock(statePath, () => {
|
|
387
|
+
options.lifecycleHooks?.onStateLocked?.();
|
|
388
|
+
const state = readAdmissionState(statePath);
|
|
389
|
+
if (!state) return { outcome: "none" };
|
|
390
|
+
if (state.phase === "completed") {
|
|
391
|
+
if (state.approvedPath) {
|
|
392
|
+
rmSync(join(dirname(state.approvedPath), "quarantine"), {
|
|
393
|
+
recursive: true,
|
|
394
|
+
force: true,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return { outcome: "completed", state };
|
|
398
|
+
}
|
|
399
|
+
if (state.phase !== "approved" && state.phase !== "lineage_pending") {
|
|
400
|
+
return { outcome: "none" };
|
|
401
|
+
}
|
|
402
|
+
if (!state.approvedPath) {
|
|
403
|
+
const failed = {
|
|
404
|
+
...state,
|
|
405
|
+
failedStage: "approval_state",
|
|
406
|
+
lastError: "approved review path is unavailable",
|
|
407
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
408
|
+
phase: "processing_failed" as const,
|
|
409
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
410
|
+
};
|
|
411
|
+
writeJsonAtomic(statePath, failed);
|
|
412
|
+
return { outcome: "processing_failed", state: failed };
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const journalPath = join(dirname(state.approvedPath), "journal.json");
|
|
416
|
+
const transaction = existsSync(journalPath)
|
|
417
|
+
? ToolReviewTransaction.recoverToolReviewTransaction(state.approvedPath, {
|
|
418
|
+
now: options.now,
|
|
419
|
+
recipeRoot,
|
|
420
|
+
})
|
|
421
|
+
: ToolReviewTransaction.applyToolReviewPlan(state.approvedPath, {
|
|
422
|
+
now: options.now,
|
|
423
|
+
recipeRoot,
|
|
424
|
+
});
|
|
425
|
+
if (transaction.phase !== "committed") {
|
|
426
|
+
const failed = {
|
|
427
|
+
...state,
|
|
428
|
+
failedStage: "transaction",
|
|
429
|
+
lastError: `tool review transaction remained ${transaction.phase}`,
|
|
430
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
431
|
+
phase: "processing_failed" as const,
|
|
432
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
433
|
+
};
|
|
434
|
+
writeJsonAtomic(statePath, failed);
|
|
435
|
+
return { outcome: "processing_failed", state: failed };
|
|
436
|
+
}
|
|
437
|
+
const pending = {
|
|
438
|
+
...state,
|
|
439
|
+
...(transaction.evidencePath
|
|
440
|
+
? { transactionEvidencePath: transaction.evidencePath }
|
|
441
|
+
: {}),
|
|
442
|
+
phase: "lineage_pending" as const,
|
|
443
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
444
|
+
};
|
|
445
|
+
writeJsonAtomic(statePath, pending);
|
|
446
|
+
options.lifecycleHooks?.onLineagePending?.();
|
|
447
|
+
let lineage: ToolReviewLineageTransaction.ToolReviewLineageTransactionResult;
|
|
448
|
+
try {
|
|
449
|
+
lineage = ToolReviewLineageTransaction.finalizeToolReviewLineage(
|
|
450
|
+
state.approvedPath,
|
|
451
|
+
{
|
|
452
|
+
now: options.now,
|
|
453
|
+
recipeRoot,
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const failedPending = {
|
|
458
|
+
...pending,
|
|
459
|
+
failedStage: "lineage",
|
|
460
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
461
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
462
|
+
};
|
|
463
|
+
writeJsonAtomic(statePath, failedPending);
|
|
464
|
+
return { outcome: "lineage_pending", state: failedPending };
|
|
465
|
+
}
|
|
466
|
+
const completed = {
|
|
467
|
+
...pending,
|
|
468
|
+
lineageJournalPath: lineage.journalPath,
|
|
469
|
+
phase: "completed" as const,
|
|
470
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
471
|
+
};
|
|
472
|
+
writeJsonAtomic(statePath, completed);
|
|
473
|
+
options.lifecycleHooks?.onCompleted?.();
|
|
474
|
+
rmSync(transaction.quarantineDir, { recursive: true, force: true });
|
|
475
|
+
return { outcome: "completed", state: completed };
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const failed = {
|
|
478
|
+
...state,
|
|
479
|
+
failedStage: "transaction",
|
|
480
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
481
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
482
|
+
phase: "processing_failed" as const,
|
|
483
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
484
|
+
};
|
|
485
|
+
writeJsonAtomic(statePath, failed);
|
|
486
|
+
return { outcome: "processing_failed", state: failed };
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function createToolReviewScheduler(
|
|
492
|
+
deps: ToolReviewSchedulerDeps,
|
|
493
|
+
): ToolReviewScheduler {
|
|
494
|
+
const statePath = deps.statePath ?? Paths.getToolReviewStatePath();
|
|
495
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
496
|
+
const now = deps.now ?? (() => new Date());
|
|
497
|
+
let timer: NodeJS.Timeout | undefined;
|
|
498
|
+
let closed = false;
|
|
499
|
+
const close = (): void => {
|
|
500
|
+
closed = true;
|
|
501
|
+
if (timer) clearTimeout(timer);
|
|
502
|
+
timer = undefined;
|
|
503
|
+
};
|
|
504
|
+
const schedule = (): void => {
|
|
505
|
+
if (closed || timer) return;
|
|
506
|
+
timer = setTimeout(() => {
|
|
507
|
+
timer = undefined;
|
|
508
|
+
if (closed || deps.hasActiveActors()) return;
|
|
509
|
+
try {
|
|
510
|
+
withFileMutationLock(statePath, () => {
|
|
511
|
+
const existing = readAdmissionState(statePath);
|
|
512
|
+
if (
|
|
513
|
+
existing?.phase === "launched" ||
|
|
514
|
+
existing?.phase === "processing_failed"
|
|
515
|
+
) {
|
|
516
|
+
if (!deps.process || (existing.processingAttempts ?? 0) >= 3) return;
|
|
517
|
+
let processed: ToolReviewProcessResult;
|
|
518
|
+
try {
|
|
519
|
+
processed = deps.process(existing);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
processed = {
|
|
522
|
+
error: error instanceof Error ? error.message : String(error),
|
|
523
|
+
outcome: "processing_failed",
|
|
524
|
+
stage: "result_processing",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (processed.outcome === "pending") return;
|
|
528
|
+
if (processed.outcome === "approved") {
|
|
529
|
+
writeJsonAtomic(statePath, {
|
|
530
|
+
...existing,
|
|
531
|
+
...(processed.approvedPath ? { approvedPath: processed.approvedPath } : {}),
|
|
532
|
+
failedStage: undefined,
|
|
533
|
+
lastError: undefined,
|
|
534
|
+
nextAction: undefined,
|
|
535
|
+
phase: "approved",
|
|
536
|
+
updatedAt: now().toISOString(),
|
|
537
|
+
} satisfies ToolReviewAdmissionState);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
writeJsonAtomic(statePath, {
|
|
541
|
+
...existing,
|
|
542
|
+
failedStage: processed.stage ?? "review_result",
|
|
543
|
+
lastError: processed.error ?? "automatic tool reviewer failed",
|
|
544
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
545
|
+
phase: processed.outcome === "processing_failed"
|
|
546
|
+
? "processing_failed"
|
|
547
|
+
: "failed",
|
|
548
|
+
...(processed.outcome === "processing_failed"
|
|
549
|
+
? { processingAttempts: (existing.processingAttempts ?? 0) + 1 }
|
|
550
|
+
: { runId: undefined }),
|
|
551
|
+
updatedAt: now().toISOString(),
|
|
552
|
+
} satisfies ToolReviewAdmissionState);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (
|
|
556
|
+
existing?.phase === "captured" ||
|
|
557
|
+
existing?.phase === "failed"
|
|
558
|
+
) {
|
|
559
|
+
if (existing.attempts >= 3) return;
|
|
560
|
+
try {
|
|
561
|
+
const launched = deps.launch({
|
|
562
|
+
inputPath: existing.inputPath,
|
|
563
|
+
reviewerInputPath: existing.reviewerInputPath,
|
|
564
|
+
reviewId: existing.reviewId,
|
|
565
|
+
toolNames: existing.toolNames,
|
|
566
|
+
});
|
|
567
|
+
writeJsonAtomic(statePath, {
|
|
568
|
+
...existing,
|
|
569
|
+
attempts: existing.attempts + 1,
|
|
570
|
+
phase: "launched",
|
|
571
|
+
runId: launched.run,
|
|
572
|
+
updatedAt: now().toISOString(),
|
|
573
|
+
} satisfies ToolReviewAdmissionState);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
writeJsonAtomic(statePath, {
|
|
576
|
+
...existing,
|
|
577
|
+
attempts: existing.attempts + 1,
|
|
578
|
+
failedStage: "review_launch",
|
|
579
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
580
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
581
|
+
phase: "failed",
|
|
582
|
+
updatedAt: now().toISOString(),
|
|
583
|
+
} satisfies ToolReviewAdmissionState);
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (existing && existing.phase !== "completed") return;
|
|
588
|
+
let batch: ToolReviewBatch | undefined;
|
|
589
|
+
try {
|
|
590
|
+
batch = captureToolReviewBatchLocked(
|
|
591
|
+
deps,
|
|
592
|
+
recipeRoot,
|
|
593
|
+
statePath,
|
|
594
|
+
now,
|
|
595
|
+
);
|
|
596
|
+
if (!batch) return;
|
|
597
|
+
const launched = deps.launch(batch);
|
|
598
|
+
writeJsonAtomic(statePath, {
|
|
599
|
+
attempts: 1,
|
|
600
|
+
inputPath: batch.inputPath,
|
|
601
|
+
phase: "launched",
|
|
602
|
+
reviewerInputPath: batch.reviewerInputPath,
|
|
603
|
+
reviewId: batch.reviewId,
|
|
604
|
+
runId: launched.run,
|
|
605
|
+
toolNames: batch.toolNames,
|
|
606
|
+
updatedAt: now().toISOString(),
|
|
607
|
+
} satisfies ToolReviewAdmissionState);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (!batch) return;
|
|
610
|
+
writeJsonAtomic(statePath, {
|
|
611
|
+
attempts: 1,
|
|
612
|
+
failedStage: "review_launch",
|
|
613
|
+
inputPath: batch.inputPath,
|
|
614
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
615
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
616
|
+
phase: "failed",
|
|
617
|
+
reviewerInputPath: batch.reviewerInputPath,
|
|
618
|
+
reviewId: batch.reviewId,
|
|
619
|
+
toolNames: batch.toolNames,
|
|
620
|
+
updatedAt: now().toISOString(),
|
|
621
|
+
} satisfies ToolReviewAdmissionState);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
} catch {
|
|
625
|
+
/* another session owns the durable admission transition */
|
|
626
|
+
}
|
|
627
|
+
}, deps.delayMs ?? 50);
|
|
628
|
+
timer.unref?.();
|
|
629
|
+
};
|
|
630
|
+
return { close, schedule };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function toolReviewRunId(reviewId: string): string {
|
|
634
|
+
return `tool-review-${reviewId}`;
|
|
635
|
+
}
|