@llblab/pi-actors 0.41.1 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -6
- package/BACKLOG.md +2 -62
- package/CHANGELOG.md +15 -0
- package/README.md +22 -4
- package/dist/index.js +25 -121
- package/dist/lib/async-runs.d.ts +25 -5
- package/dist/lib/async-runs.js +136 -47
- package/dist/lib/automatic-review-runtime.d.ts +18 -0
- package/dist/lib/automatic-review-runtime.js +96 -0
- 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 +20 -3
- package/dist/lib/inspector-overlay.js +275 -74
- package/dist/lib/inspector.d.ts +9 -0
- package/dist/lib/inspector.js +48 -0
- package/dist/lib/observability.d.ts +1 -0
- package/dist/lib/observability.js +11 -1
- 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 +8 -2
- package/dist/lib/runs-control.js +23 -6
- 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 +1 -1
- 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 +1 -1
- 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 +6 -48
- package/dist/lib/runs-status.d.ts +5 -3
- package/dist/lib/runs-status.js +4 -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 +26 -3
- package/dist/lib/tools-local.js +4 -2
- package/dist/lib/tools-message.d.ts +1 -0
- package/dist/lib/tools-message.js +29 -17
- package/dist/lib/tools-spawn.js +4 -1
- 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/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 +9 -1
- package/docs/recipe-library.md +7 -3
- package/docs/template-recipes.md +4 -11
- package/docs/tool-registry.md +11 -4
- package/index.ts +27 -142
- package/lib/async-runs.ts +218 -62
- package/lib/automatic-review-runtime.ts +135 -0
- 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 +325 -91
- package/lib/inspector.ts +72 -0
- package/lib/observability.ts +14 -1
- 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 +49 -5
- 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 +1 -1
- package/lib/runs-parent-teardown.ts +257 -0
- package/lib/runs-process.ts +1 -1
- package/lib/runs-retention.ts +1 -1
- package/lib/runs-start.ts +12 -68
- package/lib/runs-status.ts +12 -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 +33 -3
- package/lib/tools-local.ts +8 -2
- package/lib/tools-message.ts +45 -30
- package/lib/tools-spawn.ts +8 -1
- package/lib/tools.ts +5 -0
- package/package.json +3 -2
- package/recipes/draft-review.json +24 -0
- package/recipes/tool-review.json +24 -0
- package/scripts/release-gates.mjs +165 -0
- package/skills/actors/SKILL.md +9 -8
- package/skills/swarm/SKILL.md +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-review lineage projection.
|
|
3
|
+
* Zones: approved source coverage, target ownership, lineage transition semantics
|
|
4
|
+
* Owns deterministic keep/evolve/replace/demote/merge/split grouping; filesystem mutation and recovery remain separate.
|
|
5
|
+
*/
|
|
6
|
+
function sameNames(actual, expected) {
|
|
7
|
+
return (actual.length === expected.length &&
|
|
8
|
+
[...actual].sort().every((name, index) => name === [...expected].sort()[index]));
|
|
9
|
+
}
|
|
10
|
+
export function projectToolReviewLineage(plan) {
|
|
11
|
+
const sources = new Map();
|
|
12
|
+
for (const source of plan.sources) {
|
|
13
|
+
if (sources.has(source.name)) {
|
|
14
|
+
throw new Error(`Duplicate tool review lineage source: ${source.name}`);
|
|
15
|
+
}
|
|
16
|
+
sources.set(source.name, source);
|
|
17
|
+
}
|
|
18
|
+
const targetsBySource = new Map();
|
|
19
|
+
for (const target of plan.targets) {
|
|
20
|
+
if (target.sources.length === 0 || new Set(target.sources).size !== target.sources.length) {
|
|
21
|
+
throw new Error(`Invalid tool review lineage target sources: ${target.name}`);
|
|
22
|
+
}
|
|
23
|
+
for (const sourceName of target.sources) {
|
|
24
|
+
if (!sources.has(sourceName)) {
|
|
25
|
+
throw new Error(`Unknown tool review lineage target source: ${sourceName}`);
|
|
26
|
+
}
|
|
27
|
+
const entries = targetsBySource.get(sourceName) ?? [];
|
|
28
|
+
entries.push(target);
|
|
29
|
+
targetsBySource.set(sourceName, entries);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const operations = [];
|
|
33
|
+
const handled = new Set();
|
|
34
|
+
for (const source of plan.sources) {
|
|
35
|
+
if (handled.has(source.name))
|
|
36
|
+
continue;
|
|
37
|
+
const targets = targetsBySource.get(source.name) ?? [];
|
|
38
|
+
if (source.action === "keep") {
|
|
39
|
+
if (targets.length > 0) {
|
|
40
|
+
throw new Error(`Kept tool owns lineage targets: ${source.name}`);
|
|
41
|
+
}
|
|
42
|
+
operations.push({ action: "keep", sources: [source], targets: [] });
|
|
43
|
+
handled.add(source.name);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (source.action === "demote" ||
|
|
47
|
+
source.action === "evolve" ||
|
|
48
|
+
source.action === "replace") {
|
|
49
|
+
if (targets.length !== 1 ||
|
|
50
|
+
targets[0].lineage !== source.action ||
|
|
51
|
+
!sameNames(targets[0].sources, [source.name])) {
|
|
52
|
+
throw new Error(`Invalid ${source.action} lineage target: ${source.name}`);
|
|
53
|
+
}
|
|
54
|
+
operations.push({
|
|
55
|
+
action: source.action,
|
|
56
|
+
sources: [source],
|
|
57
|
+
targets: [targets[0]],
|
|
58
|
+
});
|
|
59
|
+
handled.add(source.name);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (source.action === "merge") {
|
|
63
|
+
if (targets.length !== 1 || targets[0].lineage !== "merge") {
|
|
64
|
+
throw new Error(`Invalid merge lineage target: ${source.name}`);
|
|
65
|
+
}
|
|
66
|
+
const target = targets[0];
|
|
67
|
+
const groupSources = target.sources.map((name) => sources.get(name));
|
|
68
|
+
if (groupSources.length < 2 ||
|
|
69
|
+
groupSources.some((candidate) => candidate.action !== "merge") ||
|
|
70
|
+
!sameNames(target.sources, groupSources.map((candidate) => candidate.name))) {
|
|
71
|
+
throw new Error(`Invalid merge lineage group: ${target.name}`);
|
|
72
|
+
}
|
|
73
|
+
operations.push({ action: "merge", sources: groupSources, targets: [target] });
|
|
74
|
+
for (const candidate of groupSources)
|
|
75
|
+
handled.add(candidate.name);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (source.action === "split") {
|
|
79
|
+
if (targets.length < 2 ||
|
|
80
|
+
targets.some((target) => target.lineage !== "split" ||
|
|
81
|
+
!sameNames(target.sources, [source.name]))) {
|
|
82
|
+
throw new Error(`Invalid split lineage targets: ${source.name}`);
|
|
83
|
+
}
|
|
84
|
+
operations.push({
|
|
85
|
+
action: "split",
|
|
86
|
+
sources: [source],
|
|
87
|
+
targets: [...targets].sort((left, right) => left.name.localeCompare(right.name)),
|
|
88
|
+
});
|
|
89
|
+
handled.add(source.name);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Unsupported tool review lineage action: ${source.action}`);
|
|
93
|
+
}
|
|
94
|
+
if (handled.size !== plan.sources.length) {
|
|
95
|
+
throw new Error("Tool review lineage projection does not cover every source.");
|
|
96
|
+
}
|
|
97
|
+
return operations;
|
|
98
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
import { type ToolReviewTool } from "./tool-review.ts";
|
|
7
|
+
export declare const TOOL_REVIEW_RESULT_MAX_BYTES: number;
|
|
8
|
+
export interface ToolReviewBatch {
|
|
9
|
+
inputPath: string;
|
|
10
|
+
reviewerInputPath: string;
|
|
11
|
+
reviewId: string;
|
|
12
|
+
toolNames: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface ToolReviewAdmissionState {
|
|
15
|
+
approvedPath?: string;
|
|
16
|
+
attempts: number;
|
|
17
|
+
failedStage?: string;
|
|
18
|
+
lastError?: string;
|
|
19
|
+
lineageJournalPath?: string;
|
|
20
|
+
inputPath: string;
|
|
21
|
+
nextAction?: string;
|
|
22
|
+
phase: "approved" | "captured" | "completed" | "failed" | "launched" | "lineage_pending" | "processing_failed";
|
|
23
|
+
processingAttempts?: number;
|
|
24
|
+
reviewerInputPath: string;
|
|
25
|
+
reviewId: string;
|
|
26
|
+
runId?: string;
|
|
27
|
+
toolNames: string[];
|
|
28
|
+
transactionEvidencePath?: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ToolReviewSchedulerDeps extends CaptureToolReviewOptions {
|
|
32
|
+
delayMs?: number;
|
|
33
|
+
hasActiveActors(): boolean;
|
|
34
|
+
launch(batch: ToolReviewBatch): {
|
|
35
|
+
run: string;
|
|
36
|
+
};
|
|
37
|
+
process?(state: ToolReviewAdmissionState): ToolReviewProcessResult;
|
|
38
|
+
}
|
|
39
|
+
export interface ToolReviewProcessResult {
|
|
40
|
+
approvedPath?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
outcome: "approved" | "pending" | "processing_failed" | "review_failed";
|
|
43
|
+
stage?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ToolReviewScheduler {
|
|
46
|
+
close(): void;
|
|
47
|
+
schedule(): void;
|
|
48
|
+
}
|
|
49
|
+
export interface CaptureToolReviewOptions {
|
|
50
|
+
batchRoot?(reviewId: string): string;
|
|
51
|
+
createReviewId?(): string;
|
|
52
|
+
now?(): Date;
|
|
53
|
+
recipeRoot?: string;
|
|
54
|
+
statePath?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function listEligibleToolReviewRecipes(recipeRoot?: string, now?: Date): ToolReviewTool[];
|
|
57
|
+
export declare function captureToolReviewBatch(options?: CaptureToolReviewOptions): ToolReviewBatch | undefined;
|
|
58
|
+
export interface ProcessToolReviewDeps {
|
|
59
|
+
draftRoot?: string;
|
|
60
|
+
getRunStatus(runId: string): Record<string, unknown>;
|
|
61
|
+
now?(): Date;
|
|
62
|
+
recipeRoot?: string;
|
|
63
|
+
}
|
|
64
|
+
export declare function processToolReviewResult(state: ToolReviewAdmissionState, deps: ProcessToolReviewDeps): ToolReviewProcessResult;
|
|
65
|
+
export interface ToolReviewBoundaryResult {
|
|
66
|
+
outcome: "completed" | "lineage_pending" | "none" | "processing_failed";
|
|
67
|
+
state?: ToolReviewAdmissionState;
|
|
68
|
+
}
|
|
69
|
+
export declare function applyApprovedToolReviewAtSessionBoundary(options?: {
|
|
70
|
+
lifecycleHooks?: {
|
|
71
|
+
onCompleted?(): void;
|
|
72
|
+
onLineagePending?(): void;
|
|
73
|
+
onStateLocked?(): void;
|
|
74
|
+
};
|
|
75
|
+
now?(): Date;
|
|
76
|
+
recipeRoot?: string;
|
|
77
|
+
statePath?: string;
|
|
78
|
+
}): ToolReviewBoundaryResult;
|
|
79
|
+
export declare function createToolReviewScheduler(deps: ToolReviewSchedulerDeps): ToolReviewScheduler;
|
|
80
|
+
export declare function toolReviewRunId(reviewId: string): string;
|
|
@@ -0,0 +1,494 @@
|
|
|
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
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { existsSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
|
|
10
|
+
import * as Paths from "./paths.js";
|
|
11
|
+
import * as RecipesDiscovery from "./recipes-discovery.js";
|
|
12
|
+
import * as RecipesReferences from "./recipes-references.js";
|
|
13
|
+
import * as RecipesUsage from "./recipes-usage.js";
|
|
14
|
+
import * as ReviewProjection from "./review-projection.js";
|
|
15
|
+
import * as ToolReviewLineage from "./tool-review-lineage.js";
|
|
16
|
+
import * as ToolReviewLineageTransaction from "./tool-review-lineage-transaction.js";
|
|
17
|
+
import * as ToolReviewTransaction from "./tool-review-transaction.js";
|
|
18
|
+
import { findUnsafeRecipeReason } from "./draft-review.js";
|
|
19
|
+
import { parseToolReviewResult, TOOL_REVIEW_THRESHOLD, validateToolReviewInput, validateToolReviewResult, } from "./tool-review.js";
|
|
20
|
+
export const TOOL_REVIEW_RESULT_MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
function sha256(path) {
|
|
22
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
function readAdmissionState(path) {
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
+
return value && typeof value === "object" ? value : undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function usageOrder(tool) {
|
|
34
|
+
const firstSeen = tool.usage?.first_seen;
|
|
35
|
+
return typeof firstSeen === "string" ? firstSeen : "";
|
|
36
|
+
}
|
|
37
|
+
export function listEligibleToolReviewRecipes(recipeRoot = Paths.getRecipeRoot(), now = new Date()) {
|
|
38
|
+
const discovered = RecipesDiscovery.discoverRecipeSources([
|
|
39
|
+
{ root: recipeRoot, defaultTool: true, mutableUsage: true },
|
|
40
|
+
]);
|
|
41
|
+
return [...discovered.active.values()]
|
|
42
|
+
.filter((entry) => entry.active && entry.tool && !entry.invalid && !entry.disabled)
|
|
43
|
+
.flatMap((entry) => {
|
|
44
|
+
if (!RecipesUsage.ensureRecipeLineage(entry.path, now, recipeRoot))
|
|
45
|
+
return [];
|
|
46
|
+
if (RecipesUsage.isCurrentRecipeRevisionReviewed(entry.path, recipeRoot))
|
|
47
|
+
return [];
|
|
48
|
+
const recipe = RecipesReferences.readRawRecipeConfig(entry.path);
|
|
49
|
+
if (!recipe ||
|
|
50
|
+
entry.riskLabels.includes("risk.secret_touching") ||
|
|
51
|
+
findUnsafeRecipeReason(recipe))
|
|
52
|
+
return [];
|
|
53
|
+
return [{
|
|
54
|
+
name: entry.id,
|
|
55
|
+
path: entry.path,
|
|
56
|
+
recipe,
|
|
57
|
+
riskLabels: [...entry.riskLabels],
|
|
58
|
+
sha256: sha256(entry.path),
|
|
59
|
+
usage: RecipesUsage.readRecipeUsage(entry.path, recipeRoot),
|
|
60
|
+
valid: true,
|
|
61
|
+
}];
|
|
62
|
+
})
|
|
63
|
+
.sort((left, right) => usageOrder(left).localeCompare(usageOrder(right)) ||
|
|
64
|
+
left.name.localeCompare(right.name));
|
|
65
|
+
}
|
|
66
|
+
function captureToolReviewBatchLocked(options, recipeRoot, statePath, now) {
|
|
67
|
+
const state = readAdmissionState(statePath);
|
|
68
|
+
if (state?.phase === "captured" ||
|
|
69
|
+
state?.phase === "launched" ||
|
|
70
|
+
state?.phase === "processing_failed")
|
|
71
|
+
return undefined;
|
|
72
|
+
const eligible = listEligibleToolReviewRecipes(recipeRoot, now());
|
|
73
|
+
if (eligible.length < TOOL_REVIEW_THRESHOLD)
|
|
74
|
+
return undefined;
|
|
75
|
+
const reviewId = (options.createReviewId ?? randomUUID)();
|
|
76
|
+
const inputPath = join((options.batchRoot ?? Paths.getToolReviewBatchDir)(reviewId), "input.json");
|
|
77
|
+
const tools = eligible.slice(0, TOOL_REVIEW_THRESHOLD);
|
|
78
|
+
const input = {
|
|
79
|
+
createdAt: now().toISOString(),
|
|
80
|
+
reviewId,
|
|
81
|
+
tools,
|
|
82
|
+
};
|
|
83
|
+
const validation = validateToolReviewInput(input);
|
|
84
|
+
if (!validation.ok) {
|
|
85
|
+
throw new Error(`Tool review input rejected: ${validation.errors.join("; ")}`);
|
|
86
|
+
}
|
|
87
|
+
writeJsonAtomic(inputPath, input);
|
|
88
|
+
const reviewerInputPath = join(dirname(inputPath), "review-input.json");
|
|
89
|
+
writeJsonAtomic(reviewerInputPath, ReviewProjection.projectToolReviewInput(input));
|
|
90
|
+
const toolNames = tools.map((tool) => tool.name);
|
|
91
|
+
writeJsonAtomic(statePath, {
|
|
92
|
+
attempts: 0,
|
|
93
|
+
inputPath,
|
|
94
|
+
phase: "captured",
|
|
95
|
+
reviewerInputPath,
|
|
96
|
+
reviewId,
|
|
97
|
+
toolNames,
|
|
98
|
+
updatedAt: input.createdAt,
|
|
99
|
+
});
|
|
100
|
+
return { inputPath, reviewerInputPath, reviewId, toolNames };
|
|
101
|
+
}
|
|
102
|
+
export function captureToolReviewBatch(options = {}) {
|
|
103
|
+
const recipeRoot = options.recipeRoot ?? Paths.getRecipeRoot();
|
|
104
|
+
const statePath = options.statePath ?? Paths.getToolReviewStatePath();
|
|
105
|
+
const now = options.now ?? (() => new Date());
|
|
106
|
+
return withFileMutationLock(statePath, () => captureToolReviewBatchLocked(options, recipeRoot, statePath, now));
|
|
107
|
+
}
|
|
108
|
+
function approvedTargets(input, decisions, recipeRoot) {
|
|
109
|
+
const targets = new Map();
|
|
110
|
+
const tools = new Map(input.tools.map((tool) => [tool.name, tool]));
|
|
111
|
+
const add = (name, path, recipe, lineage, source) => {
|
|
112
|
+
const existing = targets.get(path);
|
|
113
|
+
if (existing) {
|
|
114
|
+
if (lineage !== "merge" || existing.lineage !== "merge") {
|
|
115
|
+
throw new Error(`Duplicate tool review target: ${path}`);
|
|
116
|
+
}
|
|
117
|
+
existing.sources.push(source);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const sourceAtPath = input.tools.find((tool) => tool.path === path);
|
|
121
|
+
if (existsSync(path) && !sourceAtPath) {
|
|
122
|
+
throw new Error(`Tool review target appeared after capture: ${path}`);
|
|
123
|
+
}
|
|
124
|
+
targets.set(path, {
|
|
125
|
+
expectedSha256: sourceAtPath?.sha256 ?? null,
|
|
126
|
+
lineage,
|
|
127
|
+
name,
|
|
128
|
+
path,
|
|
129
|
+
recipe,
|
|
130
|
+
sources: [source],
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
for (const decision of decisions) {
|
|
134
|
+
const source = tools.get(decision.source);
|
|
135
|
+
if (decision.action === "keep")
|
|
136
|
+
continue;
|
|
137
|
+
if (decision.action === "demote") {
|
|
138
|
+
add(decision.source, join(recipeRoot, "drafts", `${decision.source}.json`), source.recipe, "demote", decision.source);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (decision.action === "evolve") {
|
|
142
|
+
const source = tools.get(decision.source);
|
|
143
|
+
add(decision.target, join(recipeRoot, `${decision.target}.json`), source.recipe, "evolve", decision.source);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (decision.action === "merge") {
|
|
147
|
+
const source = tools.get(decision.source);
|
|
148
|
+
add(decision.target, join(recipeRoot, `${decision.target}.json`), source.recipe, "merge", decision.source);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`${decision.action} requires explicit operator-authored recipe mutation.`);
|
|
152
|
+
}
|
|
153
|
+
return [...targets.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
154
|
+
}
|
|
155
|
+
export function processToolReviewResult(state, deps) {
|
|
156
|
+
if (!state.runId) {
|
|
157
|
+
return { error: "review run id is unavailable", outcome: "review_failed", stage: "review_state" };
|
|
158
|
+
}
|
|
159
|
+
let status;
|
|
160
|
+
try {
|
|
161
|
+
status = deps.getRunStatus(state.runId);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
error: error instanceof Error ? error.message : String(error),
|
|
166
|
+
outcome: "review_failed",
|
|
167
|
+
stage: "review_state",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (status.status === "running")
|
|
171
|
+
return { outcome: "pending" };
|
|
172
|
+
if (status.status !== "done" || typeof status.state_dir !== "string") {
|
|
173
|
+
return {
|
|
174
|
+
error: `review run ended with status ${String(status.status ?? "unknown")}`,
|
|
175
|
+
outcome: "review_failed",
|
|
176
|
+
stage: "review_run",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
let input;
|
|
180
|
+
let result;
|
|
181
|
+
try {
|
|
182
|
+
input = JSON.parse(readFileSync(state.inputPath, "utf8"));
|
|
183
|
+
const stdoutPath = join(status.state_dir, "stdout.log");
|
|
184
|
+
if (statSync(stdoutPath).size > TOOL_REVIEW_RESULT_MAX_BYTES) {
|
|
185
|
+
throw new Error("Tool reviewer output exceeds the approval limit.");
|
|
186
|
+
}
|
|
187
|
+
result = ReviewProjection.restoreToolReviewResult(input, parseToolReviewResult(readFileSync(stdoutPath, "utf8")));
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
error: error instanceof Error ? error.message : String(error),
|
|
192
|
+
outcome: "review_failed",
|
|
193
|
+
stage: "result_parse",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const validation = validateToolReviewResult(input, result);
|
|
197
|
+
if (!validation.ok) {
|
|
198
|
+
return {
|
|
199
|
+
error: validation.errors.join("; "),
|
|
200
|
+
outcome: "review_failed",
|
|
201
|
+
stage: "result_validation",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
for (const tool of input.tools) {
|
|
206
|
+
if (!existsSync(tool.path) || sha256(tool.path) !== tool.sha256) {
|
|
207
|
+
throw new Error(`Reviewed tool changed after capture: ${tool.name}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
211
|
+
const plan = {
|
|
212
|
+
createdAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
213
|
+
decisions: result.decisions,
|
|
214
|
+
reviewId: state.reviewId,
|
|
215
|
+
sources: result.decisions.map((decision) => {
|
|
216
|
+
const tool = input.tools.find((candidate) => candidate.name === decision.source);
|
|
217
|
+
return {
|
|
218
|
+
action: decision.action,
|
|
219
|
+
name: tool.name,
|
|
220
|
+
path: tool.path,
|
|
221
|
+
sha256: tool.sha256,
|
|
222
|
+
};
|
|
223
|
+
}),
|
|
224
|
+
targets: approvedTargets(input, result.decisions, recipeRoot),
|
|
225
|
+
};
|
|
226
|
+
ToolReviewLineage.projectToolReviewLineage(plan);
|
|
227
|
+
if (Buffer.byteLength(JSON.stringify(plan)) > TOOL_REVIEW_RESULT_MAX_BYTES) {
|
|
228
|
+
throw new Error("Tool review approved plan exceeds the persistence limit.");
|
|
229
|
+
}
|
|
230
|
+
const approvedPath = join(dirname(state.inputPath), "approved.json");
|
|
231
|
+
writeJsonAtomic(approvedPath, plan);
|
|
232
|
+
return { approvedPath, outcome: "approved" };
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
return {
|
|
236
|
+
error: error instanceof Error ? error.message : String(error),
|
|
237
|
+
outcome: "processing_failed",
|
|
238
|
+
stage: "approval",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export function applyApprovedToolReviewAtSessionBoundary(options = {}) {
|
|
243
|
+
const statePath = options.statePath ?? Paths.getToolReviewStatePath();
|
|
244
|
+
const recipeRoot = options.recipeRoot ?? Paths.getRecipeRoot();
|
|
245
|
+
return withFileMutationLock(statePath, () => {
|
|
246
|
+
options.lifecycleHooks?.onStateLocked?.();
|
|
247
|
+
const state = readAdmissionState(statePath);
|
|
248
|
+
if (!state)
|
|
249
|
+
return { outcome: "none" };
|
|
250
|
+
if (state.phase === "completed") {
|
|
251
|
+
if (state.approvedPath) {
|
|
252
|
+
rmSync(join(dirname(state.approvedPath), "quarantine"), {
|
|
253
|
+
recursive: true,
|
|
254
|
+
force: true,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return { outcome: "completed", state };
|
|
258
|
+
}
|
|
259
|
+
if (state.phase !== "approved" && state.phase !== "lineage_pending") {
|
|
260
|
+
return { outcome: "none" };
|
|
261
|
+
}
|
|
262
|
+
if (!state.approvedPath) {
|
|
263
|
+
const failed = {
|
|
264
|
+
...state,
|
|
265
|
+
failedStage: "approval_state",
|
|
266
|
+
lastError: "approved review path is unavailable",
|
|
267
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
268
|
+
phase: "processing_failed",
|
|
269
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
270
|
+
};
|
|
271
|
+
writeJsonAtomic(statePath, failed);
|
|
272
|
+
return { outcome: "processing_failed", state: failed };
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const journalPath = join(dirname(state.approvedPath), "journal.json");
|
|
276
|
+
const transaction = existsSync(journalPath)
|
|
277
|
+
? ToolReviewTransaction.recoverToolReviewTransaction(state.approvedPath, {
|
|
278
|
+
now: options.now,
|
|
279
|
+
recipeRoot,
|
|
280
|
+
})
|
|
281
|
+
: ToolReviewTransaction.applyToolReviewPlan(state.approvedPath, {
|
|
282
|
+
now: options.now,
|
|
283
|
+
recipeRoot,
|
|
284
|
+
});
|
|
285
|
+
if (transaction.phase !== "committed") {
|
|
286
|
+
const failed = {
|
|
287
|
+
...state,
|
|
288
|
+
failedStage: "transaction",
|
|
289
|
+
lastError: `tool review transaction remained ${transaction.phase}`,
|
|
290
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
291
|
+
phase: "processing_failed",
|
|
292
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
293
|
+
};
|
|
294
|
+
writeJsonAtomic(statePath, failed);
|
|
295
|
+
return { outcome: "processing_failed", state: failed };
|
|
296
|
+
}
|
|
297
|
+
const pending = {
|
|
298
|
+
...state,
|
|
299
|
+
...(transaction.evidencePath
|
|
300
|
+
? { transactionEvidencePath: transaction.evidencePath }
|
|
301
|
+
: {}),
|
|
302
|
+
phase: "lineage_pending",
|
|
303
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
304
|
+
};
|
|
305
|
+
writeJsonAtomic(statePath, pending);
|
|
306
|
+
options.lifecycleHooks?.onLineagePending?.();
|
|
307
|
+
let lineage;
|
|
308
|
+
try {
|
|
309
|
+
lineage = ToolReviewLineageTransaction.finalizeToolReviewLineage(state.approvedPath, {
|
|
310
|
+
now: options.now,
|
|
311
|
+
recipeRoot,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
const failedPending = {
|
|
316
|
+
...pending,
|
|
317
|
+
failedStage: "lineage",
|
|
318
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
319
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
320
|
+
};
|
|
321
|
+
writeJsonAtomic(statePath, failedPending);
|
|
322
|
+
return { outcome: "lineage_pending", state: failedPending };
|
|
323
|
+
}
|
|
324
|
+
const completed = {
|
|
325
|
+
...pending,
|
|
326
|
+
lineageJournalPath: lineage.journalPath,
|
|
327
|
+
phase: "completed",
|
|
328
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
329
|
+
};
|
|
330
|
+
writeJsonAtomic(statePath, completed);
|
|
331
|
+
options.lifecycleHooks?.onCompleted?.();
|
|
332
|
+
rmSync(transaction.quarantineDir, { recursive: true, force: true });
|
|
333
|
+
return { outcome: "completed", state: completed };
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const failed = {
|
|
337
|
+
...state,
|
|
338
|
+
failedStage: "transaction",
|
|
339
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
340
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
341
|
+
phase: "processing_failed",
|
|
342
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
343
|
+
};
|
|
344
|
+
writeJsonAtomic(statePath, failed);
|
|
345
|
+
return { outcome: "processing_failed", state: failed };
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
export function createToolReviewScheduler(deps) {
|
|
350
|
+
const statePath = deps.statePath ?? Paths.getToolReviewStatePath();
|
|
351
|
+
const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
|
|
352
|
+
const now = deps.now ?? (() => new Date());
|
|
353
|
+
let timer;
|
|
354
|
+
let closed = false;
|
|
355
|
+
const close = () => {
|
|
356
|
+
closed = true;
|
|
357
|
+
if (timer)
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
timer = undefined;
|
|
360
|
+
};
|
|
361
|
+
const schedule = () => {
|
|
362
|
+
if (closed || timer)
|
|
363
|
+
return;
|
|
364
|
+
timer = setTimeout(() => {
|
|
365
|
+
timer = undefined;
|
|
366
|
+
if (closed || deps.hasActiveActors())
|
|
367
|
+
return;
|
|
368
|
+
try {
|
|
369
|
+
withFileMutationLock(statePath, () => {
|
|
370
|
+
const existing = readAdmissionState(statePath);
|
|
371
|
+
if (existing?.phase === "launched" ||
|
|
372
|
+
existing?.phase === "processing_failed") {
|
|
373
|
+
if (!deps.process || (existing.processingAttempts ?? 0) >= 3)
|
|
374
|
+
return;
|
|
375
|
+
let processed;
|
|
376
|
+
try {
|
|
377
|
+
processed = deps.process(existing);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
processed = {
|
|
381
|
+
error: error instanceof Error ? error.message : String(error),
|
|
382
|
+
outcome: "processing_failed",
|
|
383
|
+
stage: "result_processing",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (processed.outcome === "pending")
|
|
387
|
+
return;
|
|
388
|
+
if (processed.outcome === "approved") {
|
|
389
|
+
writeJsonAtomic(statePath, {
|
|
390
|
+
...existing,
|
|
391
|
+
...(processed.approvedPath ? { approvedPath: processed.approvedPath } : {}),
|
|
392
|
+
failedStage: undefined,
|
|
393
|
+
lastError: undefined,
|
|
394
|
+
nextAction: undefined,
|
|
395
|
+
phase: "approved",
|
|
396
|
+
updatedAt: now().toISOString(),
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
writeJsonAtomic(statePath, {
|
|
401
|
+
...existing,
|
|
402
|
+
failedStage: processed.stage ?? "review_result",
|
|
403
|
+
lastError: processed.error ?? "automatic tool reviewer failed",
|
|
404
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
405
|
+
phase: processed.outcome === "processing_failed"
|
|
406
|
+
? "processing_failed"
|
|
407
|
+
: "failed",
|
|
408
|
+
...(processed.outcome === "processing_failed"
|
|
409
|
+
? { processingAttempts: (existing.processingAttempts ?? 0) + 1 }
|
|
410
|
+
: { runId: undefined }),
|
|
411
|
+
updatedAt: now().toISOString(),
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (existing?.phase === "captured" ||
|
|
416
|
+
existing?.phase === "failed") {
|
|
417
|
+
if (existing.attempts >= 3)
|
|
418
|
+
return;
|
|
419
|
+
try {
|
|
420
|
+
const launched = deps.launch({
|
|
421
|
+
inputPath: existing.inputPath,
|
|
422
|
+
reviewerInputPath: existing.reviewerInputPath,
|
|
423
|
+
reviewId: existing.reviewId,
|
|
424
|
+
toolNames: existing.toolNames,
|
|
425
|
+
});
|
|
426
|
+
writeJsonAtomic(statePath, {
|
|
427
|
+
...existing,
|
|
428
|
+
attempts: existing.attempts + 1,
|
|
429
|
+
phase: "launched",
|
|
430
|
+
runId: launched.run,
|
|
431
|
+
updatedAt: now().toISOString(),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
writeJsonAtomic(statePath, {
|
|
436
|
+
...existing,
|
|
437
|
+
attempts: existing.attempts + 1,
|
|
438
|
+
failedStage: "review_launch",
|
|
439
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
440
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
441
|
+
phase: "failed",
|
|
442
|
+
updatedAt: now().toISOString(),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (existing && existing.phase !== "completed")
|
|
448
|
+
return;
|
|
449
|
+
let batch;
|
|
450
|
+
try {
|
|
451
|
+
batch = captureToolReviewBatchLocked(deps, recipeRoot, statePath, now);
|
|
452
|
+
if (!batch)
|
|
453
|
+
return;
|
|
454
|
+
const launched = deps.launch(batch);
|
|
455
|
+
writeJsonAtomic(statePath, {
|
|
456
|
+
attempts: 1,
|
|
457
|
+
inputPath: batch.inputPath,
|
|
458
|
+
phase: "launched",
|
|
459
|
+
reviewerInputPath: batch.reviewerInputPath,
|
|
460
|
+
reviewId: batch.reviewId,
|
|
461
|
+
runId: launched.run,
|
|
462
|
+
toolNames: batch.toolNames,
|
|
463
|
+
updatedAt: now().toISOString(),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (!batch)
|
|
468
|
+
return;
|
|
469
|
+
writeJsonAtomic(statePath, {
|
|
470
|
+
attempts: 1,
|
|
471
|
+
failedStage: "review_launch",
|
|
472
|
+
inputPath: batch.inputPath,
|
|
473
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
474
|
+
nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"tool\"}",
|
|
475
|
+
phase: "failed",
|
|
476
|
+
reviewerInputPath: batch.reviewerInputPath,
|
|
477
|
+
reviewId: batch.reviewId,
|
|
478
|
+
toolNames: batch.toolNames,
|
|
479
|
+
updatedAt: now().toISOString(),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
/* another session owns the durable admission transition */
|
|
486
|
+
}
|
|
487
|
+
}, deps.delayMs ?? 50);
|
|
488
|
+
timer.unref?.();
|
|
489
|
+
};
|
|
490
|
+
return { close, schedule };
|
|
491
|
+
}
|
|
492
|
+
export function toolReviewRunId(reviewId) {
|
|
493
|
+
return `tool-review-${reviewId}`;
|
|
494
|
+
}
|