@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,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic draft consolidation transaction and crash recovery.
|
|
3
|
+
* Owns: cycle claim, source/target compare-and-swap, complete recipe persistence, source quarantine, journal transitions, rollback, and runtime evidence.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
7
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { validateDraftConsolidationPlan, } from "./draft-consolidation.js";
|
|
9
|
+
import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
|
|
10
|
+
import * as RecipesReferences from "./recipes-references.js";
|
|
11
|
+
function canonicalJson(value) {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
14
|
+
}
|
|
15
|
+
if (value && typeof value === "object") {
|
|
16
|
+
const record = value;
|
|
17
|
+
return `{${Object.keys(record)
|
|
18
|
+
.sort()
|
|
19
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
20
|
+
.join(",")}}`;
|
|
21
|
+
}
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
function sha256(bytes) {
|
|
25
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
function recipeBytes(recipe) {
|
|
28
|
+
return Buffer.from(`${JSON.stringify(recipe, null, 2)}\n`, "utf8");
|
|
29
|
+
}
|
|
30
|
+
function writeBytesAtomic(path, bytes) {
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(temporary, bytes);
|
|
35
|
+
renameSync(temporary, path);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
rmSync(temporary, { force: true });
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function assertContainedRegularFile(path, root) {
|
|
43
|
+
const canonicalRoot = resolve(root);
|
|
44
|
+
const canonicalPath = resolve(path);
|
|
45
|
+
const relation = relative(canonicalRoot, canonicalPath);
|
|
46
|
+
if (!relation || relation.startsWith("..") || resolve(relation) === relation) {
|
|
47
|
+
throw new Error(`Path escapes consolidation root: ${path}`);
|
|
48
|
+
}
|
|
49
|
+
const stat = lstatSync(canonicalPath);
|
|
50
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
51
|
+
throw new Error(`Consolidation source must be a regular non-symlink file: ${path}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isContainedPath(path, root) {
|
|
55
|
+
const relation = relative(resolve(root), resolve(path));
|
|
56
|
+
return Boolean(relation) && !relation.startsWith("..") && resolve(relation) !== relation;
|
|
57
|
+
}
|
|
58
|
+
function assertNoSymlinkComponents(path, root) {
|
|
59
|
+
if (existsSync(root) && lstatSync(root).isSymbolicLink()) {
|
|
60
|
+
throw new Error(`Symlink is not allowed in consolidation state: ${root}`);
|
|
61
|
+
}
|
|
62
|
+
if (!isContainedPath(path, root)) {
|
|
63
|
+
throw new Error(`Path escapes consolidation root: ${path}`);
|
|
64
|
+
}
|
|
65
|
+
let current = resolve(root);
|
|
66
|
+
for (const segment of relative(current, resolve(path)).split(/[\\/]/u)) {
|
|
67
|
+
current = join(current, segment);
|
|
68
|
+
if (!existsSync(current))
|
|
69
|
+
break;
|
|
70
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
71
|
+
throw new Error(`Symlink is not allowed in consolidation state: ${current}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function captureRootIdentity(path) {
|
|
76
|
+
const absolute = resolve(path);
|
|
77
|
+
const lexical = lstatSync(absolute);
|
|
78
|
+
if (!lexical.isDirectory() || lexical.isSymbolicLink()) {
|
|
79
|
+
throw new Error(`Symlink is not allowed in consolidation state: ${absolute}`);
|
|
80
|
+
}
|
|
81
|
+
const canonical = realpathSync.native(absolute);
|
|
82
|
+
const stat = statSync(canonical, { bigint: true });
|
|
83
|
+
return {
|
|
84
|
+
dev: String(stat.dev),
|
|
85
|
+
ino: String(stat.ino),
|
|
86
|
+
path: absolute,
|
|
87
|
+
realpath: canonical,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function captureRootIdentities(options) {
|
|
91
|
+
return {
|
|
92
|
+
cycleDir: captureRootIdentity(options.cycleDir),
|
|
93
|
+
draftRoot: captureRootIdentity(options.draftRoot),
|
|
94
|
+
recipeRoot: captureRootIdentity(options.recipeRoot),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function verifyRootIdentities(recorded, options) {
|
|
98
|
+
if (!recorded || typeof recorded !== "object") {
|
|
99
|
+
throw new Error("Consolidation root identity is missing.");
|
|
100
|
+
}
|
|
101
|
+
const current = captureRootIdentities(options);
|
|
102
|
+
for (const key of ["cycleDir", "draftRoot", "recipeRoot"]) {
|
|
103
|
+
const expected = recorded[key];
|
|
104
|
+
if (!expected ||
|
|
105
|
+
expected.path !== current[key].path ||
|
|
106
|
+
expected.realpath !== current[key].realpath ||
|
|
107
|
+
expected.dev !== current[key].dev ||
|
|
108
|
+
expected.ino !== current[key].ino) {
|
|
109
|
+
throw new Error(`Consolidation root identity changed: ${key}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function readValidatedJournal(path, options) {
|
|
114
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
115
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
116
|
+
throw new Error("Invalid consolidation journal object.");
|
|
117
|
+
}
|
|
118
|
+
const journal = value;
|
|
119
|
+
const phases = [
|
|
120
|
+
"prepared",
|
|
121
|
+
"targets_validated",
|
|
122
|
+
"sources_quarantined",
|
|
123
|
+
"committed_evidence_pending",
|
|
124
|
+
"committed",
|
|
125
|
+
"rolled_back",
|
|
126
|
+
"rollback_required",
|
|
127
|
+
];
|
|
128
|
+
if (!phases.includes(journal.phase) ||
|
|
129
|
+
journal.cycleId !== basename(resolve(options.cycleDir)) ||
|
|
130
|
+
!journal.plan ||
|
|
131
|
+
journal.plan.cycleId !== journal.cycleId ||
|
|
132
|
+
journal.planSha256 !== sha256(canonicalJson(journal.plan)) ||
|
|
133
|
+
!["batch", "complete"].includes(journal.sourceScope) ||
|
|
134
|
+
!Array.isArray(journal.sources) ||
|
|
135
|
+
!Array.isArray(journal.targets)) {
|
|
136
|
+
throw new Error("Invalid or mismatched consolidation journal identity.");
|
|
137
|
+
}
|
|
138
|
+
verifyRootIdentities(journal.roots, options);
|
|
139
|
+
const derived = deriveOperations(journal.plan, options);
|
|
140
|
+
const operationIdentity = {
|
|
141
|
+
roots: journal.roots,
|
|
142
|
+
sourceScope: journal.sourceScope,
|
|
143
|
+
sources: journal.sources,
|
|
144
|
+
targets: journal.targets,
|
|
145
|
+
};
|
|
146
|
+
if (journal.operationsSha256 !== sha256(canonicalJson(operationIdentity)) ||
|
|
147
|
+
canonicalJson({ sources: journal.sources, targets: journal.targets }) !==
|
|
148
|
+
canonicalJson(derived)) {
|
|
149
|
+
throw new Error("Consolidation journal operations do not match its plan.");
|
|
150
|
+
}
|
|
151
|
+
const sourcePaths = new Set();
|
|
152
|
+
for (const source of journal.sources) {
|
|
153
|
+
if (!source ||
|
|
154
|
+
typeof source.path !== "string" ||
|
|
155
|
+
typeof source.quarantinePath !== "string" ||
|
|
156
|
+
typeof source.sha256 !== "string" ||
|
|
157
|
+
!isContainedPath(source.path, options.draftRoot) ||
|
|
158
|
+
!isContainedPath(source.quarantinePath, join(options.cycleDir, "quarantine")) ||
|
|
159
|
+
sourcePaths.has(resolve(source.path))) {
|
|
160
|
+
throw new Error("Invalid consolidation journal source path.");
|
|
161
|
+
}
|
|
162
|
+
assertNoSymlinkComponents(source.path, options.draftRoot);
|
|
163
|
+
assertNoSymlinkComponents(source.quarantinePath, options.cycleDir);
|
|
164
|
+
sourcePaths.add(resolve(source.path));
|
|
165
|
+
}
|
|
166
|
+
const targetPaths = new Set();
|
|
167
|
+
for (const target of journal.targets) {
|
|
168
|
+
const expectedPath = target && typeof target.target === "string"
|
|
169
|
+
? join(options.recipeRoot, `${target.target}.json`)
|
|
170
|
+
: "";
|
|
171
|
+
if (!target ||
|
|
172
|
+
typeof target.path !== "string" ||
|
|
173
|
+
typeof target.intendedSha256 !== "string" ||
|
|
174
|
+
resolve(target.path) !== resolve(expectedPath) ||
|
|
175
|
+
!isContainedPath(target.path, options.recipeRoot) ||
|
|
176
|
+
(target.backupPath !== undefined &&
|
|
177
|
+
(typeof target.backupPath !== "string" ||
|
|
178
|
+
!isContainedPath(target.backupPath, join(options.cycleDir, "backups")))) ||
|
|
179
|
+
targetPaths.has(resolve(target.path))) {
|
|
180
|
+
throw new Error("Invalid consolidation journal target path.");
|
|
181
|
+
}
|
|
182
|
+
assertNoSymlinkComponents(target.path, options.recipeRoot);
|
|
183
|
+
if (target.backupPath) {
|
|
184
|
+
assertNoSymlinkComponents(target.backupPath, options.cycleDir);
|
|
185
|
+
}
|
|
186
|
+
targetPaths.add(resolve(target.path));
|
|
187
|
+
}
|
|
188
|
+
return journal;
|
|
189
|
+
}
|
|
190
|
+
function listDraftFiles(root) {
|
|
191
|
+
if (!existsSync(root))
|
|
192
|
+
return [];
|
|
193
|
+
const files = [];
|
|
194
|
+
const visit = (directory) => {
|
|
195
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
196
|
+
const path = join(directory, entry.name);
|
|
197
|
+
if (entry.isDirectory())
|
|
198
|
+
visit(path);
|
|
199
|
+
else if (entry.isFile() && [".json", ".md"].includes(extname(entry.name))) {
|
|
200
|
+
files.push(resolve(path));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
visit(root);
|
|
205
|
+
return files.sort();
|
|
206
|
+
}
|
|
207
|
+
function uniqueTargetItems(plan) {
|
|
208
|
+
const targets = new Map();
|
|
209
|
+
for (const item of plan.drafts) {
|
|
210
|
+
if (item.action === "discard" || !item.target)
|
|
211
|
+
continue;
|
|
212
|
+
if (!targets.has(item.target))
|
|
213
|
+
targets.set(item.target, item);
|
|
214
|
+
}
|
|
215
|
+
return [...targets.values()].sort((left, right) => String(left.target).localeCompare(String(right.target)));
|
|
216
|
+
}
|
|
217
|
+
function deriveOperations(plan, options) {
|
|
218
|
+
const quarantineDir = join(options.cycleDir, "quarantine");
|
|
219
|
+
const backupDir = join(options.cycleDir, "backups");
|
|
220
|
+
const sources = plan.drafts.map((item, index) => ({
|
|
221
|
+
path: resolve(item.draft),
|
|
222
|
+
quarantinePath: join(quarantineDir, `${String(index + 1).padStart(4, "0")}-${basename(item.draft)}`),
|
|
223
|
+
sha256: item.sha256,
|
|
224
|
+
}));
|
|
225
|
+
const targets = uniqueTargetItems(plan).map((item) => ({
|
|
226
|
+
...(item.targetSha256 !== null
|
|
227
|
+
? { backupPath: join(backupDir, `${item.target}.json`) }
|
|
228
|
+
: {}),
|
|
229
|
+
expectedSha256: item.targetSha256 ?? null,
|
|
230
|
+
intendedSha256: sha256(recipeBytes(item.recipe)),
|
|
231
|
+
path: join(options.recipeRoot, `${item.target}.json`),
|
|
232
|
+
recipe: item.recipe,
|
|
233
|
+
target: item.target,
|
|
234
|
+
}));
|
|
235
|
+
return { sources, targets };
|
|
236
|
+
}
|
|
237
|
+
function createJournal(plan, options) {
|
|
238
|
+
const operations = deriveOperations(plan, options);
|
|
239
|
+
const roots = captureRootIdentities(options);
|
|
240
|
+
const sourceScope = options.sourceScope ?? "complete";
|
|
241
|
+
for (const target of operations.targets) {
|
|
242
|
+
if (target.backupPath && existsSync(target.path)) {
|
|
243
|
+
writeBytesAtomic(target.backupPath, readFileSync(target.path));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
cycleId: plan.cycleId,
|
|
248
|
+
operationsSha256: sha256(canonicalJson({ roots, sourceScope, ...operations })),
|
|
249
|
+
phase: "prepared",
|
|
250
|
+
plan,
|
|
251
|
+
planSha256: sha256(canonicalJson(plan)),
|
|
252
|
+
roots,
|
|
253
|
+
sourceScope,
|
|
254
|
+
...operations,
|
|
255
|
+
updatedAt: new Date().toISOString(),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function persistJournal(path, journal) {
|
|
259
|
+
journal.updatedAt = new Date().toISOString();
|
|
260
|
+
writeJsonAtomic(path, journal);
|
|
261
|
+
}
|
|
262
|
+
function verifyPreconditions(journal, options) {
|
|
263
|
+
const plannedSources = journal.sources.map((source) => source.path).sort();
|
|
264
|
+
const currentSources = listDraftFiles(options.draftRoot);
|
|
265
|
+
if (journal.sourceScope === "complete" &&
|
|
266
|
+
JSON.stringify(plannedSources) !== JSON.stringify(currentSources)) {
|
|
267
|
+
throw new Error("Draft inventory changed after planning.");
|
|
268
|
+
}
|
|
269
|
+
for (const source of journal.sources) {
|
|
270
|
+
assertContainedRegularFile(source.path, options.draftRoot);
|
|
271
|
+
if (sha256(readFileSync(source.path)) !== source.sha256) {
|
|
272
|
+
throw new Error(`Draft source changed: ${source.path}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const target of journal.targets) {
|
|
276
|
+
if (target.expectedSha256 === null) {
|
|
277
|
+
if (existsSync(target.path)) {
|
|
278
|
+
throw new Error(`Active target appeared after planning: ${target.target}`);
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
assertContainedRegularFile(target.path, options.recipeRoot);
|
|
283
|
+
if (sha256(readFileSync(target.path)) !== target.expectedSha256) {
|
|
284
|
+
throw new Error(`Active target changed after planning: ${target.target}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function writeEvidence(path, journal, outcome) {
|
|
289
|
+
writeJsonAtomic(path, {
|
|
290
|
+
cycleId: journal.cycleId,
|
|
291
|
+
outcome,
|
|
292
|
+
phase: journal.phase,
|
|
293
|
+
decisions: journal.plan.drafts.map((item) => ({
|
|
294
|
+
action: item.action,
|
|
295
|
+
draft: item.draft,
|
|
296
|
+
rationale: item.rationale,
|
|
297
|
+
sha256: item.sha256,
|
|
298
|
+
...(item.target ? { target: item.target } : {}),
|
|
299
|
+
...(item.targetSha256 !== undefined
|
|
300
|
+
? { targetSha256: item.targetSha256 }
|
|
301
|
+
: {}),
|
|
302
|
+
})),
|
|
303
|
+
...(journal.error ? { error: journal.error } : {}),
|
|
304
|
+
sources: journal.sources.map((source) => ({
|
|
305
|
+
path: source.path,
|
|
306
|
+
quarantinePath: source.quarantinePath,
|
|
307
|
+
})),
|
|
308
|
+
targets: journal.targets.map((target) => ({
|
|
309
|
+
path: target.path,
|
|
310
|
+
target: target.target,
|
|
311
|
+
})),
|
|
312
|
+
ts: new Date().toISOString(),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function restoreJournal(journal, options) {
|
|
316
|
+
const errors = [];
|
|
317
|
+
for (const source of [...journal.sources].reverse()) {
|
|
318
|
+
try {
|
|
319
|
+
if (existsSync(source.path)) {
|
|
320
|
+
assertContainedRegularFile(source.path, options.draftRoot);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
assertNoSymlinkComponents(source.path, options.draftRoot);
|
|
324
|
+
}
|
|
325
|
+
assertNoSymlinkComponents(source.quarantinePath, options.cycleDir);
|
|
326
|
+
if (existsSync(source.quarantinePath)) {
|
|
327
|
+
assertContainedRegularFile(source.quarantinePath, options.cycleDir);
|
|
328
|
+
}
|
|
329
|
+
const originalHash = existsSync(source.path)
|
|
330
|
+
? sha256(readFileSync(source.path))
|
|
331
|
+
: undefined;
|
|
332
|
+
const quarantineHash = existsSync(source.quarantinePath)
|
|
333
|
+
? sha256(readFileSync(source.quarantinePath))
|
|
334
|
+
: undefined;
|
|
335
|
+
if (originalHash !== undefined && originalHash !== source.sha256) {
|
|
336
|
+
throw new Error("original source changed after crash");
|
|
337
|
+
}
|
|
338
|
+
if (quarantineHash !== undefined && quarantineHash !== source.sha256) {
|
|
339
|
+
throw new Error("quarantined source changed after crash");
|
|
340
|
+
}
|
|
341
|
+
if (originalHash === undefined && quarantineHash === source.sha256) {
|
|
342
|
+
mkdirSync(dirname(source.path), { recursive: true });
|
|
343
|
+
renameSync(source.quarantinePath, source.path);
|
|
344
|
+
}
|
|
345
|
+
else if (originalHash === undefined && quarantineHash === undefined) {
|
|
346
|
+
throw new Error("source and quarantine are both missing");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
errors.push(`source ${source.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
for (const target of [...journal.targets].reverse()) {
|
|
354
|
+
try {
|
|
355
|
+
assertNoSymlinkComponents(target.path, options.recipeRoot);
|
|
356
|
+
if (existsSync(target.path)) {
|
|
357
|
+
assertContainedRegularFile(target.path, options.recipeRoot);
|
|
358
|
+
}
|
|
359
|
+
if (target.backupPath) {
|
|
360
|
+
assertNoSymlinkComponents(target.backupPath, options.cycleDir);
|
|
361
|
+
if (existsSync(target.backupPath)) {
|
|
362
|
+
assertContainedRegularFile(target.backupPath, options.cycleDir);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const currentHash = existsSync(target.path)
|
|
366
|
+
? sha256(readFileSync(target.path))
|
|
367
|
+
: undefined;
|
|
368
|
+
if ((target.expectedSha256 === null && currentHash === undefined) ||
|
|
369
|
+
currentHash === target.expectedSha256) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (currentHash !== target.intendedSha256) {
|
|
373
|
+
throw new Error("target changed after crash");
|
|
374
|
+
}
|
|
375
|
+
if (target.expectedSha256 === null) {
|
|
376
|
+
rmSync(target.path, { force: true });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (!target.backupPath || !existsSync(target.backupPath)) {
|
|
380
|
+
throw new Error("target backup is missing");
|
|
381
|
+
}
|
|
382
|
+
const backup = readFileSync(target.backupPath);
|
|
383
|
+
if (sha256(backup) !== target.expectedSha256) {
|
|
384
|
+
throw new Error("target backup changed after crash");
|
|
385
|
+
}
|
|
386
|
+
writeBytesAtomic(target.path, backup);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
errors.push(`target ${target.target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return errors;
|
|
393
|
+
}
|
|
394
|
+
function verifyCommitState(journal, options) {
|
|
395
|
+
for (const source of journal.sources) {
|
|
396
|
+
if (existsSync(source.path)) {
|
|
397
|
+
throw new Error(`Committed source still exists: ${source.path}`);
|
|
398
|
+
}
|
|
399
|
+
if (!existsSync(source.quarantinePath)) {
|
|
400
|
+
throw new Error(`Committed quarantine is missing: ${source.quarantinePath}`);
|
|
401
|
+
}
|
|
402
|
+
assertContainedRegularFile(source.quarantinePath, join(options.cycleDir, "quarantine"));
|
|
403
|
+
if (sha256(readFileSync(source.quarantinePath)) !== source.sha256) {
|
|
404
|
+
throw new Error(`Committed quarantine changed: ${source.quarantinePath}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const target of journal.targets) {
|
|
408
|
+
if (!existsSync(target.path)) {
|
|
409
|
+
throw new Error(`Committed target is missing: ${target.target}`);
|
|
410
|
+
}
|
|
411
|
+
assertContainedRegularFile(target.path, options.recipeRoot);
|
|
412
|
+
if (sha256(readFileSync(target.path)) !== target.intendedSha256) {
|
|
413
|
+
throw new Error(`Committed target changed: ${target.target}`);
|
|
414
|
+
}
|
|
415
|
+
if (!RecipesReferences.readResolvedRecipeConfig(target.path)) {
|
|
416
|
+
throw new Error(`Committed target is invalid: ${target.target}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (journal.sourceScope === "complete" &&
|
|
420
|
+
listDraftFiles(options.draftRoot).length !== 0) {
|
|
421
|
+
throw new Error("Draft directory changed during consolidation.");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function verifyRollbackState(journal, options) {
|
|
425
|
+
for (const source of journal.sources) {
|
|
426
|
+
if (existsSync(source.path)) {
|
|
427
|
+
assertContainedRegularFile(source.path, options.draftRoot);
|
|
428
|
+
}
|
|
429
|
+
if (!existsSync(source.path) || sha256(readFileSync(source.path)) !== source.sha256) {
|
|
430
|
+
throw new Error(`Rolled-back source is missing or changed: ${source.path}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
for (const target of journal.targets) {
|
|
434
|
+
assertNoSymlinkComponents(target.path, options.recipeRoot);
|
|
435
|
+
if (existsSync(target.path)) {
|
|
436
|
+
assertContainedRegularFile(target.path, options.recipeRoot);
|
|
437
|
+
}
|
|
438
|
+
const currentHash = existsSync(target.path)
|
|
439
|
+
? sha256(readFileSync(target.path))
|
|
440
|
+
: undefined;
|
|
441
|
+
if ((target.expectedSha256 === null && currentHash !== undefined) ||
|
|
442
|
+
(target.expectedSha256 !== null && currentHash !== target.expectedSha256)) {
|
|
443
|
+
throw new Error(`Rolled-back target is missing or changed: ${target.target}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function withMutationLocks(paths, mutate, onContention) {
|
|
448
|
+
const ordered = [...new Set(paths.map((path) => resolve(path)))].sort();
|
|
449
|
+
const acquire = (index) => index >= ordered.length
|
|
450
|
+
? mutate()
|
|
451
|
+
: withFileMutationLock(ordered[index], () => acquire(index + 1), { onContention });
|
|
452
|
+
return acquire(0);
|
|
453
|
+
}
|
|
454
|
+
export function applyDraftConsolidationPlan(plan, options) {
|
|
455
|
+
const validation = validateDraftConsolidationPlan(options.inventory, plan);
|
|
456
|
+
if (!validation.ok) {
|
|
457
|
+
throw new Error(`Invalid consolidation plan: ${validation.errors.join("; ")}`);
|
|
458
|
+
}
|
|
459
|
+
const journalPath = join(options.cycleDir, "journal.json");
|
|
460
|
+
const evidencePath = join(options.cycleDir, "evidence.json");
|
|
461
|
+
const lockPaths = [
|
|
462
|
+
options.draftRoot,
|
|
463
|
+
...plan.drafts.map((item) => item.draft),
|
|
464
|
+
...uniqueTargetItems(plan).map((item) => join(options.recipeRoot, `${item.target}.json`)),
|
|
465
|
+
];
|
|
466
|
+
return withMutationLocks(lockPaths, () => {
|
|
467
|
+
if (existsSync(journalPath)) {
|
|
468
|
+
throw new Error(`Consolidation cycle already claimed: ${plan.cycleId}`);
|
|
469
|
+
}
|
|
470
|
+
mkdirSync(options.cycleDir, { recursive: true });
|
|
471
|
+
const journal = createJournal(plan, options);
|
|
472
|
+
persistJournal(journalPath, journal);
|
|
473
|
+
options.checkpoint?.("prepared");
|
|
474
|
+
let mutationStarted = false;
|
|
475
|
+
try {
|
|
476
|
+
verifyPreconditions(journal, options);
|
|
477
|
+
for (const target of journal.targets) {
|
|
478
|
+
mutationStarted = true;
|
|
479
|
+
writeJsonAtomic(target.path, target.recipe);
|
|
480
|
+
options.checkpoint?.("target_written");
|
|
481
|
+
if (!RecipesReferences.readResolvedRecipeConfig(target.path)) {
|
|
482
|
+
throw new Error(`Persisted target recipe is invalid: ${target.target}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
journal.phase = "targets_validated";
|
|
486
|
+
persistJournal(journalPath, journal);
|
|
487
|
+
options.checkpoint?.("targets_validated");
|
|
488
|
+
for (const source of journal.sources) {
|
|
489
|
+
if (sha256(readFileSync(source.path)) !== source.sha256) {
|
|
490
|
+
throw new Error(`Draft source changed before quarantine: ${source.path}`);
|
|
491
|
+
}
|
|
492
|
+
mkdirSync(dirname(source.quarantinePath), { recursive: true });
|
|
493
|
+
renameSync(source.path, source.quarantinePath);
|
|
494
|
+
options.checkpoint?.("source_quarantined");
|
|
495
|
+
}
|
|
496
|
+
journal.phase = "sources_quarantined";
|
|
497
|
+
persistJournal(journalPath, journal);
|
|
498
|
+
options.checkpoint?.("sources_quarantined");
|
|
499
|
+
verifyCommitState(journal, options);
|
|
500
|
+
journal.phase = "committed_evidence_pending";
|
|
501
|
+
persistJournal(journalPath, journal);
|
|
502
|
+
options.checkpoint?.("committed_evidence_pending");
|
|
503
|
+
writeEvidence(evidencePath, journal, "committed");
|
|
504
|
+
options.checkpoint?.("evidence_written");
|
|
505
|
+
journal.phase = "committed";
|
|
506
|
+
persistJournal(journalPath, journal);
|
|
507
|
+
return {
|
|
508
|
+
cycleId: plan.cycleId,
|
|
509
|
+
evidencePath,
|
|
510
|
+
phase: "committed",
|
|
511
|
+
plan: journal.plan,
|
|
512
|
+
sources: journal.sources.length,
|
|
513
|
+
targets: journal.targets.length,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
journal.error = error instanceof Error ? error.message : String(error);
|
|
518
|
+
const rollbackErrors = mutationStarted ? restoreJournal(journal, options) : [];
|
|
519
|
+
journal.phase = rollbackErrors.length > 0 ? "rollback_required" : "rolled_back";
|
|
520
|
+
if (rollbackErrors.length > 0) {
|
|
521
|
+
journal.error = `${journal.error}; rollback: ${rollbackErrors.join("; ")}`;
|
|
522
|
+
}
|
|
523
|
+
persistJournal(journalPath, journal);
|
|
524
|
+
if (journal.phase === "rolled_back") {
|
|
525
|
+
writeEvidence(evidencePath, journal, "rolled_back");
|
|
526
|
+
}
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}, options.onLockContention);
|
|
530
|
+
}
|
|
531
|
+
export function recoverDraftConsolidationCycle(options) {
|
|
532
|
+
const journalPath = join(options.cycleDir, "journal.json");
|
|
533
|
+
const evidencePath = join(options.cycleDir, "evidence.json");
|
|
534
|
+
const observed = readValidatedJournal(journalPath, options);
|
|
535
|
+
const lockPaths = [
|
|
536
|
+
options.draftRoot,
|
|
537
|
+
...observed.sources.map((source) => source.path),
|
|
538
|
+
...observed.targets.map((target) => target.path),
|
|
539
|
+
];
|
|
540
|
+
return withMutationLocks(lockPaths, () => {
|
|
541
|
+
const journal = readValidatedJournal(journalPath, options);
|
|
542
|
+
if (journal.phase === "committed") {
|
|
543
|
+
verifyCommitState(journal, options);
|
|
544
|
+
writeEvidence(evidencePath, journal, "committed");
|
|
545
|
+
return {
|
|
546
|
+
cycleId: journal.cycleId,
|
|
547
|
+
evidencePath,
|
|
548
|
+
phase: "committed",
|
|
549
|
+
plan: journal.plan,
|
|
550
|
+
sources: journal.sources.length,
|
|
551
|
+
targets: journal.targets.length,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (journal.phase === "rolled_back") {
|
|
555
|
+
verifyRollbackState(journal, options);
|
|
556
|
+
writeEvidence(evidencePath, journal, "rolled_back");
|
|
557
|
+
return {
|
|
558
|
+
cycleId: journal.cycleId,
|
|
559
|
+
evidencePath,
|
|
560
|
+
phase: "rolled_back",
|
|
561
|
+
plan: journal.plan,
|
|
562
|
+
sources: journal.sources.length,
|
|
563
|
+
targets: journal.targets.length,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (journal.phase === "sources_quarantined" ||
|
|
567
|
+
journal.phase === "committed_evidence_pending") {
|
|
568
|
+
verifyCommitState(journal, options);
|
|
569
|
+
journal.phase = "committed_evidence_pending";
|
|
570
|
+
persistJournal(journalPath, journal);
|
|
571
|
+
writeEvidence(evidencePath, journal, "committed");
|
|
572
|
+
journal.phase = "committed";
|
|
573
|
+
persistJournal(journalPath, journal);
|
|
574
|
+
return {
|
|
575
|
+
cycleId: journal.cycleId,
|
|
576
|
+
evidencePath,
|
|
577
|
+
phase: "committed",
|
|
578
|
+
plan: journal.plan,
|
|
579
|
+
sources: journal.sources.length,
|
|
580
|
+
targets: journal.targets.length,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const rollbackErrors = restoreJournal(journal, options);
|
|
584
|
+
if (rollbackErrors.length === 0) {
|
|
585
|
+
try {
|
|
586
|
+
verifyRollbackState(journal, options);
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
rollbackErrors.push(error instanceof Error ? error.message : String(error));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
journal.phase = rollbackErrors.length > 0 ? "rollback_required" : "rolled_back";
|
|
593
|
+
if (rollbackErrors.length > 0) {
|
|
594
|
+
journal.error = `rollback: ${rollbackErrors.join("; ")}`;
|
|
595
|
+
}
|
|
596
|
+
persistJournal(journalPath, journal);
|
|
597
|
+
if (journal.phase === "rollback_required") {
|
|
598
|
+
throw new Error(journal.error);
|
|
599
|
+
}
|
|
600
|
+
writeEvidence(evidencePath, journal, "rolled_back");
|
|
601
|
+
return {
|
|
602
|
+
cycleId: journal.cycleId,
|
|
603
|
+
evidencePath,
|
|
604
|
+
phase: "rolled_back",
|
|
605
|
+
plan: journal.plan,
|
|
606
|
+
sources: journal.sources.length,
|
|
607
|
+
targets: journal.targets.length,
|
|
608
|
+
};
|
|
609
|
+
});
|
|
610
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft-memory consolidation contracts.
|
|
3
|
+
* Owns: complete inventory normalization and promote/merge/discard plan validation for the automatic executor.
|
|
4
|
+
*/
|
|
5
|
+
export type DraftConsolidationAction = "promote" | "merge" | "discard";
|
|
6
|
+
export interface DraftConsolidationInventoryItem {
|
|
7
|
+
id: string;
|
|
8
|
+
path: string;
|
|
9
|
+
sha256: string;
|
|
10
|
+
valid: boolean;
|
|
11
|
+
description?: string;
|
|
12
|
+
diagnostics?: unknown;
|
|
13
|
+
riskLabels?: string[];
|
|
14
|
+
templatePreview?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface DraftConsolidationPlanItem {
|
|
17
|
+
action: DraftConsolidationAction;
|
|
18
|
+
draft: string;
|
|
19
|
+
rationale: string;
|
|
20
|
+
recipe?: Record<string, unknown>;
|
|
21
|
+
sha256: string;
|
|
22
|
+
target?: string;
|
|
23
|
+
targetSha256?: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface DraftConsolidationPlan {
|
|
26
|
+
createdAt: string;
|
|
27
|
+
cycleId: string;
|
|
28
|
+
drafts: DraftConsolidationPlanItem[];
|
|
29
|
+
}
|
|
30
|
+
export interface DraftConsolidationPlanValidation {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
errors: string[];
|
|
33
|
+
}
|
|
34
|
+
export declare function normalizeDraftConsolidationInventory(records: Array<Record<string, unknown>>): DraftConsolidationInventoryItem[];
|
|
35
|
+
export declare function validateDraftConsolidationPlan(inventory: DraftConsolidationInventoryItem[], plan: DraftConsolidationPlan): DraftConsolidationPlanValidation;
|