@sleepy_panda_srl/portulan 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/cli/README.md +55 -3
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- package/spec/workspace.schema.json +67 -11
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
// One CLI split/join, bounded to its original sources. The planner proves the saved operation again.
|
|
2
|
+
// Immutable lease children serialize recovery without deleting a possibly live writer's lock.
|
|
3
|
+
// Terminal proof outlives rollback authority; unproved initialization remains for human review.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import {
|
|
10
|
+
CompileError,
|
|
11
|
+
atomicWrite,
|
|
12
|
+
guardedRemove,
|
|
13
|
+
sameContent,
|
|
14
|
+
workspaceProtection,
|
|
15
|
+
} from "./guidance-files.mjs";
|
|
16
|
+
import { isInside } from "./inside.mjs";
|
|
17
|
+
|
|
18
|
+
export const SOURCE_JOURNAL = ".portulan-instructions-journal";
|
|
19
|
+
const ROOTS = ["CLAUDE.md", ".claude/CLAUDE.md"],
|
|
20
|
+
active = new Set(),
|
|
21
|
+
UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
|
|
22
|
+
HEX = /^[a-f0-9]{64}$/;
|
|
23
|
+
const encode = (value) => `${JSON.stringify(value, null, 2)}\n`,
|
|
24
|
+
sha = (value) => createHash("sha256").update(value).digest("hex"),
|
|
25
|
+
posix = (value) => value.split(path.sep).join("/");
|
|
26
|
+
const fail = (message) => {
|
|
27
|
+
throw new CompileError(`instruction recovery: ${message}`);
|
|
28
|
+
};
|
|
29
|
+
const sameId = (a, b) =>
|
|
30
|
+
Boolean(
|
|
31
|
+
a &&
|
|
32
|
+
b &&
|
|
33
|
+
a.dev === b.dev &&
|
|
34
|
+
a.ino === b.ino &&
|
|
35
|
+
(b.mode === undefined || a.mode === b.mode),
|
|
36
|
+
);
|
|
37
|
+
const id = (at) => ({ dev: at.dev, ino: at.ino, mode: at.mode });
|
|
38
|
+
const validId = (value) =>
|
|
39
|
+
keys(value, "dev,ino,mode") &&
|
|
40
|
+
Number.isSafeInteger(value.dev) &&
|
|
41
|
+
value.dev >= 0 &&
|
|
42
|
+
Number.isSafeInteger(value.ino) &&
|
|
43
|
+
value.ino >= 0 &&
|
|
44
|
+
Number.isSafeInteger(value.mode) &&
|
|
45
|
+
value.mode >= 0 &&
|
|
46
|
+
value.mode <= 0o177777;
|
|
47
|
+
const keys = (value, names) =>
|
|
48
|
+
value &&
|
|
49
|
+
typeof value === "object" &&
|
|
50
|
+
!Array.isArray(value) &&
|
|
51
|
+
Object.keys(value).sort().join(",") === names.split(",").sort().join(",");
|
|
52
|
+
function entry(file) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.lstatSync(file);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function relative(value) {
|
|
61
|
+
return (
|
|
62
|
+
typeof value === "string" &&
|
|
63
|
+
value !== "" &&
|
|
64
|
+
!/[\\\u0000-\u001f\u007f]/.test(value) &&
|
|
65
|
+
!path.posix.isAbsolute(value) &&
|
|
66
|
+
value
|
|
67
|
+
.split("/")
|
|
68
|
+
.every((part) => part !== "" && part !== "." && part !== "..")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
function inside(root, rel) {
|
|
72
|
+
if (!relative(rel)) fail(`invalid saved path ${JSON.stringify(rel)}`);
|
|
73
|
+
const full = path.resolve(root, ...rel.split("/"));
|
|
74
|
+
if (!isInside(root, full)) fail("saved path escapes its project");
|
|
75
|
+
let at = root;
|
|
76
|
+
for (const part of rel.split("/").slice(0, -1)) {
|
|
77
|
+
at = path.join(at, part);
|
|
78
|
+
const s = entry(at);
|
|
79
|
+
if (s && (!s.isDirectory() || s.isSymbolicLink()))
|
|
80
|
+
fail(`${rel} has an aliased or non-directory parent`);
|
|
81
|
+
}
|
|
82
|
+
return full;
|
|
83
|
+
}
|
|
84
|
+
function readFile(file) {
|
|
85
|
+
const at = entry(file);
|
|
86
|
+
if (!at) return null;
|
|
87
|
+
if (!at.isFile() || at.isSymbolicLink() || at.size > 20 * 1024 * 1024)
|
|
88
|
+
fail(`${file} is not a bounded ordinary file`);
|
|
89
|
+
const fd = fs.openSync(
|
|
90
|
+
file,
|
|
91
|
+
fs.constants.O_RDONLY |
|
|
92
|
+
(fs.constants.O_NOFOLLOW ?? 0) |
|
|
93
|
+
fs.constants.O_NONBLOCK,
|
|
94
|
+
);
|
|
95
|
+
try {
|
|
96
|
+
const held = fs.fstatSync(fd);
|
|
97
|
+
if (!held.isFile() || !sameId(held, id(at)) || held.size > 20 * 1024 * 1024)
|
|
98
|
+
fail(`${file} changed while reading`);
|
|
99
|
+
return {
|
|
100
|
+
file,
|
|
101
|
+
bytes: fs.readFileSync(fd),
|
|
102
|
+
identity: id(held),
|
|
103
|
+
links: held.nlink,
|
|
104
|
+
};
|
|
105
|
+
} finally {
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function temporary(name, base, bytes) {
|
|
110
|
+
for (const claim of [false, true]) {
|
|
111
|
+
const prefix = `.${base}.portulan-guidance${claim ? "-claim" : ""}.`;
|
|
112
|
+
if (!name.startsWith(prefix)) continue;
|
|
113
|
+
const parts = name.slice(prefix.length).split(".");
|
|
114
|
+
return (
|
|
115
|
+
parts.length === (claim ? 3 : 2) &&
|
|
116
|
+
/^\d+$/.test(parts[0]) &&
|
|
117
|
+
UUID.test(parts[1]) &&
|
|
118
|
+
(!claim || parts[2] === sha(bytes))
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
function metadataBase(name) {
|
|
124
|
+
const match =
|
|
125
|
+
/^\.((?:owner|terminal|lease-[a-f0-9-]{36}|(?:stage|claim|undo-stage|undo-claim|retired)-\d+-[a-f0-9-]{36})\.json)\.portulan-guidance(?:-claim)?\.\d+\.[a-f0-9-]{36}(?:\.[a-f0-9]{64})?$/.exec(
|
|
126
|
+
name,
|
|
127
|
+
);
|
|
128
|
+
return match ? match[1] : name;
|
|
129
|
+
}
|
|
130
|
+
function metadataName(name) {
|
|
131
|
+
return /^(?:owner|terminal|lease-[a-f0-9-]{36}|(?:stage|claim|undo-stage|undo-claim|retired)-\d+-[a-f0-9-]{36})\.json$/.test(
|
|
132
|
+
metadataBase(name),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
function metadataAliases(found) {
|
|
136
|
+
const base = metadataBase(path.basename(found.file));
|
|
137
|
+
return fs
|
|
138
|
+
.readdirSync(path.dirname(found.file))
|
|
139
|
+
.filter(
|
|
140
|
+
(name) =>
|
|
141
|
+
name !== path.basename(found.file) &&
|
|
142
|
+
(name === base || temporary(name, base, found.bytes)),
|
|
143
|
+
)
|
|
144
|
+
.flatMap((name) => {
|
|
145
|
+
const file = path.join(path.dirname(found.file), name);
|
|
146
|
+
try {
|
|
147
|
+
const other = readFile(file);
|
|
148
|
+
return other &&
|
|
149
|
+
sameId(other.identity, found.identity) &&
|
|
150
|
+
sameContent(other.bytes, found.bytes)
|
|
151
|
+
? [other]
|
|
152
|
+
: [];
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function canonical(file) {
|
|
159
|
+
const found = readFile(file);
|
|
160
|
+
if (!found) return null;
|
|
161
|
+
if (
|
|
162
|
+
(found.identity.mode & 0o777) !== 0o600 ||
|
|
163
|
+
found.links !== metadataAliases(found).length + 1
|
|
164
|
+
)
|
|
165
|
+
fail(`${file} is not private metadata with proved aliases`);
|
|
166
|
+
let value;
|
|
167
|
+
try {
|
|
168
|
+
value = JSON.parse(
|
|
169
|
+
new TextDecoder("utf-8", { fatal: true }).decode(found.bytes),
|
|
170
|
+
);
|
|
171
|
+
} catch {
|
|
172
|
+
fail(`${file} is malformed metadata`);
|
|
173
|
+
}
|
|
174
|
+
if (!sameContent(found.bytes, encode(value)))
|
|
175
|
+
fail(`${file} is not canonical metadata`);
|
|
176
|
+
return { ...found, value };
|
|
177
|
+
}
|
|
178
|
+
function removeMetadata(found) {
|
|
179
|
+
const base = metadataBase(path.basename(found.file));
|
|
180
|
+
for (const alias of metadataAliases(found))
|
|
181
|
+
if (entry(alias.file))
|
|
182
|
+
guardedRemove(alias.file, {
|
|
183
|
+
before: alias.bytes,
|
|
184
|
+
identity: alias.identity,
|
|
185
|
+
allowLinks: true,
|
|
186
|
+
claimBase: base,
|
|
187
|
+
});
|
|
188
|
+
if (entry(found.file))
|
|
189
|
+
guardedRemove(found.file, {
|
|
190
|
+
before: found.bytes,
|
|
191
|
+
identity: found.identity,
|
|
192
|
+
claimBase: base,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function binding(root, workspace) {
|
|
196
|
+
return (
|
|
197
|
+
posix(path.relative(fs.realpathSync(root), fs.realpathSync(workspace))) ||
|
|
198
|
+
"."
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
function namespace() {
|
|
202
|
+
try {
|
|
203
|
+
let boot;
|
|
204
|
+
if (process.platform === "linux")
|
|
205
|
+
boot =
|
|
206
|
+
fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8") +
|
|
207
|
+
fs.readlinkSync("/proc/self/ns/pid");
|
|
208
|
+
else if (process.platform === "darwin")
|
|
209
|
+
boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], {
|
|
210
|
+
encoding: "utf8",
|
|
211
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
212
|
+
}).trim();
|
|
213
|
+
else return null;
|
|
214
|
+
return sha(
|
|
215
|
+
`${process.platform}\n${os.hostname()}\n${process.getuid()}\n${boot}`,
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function dead(lease) {
|
|
222
|
+
if (lease.state === "released") return true;
|
|
223
|
+
try {
|
|
224
|
+
process.kill(lease.pid, 0);
|
|
225
|
+
return false;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return error.code === "ESRCH";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function terminalShape(value, root, workspace) {
|
|
231
|
+
return (
|
|
232
|
+
keys(value, "format,kind,state,owner,nonce,workspace,root,roots,paths") &&
|
|
233
|
+
value.format === 1 &&
|
|
234
|
+
value.kind === "terminal" &&
|
|
235
|
+
["committed", "aborted"].includes(value.state) &&
|
|
236
|
+
UUID.test(value.nonce) &&
|
|
237
|
+
HEX.test(value.owner) &&
|
|
238
|
+
value.workspace === binding(root, workspace) &&
|
|
239
|
+
validId(value.root) &&
|
|
240
|
+
sameId(fs.statSync(root), value.root) &&
|
|
241
|
+
Array.isArray(value.roots) &&
|
|
242
|
+
value.roots.length > 0 &&
|
|
243
|
+
value.roots.every((rel) => ROOTS.includes(rel)) &&
|
|
244
|
+
Array.isArray(value.paths) &&
|
|
245
|
+
value.paths.every(relative) &&
|
|
246
|
+
value.roots.every((rel) => value.paths.includes(rel))
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
export function sourceJournalPresent(root) {
|
|
250
|
+
return Boolean(entry(path.join(root, SOURCE_JOURNAL)));
|
|
251
|
+
}
|
|
252
|
+
export function assertNoSourceJournal(root) {
|
|
253
|
+
if (sourceJournalPresent(root))
|
|
254
|
+
fail(
|
|
255
|
+
`pending journal at ${path.join(root, SOURCE_JOURNAL)}; resume instructions --write before another writer runs`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
export function assertJournalScope(root, workspace) {
|
|
259
|
+
const manifest = JSON.parse(
|
|
260
|
+
fs.readFileSync(path.join(workspace, "workspace.json"), "utf8"),
|
|
261
|
+
);
|
|
262
|
+
const reserved = SOURCE_JOURNAL.toLowerCase(),
|
|
263
|
+
protectedPaths = workspaceProtection(
|
|
264
|
+
root,
|
|
265
|
+
posix(path.relative(root, workspace)) || ".",
|
|
266
|
+
manifest,
|
|
267
|
+
);
|
|
268
|
+
const clash = protectedPaths.find((p) => {
|
|
269
|
+
const rel = p.path.toLowerCase();
|
|
270
|
+
return (
|
|
271
|
+
rel === reserved ||
|
|
272
|
+
rel.startsWith(reserved + "/") ||
|
|
273
|
+
(p.directory && (rel === "." || reserved.startsWith(rel + "/")))
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
if (clash)
|
|
277
|
+
fail(
|
|
278
|
+
`${SOURCE_JOURNAL} overlaps current slot or protected path ${clash.path}; preserve the directory and review that binding`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export class InstructionJournal {
|
|
283
|
+
constructor(root, workspace, context) {
|
|
284
|
+
this.root = path.resolve(root);
|
|
285
|
+
this.workspace = workspace;
|
|
286
|
+
this.context = context;
|
|
287
|
+
this.dir = path.join(this.root, SOURCE_JOURNAL);
|
|
288
|
+
this.records = [];
|
|
289
|
+
this.known = new Map();
|
|
290
|
+
this.lease = null;
|
|
291
|
+
}
|
|
292
|
+
checkRoot() {
|
|
293
|
+
assertJournalScope(this.root, this.workspace);
|
|
294
|
+
const at = entry(this.dir);
|
|
295
|
+
if (
|
|
296
|
+
!at?.isDirectory() ||
|
|
297
|
+
at.isSymbolicLink() ||
|
|
298
|
+
(at.mode & 0o777) !== 0o700 ||
|
|
299
|
+
!sameId(at, this.dirId)
|
|
300
|
+
)
|
|
301
|
+
fail(`journal changed or is not private: ${this.dir}`);
|
|
302
|
+
if (
|
|
303
|
+
!sameId(fs.statSync(this.root), this.owner?.root ?? this.terminal?.root)
|
|
304
|
+
)
|
|
305
|
+
fail("journal belongs to another project identity");
|
|
306
|
+
}
|
|
307
|
+
remember(found) {
|
|
308
|
+
this.known.set(path.basename(found.file), found);
|
|
309
|
+
return found.value;
|
|
310
|
+
}
|
|
311
|
+
load() {
|
|
312
|
+
const at = entry(this.dir);
|
|
313
|
+
if (!at) return null;
|
|
314
|
+
if (!at.isDirectory() || at.isSymbolicLink() || (at.mode & 0o777) !== 0o700)
|
|
315
|
+
fail(
|
|
316
|
+
`unproved or non-private journal directory ${this.dir}; review it before removal`,
|
|
317
|
+
);
|
|
318
|
+
this.dirId = id(at);
|
|
319
|
+
const owner = canonical(path.join(this.dir, "owner.json"));
|
|
320
|
+
let terminal = canonical(path.join(this.dir, "terminal.json"));
|
|
321
|
+
if (!terminal)
|
|
322
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
323
|
+
if (!name.startsWith(".terminal.json.portulan-guidance-claim."))
|
|
324
|
+
continue;
|
|
325
|
+
const candidate = canonical(path.join(this.dir, name));
|
|
326
|
+
if (
|
|
327
|
+
candidate &&
|
|
328
|
+
name.endsWith("." + sha(candidate.bytes)) &&
|
|
329
|
+
terminalShape(candidate.value, this.root, this.workspace)
|
|
330
|
+
) {
|
|
331
|
+
terminal = candidate;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (terminal) {
|
|
336
|
+
if (!terminalShape(terminal.value, this.root, this.workspace))
|
|
337
|
+
fail(`foreign terminal record at ${terminal.file}`);
|
|
338
|
+
this.terminal = this.remember(terminal);
|
|
339
|
+
}
|
|
340
|
+
if (!owner) {
|
|
341
|
+
if (!this.terminal)
|
|
342
|
+
fail(
|
|
343
|
+
`unproved initializer at ${this.dir}; no source recovery is authorized, review its files`,
|
|
344
|
+
);
|
|
345
|
+
this.owner = null;
|
|
346
|
+
this.hash = this.terminal.owner;
|
|
347
|
+
this.checkRoot();
|
|
348
|
+
return this;
|
|
349
|
+
}
|
|
350
|
+
this.owner = this.remember(owner);
|
|
351
|
+
this.hash = sha(owner.bytes);
|
|
352
|
+
this.validateOwner();
|
|
353
|
+
if (
|
|
354
|
+
this.terminal &&
|
|
355
|
+
(this.terminal.owner !== this.hash ||
|
|
356
|
+
this.terminal.nonce !== this.owner.nonce)
|
|
357
|
+
)
|
|
358
|
+
fail("terminal record does not match its owner");
|
|
359
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
360
|
+
if (
|
|
361
|
+
!/^(?:stage|claim|undo-stage|undo-claim|retired)-\d+-[a-f0-9-]{36}\.json$/.test(
|
|
362
|
+
name,
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
continue;
|
|
366
|
+
const found = canonical(path.join(this.dir, name)),
|
|
367
|
+
record = found.value;
|
|
368
|
+
this.validateRecord(record);
|
|
369
|
+
if (name !== `${record.phase}-${record.index}-${record.attempt}.json`)
|
|
370
|
+
fail("source record filename disagrees with its action");
|
|
371
|
+
this.remember(found);
|
|
372
|
+
this.records.push(record);
|
|
373
|
+
}
|
|
374
|
+
if (!this.terminal) {
|
|
375
|
+
this.chain();
|
|
376
|
+
for (const record of this.records.filter(
|
|
377
|
+
(record) => record.phase === "retired",
|
|
378
|
+
))
|
|
379
|
+
this.assertRetirement(record);
|
|
380
|
+
if (this.records.some((record) => !this.leaseNonces.has(record.lease)))
|
|
381
|
+
fail("source record belongs to an unproved lease");
|
|
382
|
+
}
|
|
383
|
+
this.checkRoot();
|
|
384
|
+
return this;
|
|
385
|
+
}
|
|
386
|
+
validateOwner() {
|
|
387
|
+
const o = this.owner;
|
|
388
|
+
if (
|
|
389
|
+
!keys(
|
|
390
|
+
o,
|
|
391
|
+
"format,kind,nonce,pid,namespace,root,workspace,context,operation,taken,reads,inputs",
|
|
392
|
+
) ||
|
|
393
|
+
o.format !== 1 ||
|
|
394
|
+
o.kind !== "instructions" ||
|
|
395
|
+
!UUID.test(o.nonce) ||
|
|
396
|
+
!Number.isSafeInteger(o.pid) ||
|
|
397
|
+
o.pid < 1 ||
|
|
398
|
+
!HEX.test(o.namespace) ||
|
|
399
|
+
o.workspace !== binding(this.root, this.workspace) ||
|
|
400
|
+
o.context !== this.context ||
|
|
401
|
+
!["split", "join"].includes(o.operation) ||
|
|
402
|
+
!validId(o.root) ||
|
|
403
|
+
!sameId(fs.statSync(this.root), o.root) ||
|
|
404
|
+
!Array.isArray(o.inputs) ||
|
|
405
|
+
o.inputs.length === 0 ||
|
|
406
|
+
o.inputs.length > 10000 ||
|
|
407
|
+
!o.inputs.some((input) => ROOTS.includes(input.file)) ||
|
|
408
|
+
!Array.isArray(o.reads) ||
|
|
409
|
+
!Array.isArray(o.taken) ||
|
|
410
|
+
new Set(o.taken).size !== o.taken.length ||
|
|
411
|
+
o.taken.some(
|
|
412
|
+
(name) =>
|
|
413
|
+
typeof name !== "string" || !name || /[\\/\u0000-\u001f]/.test(name),
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
fail(`foreign or malformed owner at ${this.dir}`);
|
|
417
|
+
const seen = new Set();
|
|
418
|
+
for (const input of o.inputs) {
|
|
419
|
+
const root = ROOTS.includes(input.file),
|
|
420
|
+
unit =
|
|
421
|
+
typeof this.context === "string" &&
|
|
422
|
+
input.file.startsWith(this.context) &&
|
|
423
|
+
!input.file.slice(this.context.length).includes("/") &&
|
|
424
|
+
input.file.endsWith(".md");
|
|
425
|
+
inside(this.root, input.file);
|
|
426
|
+
if (
|
|
427
|
+
!keys(input, "file,before,next,mode,original") ||
|
|
428
|
+
input.file === SOURCE_JOURNAL ||
|
|
429
|
+
input.file.startsWith(SOURCE_JOURNAL + "/") ||
|
|
430
|
+
seen.has(input.file) ||
|
|
431
|
+
(!root && !unit) ||
|
|
432
|
+
!Number.isSafeInteger(input.mode) ||
|
|
433
|
+
input.mode < 0 ||
|
|
434
|
+
input.mode > 0o177777 ||
|
|
435
|
+
(input.before !== null && typeof input.before !== "string") ||
|
|
436
|
+
(input.next !== null && typeof input.next !== "string") ||
|
|
437
|
+
(root && (input.before === null || input.next === null)) ||
|
|
438
|
+
(!root &&
|
|
439
|
+
(o.operation === "split"
|
|
440
|
+
? input.before !== null || input.next === null
|
|
441
|
+
: input.before === null || input.next !== null)) ||
|
|
442
|
+
(input.before !== null
|
|
443
|
+
? !validId(input.original)
|
|
444
|
+
: input.original !== null)
|
|
445
|
+
)
|
|
446
|
+
fail("invalid source edit in saved operation");
|
|
447
|
+
seen.add(input.file);
|
|
448
|
+
}
|
|
449
|
+
const readNames = new Set();
|
|
450
|
+
for (const row of o.reads) {
|
|
451
|
+
if (
|
|
452
|
+
!keys(row, "file,text,identity") ||
|
|
453
|
+
!relative(row.file) ||
|
|
454
|
+
(!ROOTS.includes(row.file) && !seen.has(row.file)) ||
|
|
455
|
+
readNames.has(row.file) ||
|
|
456
|
+
(row.text !== null
|
|
457
|
+
? typeof row.text !== "string" || !validId(row.identity)
|
|
458
|
+
: row.identity !== null)
|
|
459
|
+
)
|
|
460
|
+
fail("invalid planning read set");
|
|
461
|
+
readNames.add(row.file);
|
|
462
|
+
}
|
|
463
|
+
if (!ROOTS.every((root) => readNames.has(root)))
|
|
464
|
+
fail("planning read set must retain both root observations");
|
|
465
|
+
}
|
|
466
|
+
validateRecord(r) {
|
|
467
|
+
const input = this.owner.inputs[r?.index];
|
|
468
|
+
if (
|
|
469
|
+
!keys(r, "format,kind,owner,nonce,lease,index,phase,attempt,pid,data") ||
|
|
470
|
+
r.format !== 1 ||
|
|
471
|
+
r.kind !== "source" ||
|
|
472
|
+
r.owner !== this.hash ||
|
|
473
|
+
r.nonce !== this.owner.nonce ||
|
|
474
|
+
!UUID.test(r.attempt) ||
|
|
475
|
+
!UUID.test(r.lease) ||
|
|
476
|
+
!Number.isSafeInteger(r.pid) ||
|
|
477
|
+
r.pid < 1 ||
|
|
478
|
+
!Number.isSafeInteger(r.index) ||
|
|
479
|
+
!["stage", "claim", "undo-stage", "undo-claim", "retired"].includes(
|
|
480
|
+
r.phase,
|
|
481
|
+
) ||
|
|
482
|
+
!input ||
|
|
483
|
+
r.data?.file !== input.file
|
|
484
|
+
)
|
|
485
|
+
fail("malformed source publication record");
|
|
486
|
+
const d = r.data,
|
|
487
|
+
base = path.posix.basename(input.file),
|
|
488
|
+
parent = path.posix.dirname(input.file);
|
|
489
|
+
inside(this.root, d.path);
|
|
490
|
+
if (
|
|
491
|
+
!keys(
|
|
492
|
+
d,
|
|
493
|
+
r.phase.endsWith("stage") || r.phase === "retired"
|
|
494
|
+
? "file,path,identity"
|
|
495
|
+
: "file,path,identity,placeholder,from",
|
|
496
|
+
) ||
|
|
497
|
+
path.posix.dirname(d.path) !== parent ||
|
|
498
|
+
!validId(d.identity)
|
|
499
|
+
)
|
|
500
|
+
fail("source publication path or identity is invalid");
|
|
501
|
+
const name = path.posix.basename(d.path),
|
|
502
|
+
undo = r.phase.startsWith("undo-"),
|
|
503
|
+
body = r.phase.endsWith("stage")
|
|
504
|
+
? undo
|
|
505
|
+
? input.before
|
|
506
|
+
: input.next
|
|
507
|
+
: undo
|
|
508
|
+
? input.next
|
|
509
|
+
: r.phase === "retired" && this.owner.operation === "split"
|
|
510
|
+
? input.next
|
|
511
|
+
: input.before;
|
|
512
|
+
if (body === null) fail("source publication records an impossible action");
|
|
513
|
+
if (r.phase.endsWith("stage")) {
|
|
514
|
+
if (
|
|
515
|
+
!(
|
|
516
|
+
name.startsWith(base + `.portulan-upgrade.${r.pid}.`) &&
|
|
517
|
+
/^\d+$/.test(
|
|
518
|
+
name.slice((base + `.portulan-upgrade.${r.pid}.`).length),
|
|
519
|
+
)
|
|
520
|
+
) &&
|
|
521
|
+
!(
|
|
522
|
+
name.startsWith(`.${base}.portulan-guidance.${r.pid}.`) &&
|
|
523
|
+
UUID.test(name.slice(`.${base}.portulan-guidance.${r.pid}.`.length))
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
fail("source staging name is not owned by this attempt");
|
|
527
|
+
} else if (
|
|
528
|
+
!name.startsWith(`.${base}.portulan-guidance-claim.${r.pid}.`) ||
|
|
529
|
+
!temporary(name, base, body) ||
|
|
530
|
+
(r.phase !== "retired" && !validId(d.placeholder))
|
|
531
|
+
)
|
|
532
|
+
fail("source claim name or placeholder is invalid");
|
|
533
|
+
if (r.phase === "retired" && ROOTS.includes(input.file))
|
|
534
|
+
fail("retirement confirmation is not a direct unit");
|
|
535
|
+
if (r.phase.endsWith("claim")) {
|
|
536
|
+
inside(this.root, d.from);
|
|
537
|
+
if (
|
|
538
|
+
path.posix.dirname(d.from) !== parent ||
|
|
539
|
+
!(
|
|
540
|
+
d.from === input.file ||
|
|
541
|
+
temporary(path.posix.basename(d.from), base, body) ||
|
|
542
|
+
path.posix.basename(d.from).startsWith(base + ".portulan-upgrade.")
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
fail("source claim does not name its actual acquired entry");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
write(name, value) {
|
|
549
|
+
this.checkRoot();
|
|
550
|
+
const file = path.join(this.dir, name),
|
|
551
|
+
bytes = Buffer.from(encode(value));
|
|
552
|
+
let published;
|
|
553
|
+
try {
|
|
554
|
+
atomicWrite(file, bytes, {
|
|
555
|
+
exclusive: true,
|
|
556
|
+
mode: 0o600,
|
|
557
|
+
onPublish: (identity) => {
|
|
558
|
+
published = identity;
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
} catch (error) {
|
|
562
|
+
const found = published ? canonical(file) : null;
|
|
563
|
+
if (
|
|
564
|
+
!found ||
|
|
565
|
+
!sameId(found.identity, published) ||
|
|
566
|
+
!sameContent(found.bytes, bytes)
|
|
567
|
+
)
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
const found = canonical(file);
|
|
571
|
+
if (
|
|
572
|
+
!found ||
|
|
573
|
+
!sameContent(found.bytes, bytes) ||
|
|
574
|
+
!sameId(found.identity, published)
|
|
575
|
+
)
|
|
576
|
+
fail(`record publication could not be authenticated: ${file}`);
|
|
577
|
+
this.remember(found);
|
|
578
|
+
return found.value;
|
|
579
|
+
}
|
|
580
|
+
static create(root, workspace, context, operation, edits, reads, taken) {
|
|
581
|
+
const j = new InstructionJournal(root, workspace, context),
|
|
582
|
+
ns = namespace();
|
|
583
|
+
if (!ns)
|
|
584
|
+
fail("the local boot/PID namespace is unavailable; no source was moved");
|
|
585
|
+
const rootId = id(fs.statSync(root));
|
|
586
|
+
j.owner = {
|
|
587
|
+
format: 1,
|
|
588
|
+
kind: "instructions",
|
|
589
|
+
nonce: randomUUID(),
|
|
590
|
+
pid: process.pid,
|
|
591
|
+
namespace: ns,
|
|
592
|
+
root: rootId,
|
|
593
|
+
workspace: binding(root, workspace),
|
|
594
|
+
context,
|
|
595
|
+
operation,
|
|
596
|
+
taken: [...taken],
|
|
597
|
+
reads: [...reads]
|
|
598
|
+
.filter(
|
|
599
|
+
([file]) =>
|
|
600
|
+
ROOTS.includes(file) || edits.some((edit) => edit.file === file),
|
|
601
|
+
)
|
|
602
|
+
.map(([file, s]) => ({ file, ...s })),
|
|
603
|
+
inputs: edits.map((e) => ({
|
|
604
|
+
file: e.file,
|
|
605
|
+
before: e.before,
|
|
606
|
+
next: e.next,
|
|
607
|
+
mode: e.mode ?? reads.get(e.file)?.identity?.mode ?? 0o100644,
|
|
608
|
+
original: e.before === null ? null : reads.get(e.file)?.identity,
|
|
609
|
+
})),
|
|
610
|
+
};
|
|
611
|
+
j.validateOwner();
|
|
612
|
+
if (Buffer.byteLength(encode(j.owner)) > 20 * 1024 * 1024)
|
|
613
|
+
fail("source operation exceeds its bounded journal");
|
|
614
|
+
fs.mkdirSync(j.dir, { mode: 0o700 });
|
|
615
|
+
fs.chmodSync(j.dir, 0o700);
|
|
616
|
+
j.dirId = id(fs.statSync(j.dir));
|
|
617
|
+
j.hash = sha(encode(j.owner));
|
|
618
|
+
j.write("owner.json", j.owner);
|
|
619
|
+
j.lease = { nonce: j.owner.nonce, pid: process.pid, state: "held" };
|
|
620
|
+
active.add(j.lease.nonce);
|
|
621
|
+
return j;
|
|
622
|
+
}
|
|
623
|
+
chain() {
|
|
624
|
+
let lease = { nonce: this.owner.nonce, pid: this.owner.pid, state: "held" },
|
|
625
|
+
count = 0;
|
|
626
|
+
this.leaseNonces = new Set([lease.nonce]);
|
|
627
|
+
for (;;) {
|
|
628
|
+
if (++count > 10000) fail("lease chain exceeds its bound");
|
|
629
|
+
const found = canonical(path.join(this.dir, `lease-${lease.nonce}.json`));
|
|
630
|
+
if (!found) return lease;
|
|
631
|
+
const next = found.value;
|
|
632
|
+
if (
|
|
633
|
+
!keys(next, "format,kind,owner,parent,nonce,pid,state") ||
|
|
634
|
+
next.format !== 1 ||
|
|
635
|
+
next.kind !== "lease" ||
|
|
636
|
+
next.owner !== this.hash ||
|
|
637
|
+
next.parent !== lease.nonce ||
|
|
638
|
+
!UUID.test(next.nonce) ||
|
|
639
|
+
this.leaseNonces.has(next.nonce) ||
|
|
640
|
+
!["held", "released"].includes(next.state) ||
|
|
641
|
+
(next.state === "held"
|
|
642
|
+
? !Number.isSafeInteger(next.pid) || next.pid < 1
|
|
643
|
+
: next.pid !== null)
|
|
644
|
+
)
|
|
645
|
+
fail("malformed lease chain");
|
|
646
|
+
this.remember(found);
|
|
647
|
+
this.leaseNonces.add(next.nonce);
|
|
648
|
+
lease = next;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
acquire() {
|
|
652
|
+
this.checkRoot();
|
|
653
|
+
if (this.terminal) return;
|
|
654
|
+
if (namespace() !== this.owner.namespace)
|
|
655
|
+
fail("journal belongs to another or unverified boot/PID namespace");
|
|
656
|
+
const leaf = this.chain();
|
|
657
|
+
if (!dead(leaf)) fail(`another instruction writer owns ${this.dir}`);
|
|
658
|
+
const next = {
|
|
659
|
+
format: 1,
|
|
660
|
+
kind: "lease",
|
|
661
|
+
owner: this.hash,
|
|
662
|
+
parent: leaf.nonce,
|
|
663
|
+
nonce: randomUUID(),
|
|
664
|
+
pid: process.pid,
|
|
665
|
+
state: "held",
|
|
666
|
+
};
|
|
667
|
+
try {
|
|
668
|
+
this.write(`lease-${leaf.nonce}.json`, next);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
fail(`recovery lease was not acquired: ${error.message}`);
|
|
671
|
+
}
|
|
672
|
+
this.lease = next;
|
|
673
|
+
active.add(next.nonce);
|
|
674
|
+
this.guard();
|
|
675
|
+
}
|
|
676
|
+
guard() {
|
|
677
|
+
this.checkRoot();
|
|
678
|
+
const owner = canonical(path.join(this.dir, "owner.json"));
|
|
679
|
+
if (!owner || sha(owner.bytes) !== this.hash) fail("journal owner changed");
|
|
680
|
+
if (entry(path.join(this.dir, "terminal.json")))
|
|
681
|
+
fail("source transaction is already terminal");
|
|
682
|
+
const leaf = this.chain();
|
|
683
|
+
if (
|
|
684
|
+
!this.lease ||
|
|
685
|
+
leaf.nonce !== this.lease.nonce ||
|
|
686
|
+
leaf.pid !== process.pid ||
|
|
687
|
+
!active.has(leaf.nonce)
|
|
688
|
+
)
|
|
689
|
+
fail("instruction recovery lease changed");
|
|
690
|
+
}
|
|
691
|
+
release() {
|
|
692
|
+
if (!this.lease) return;
|
|
693
|
+
try {
|
|
694
|
+
if (entry(this.dir) && !this.terminal) {
|
|
695
|
+
this.guard();
|
|
696
|
+
this.write(`lease-${this.lease.nonce}.json`, {
|
|
697
|
+
format: 1,
|
|
698
|
+
kind: "lease",
|
|
699
|
+
owner: this.hash,
|
|
700
|
+
parent: this.lease.nonce,
|
|
701
|
+
nonce: randomUUID(),
|
|
702
|
+
pid: null,
|
|
703
|
+
state: "released",
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
} finally {
|
|
707
|
+
active.delete(this.lease.nonce);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
observe(edit, phase, packet) {
|
|
711
|
+
this.guard();
|
|
712
|
+
const index = this.owner.inputs.findIndex(
|
|
713
|
+
(input) => input.file === edit.file,
|
|
714
|
+
);
|
|
715
|
+
if (index < 0) fail("an unplanned source edit reached the journal");
|
|
716
|
+
const data = {
|
|
717
|
+
file: edit.file,
|
|
718
|
+
path: posix(
|
|
719
|
+
path.relative(this.root, packet.staging ?? packet.claim ?? packet.file),
|
|
720
|
+
),
|
|
721
|
+
identity: { ...packet.identity },
|
|
722
|
+
};
|
|
723
|
+
if (phase.endsWith("claim")) {
|
|
724
|
+
data.placeholder = { ...packet.placeholder };
|
|
725
|
+
data.from = posix(path.relative(this.root, packet.file));
|
|
726
|
+
}
|
|
727
|
+
const record = {
|
|
728
|
+
format: 1,
|
|
729
|
+
kind: "source",
|
|
730
|
+
owner: this.hash,
|
|
731
|
+
nonce: this.owner.nonce,
|
|
732
|
+
lease: this.lease.nonce,
|
|
733
|
+
index,
|
|
734
|
+
phase,
|
|
735
|
+
attempt: randomUUID(),
|
|
736
|
+
pid: process.pid,
|
|
737
|
+
data,
|
|
738
|
+
};
|
|
739
|
+
this.validateRecord(record);
|
|
740
|
+
if (phase === "retired") this.assertRetirement(record);
|
|
741
|
+
const body = this.body(record),
|
|
742
|
+
found = readFile(inside(this.root, data.path));
|
|
743
|
+
const expected = phase.endsWith("claim") ? data.placeholder : data.identity;
|
|
744
|
+
if (
|
|
745
|
+
!found ||
|
|
746
|
+
!sameId(found.identity, expected) ||
|
|
747
|
+
!sameContent(found.bytes, body)
|
|
748
|
+
)
|
|
749
|
+
fail("prepared source changed before its durable record");
|
|
750
|
+
this.write(`${phase}-${index}-${record.attempt}.json`, record);
|
|
751
|
+
this.records.push(record);
|
|
752
|
+
this.guard();
|
|
753
|
+
}
|
|
754
|
+
body(record) {
|
|
755
|
+
const input = this.owner.inputs[record.index],
|
|
756
|
+
undo = record.phase.startsWith("undo-");
|
|
757
|
+
return record.phase.endsWith("stage")
|
|
758
|
+
? undo
|
|
759
|
+
? input.before
|
|
760
|
+
: input.next
|
|
761
|
+
: undo
|
|
762
|
+
? input.next
|
|
763
|
+
: record.phase === "retired" && this.owner.operation === "split"
|
|
764
|
+
? input.next
|
|
765
|
+
: input.before;
|
|
766
|
+
}
|
|
767
|
+
source(input) {
|
|
768
|
+
return readFile(inside(this.root, input.file));
|
|
769
|
+
}
|
|
770
|
+
ownStage(input, current, undo = false) {
|
|
771
|
+
return Boolean(
|
|
772
|
+
current &&
|
|
773
|
+
this.records.some(
|
|
774
|
+
(r) =>
|
|
775
|
+
r.data.file === input.file &&
|
|
776
|
+
r.phase === (undo ? "undo-stage" : "stage") &&
|
|
777
|
+
sameId(current.identity, r.data.identity) &&
|
|
778
|
+
sameContent(current.bytes, undo ? input.before : input.next),
|
|
779
|
+
),
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
before(input, current) {
|
|
783
|
+
return input.before === null
|
|
784
|
+
? current === null
|
|
785
|
+
: Boolean(
|
|
786
|
+
current &&
|
|
787
|
+
sameContent(current.bytes, input.before) &&
|
|
788
|
+
(sameId(current.identity, input.original) ||
|
|
789
|
+
this.ownStage(input, current, true)),
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
after(input, current) {
|
|
793
|
+
return input.next === null
|
|
794
|
+
? current === null
|
|
795
|
+
: this.ownStage(input, current, false);
|
|
796
|
+
}
|
|
797
|
+
recoverClaims() {
|
|
798
|
+
this.assertArtifacts();
|
|
799
|
+
for (const r of this.records.filter((r) => r.phase.endsWith("claim"))) {
|
|
800
|
+
this.guard();
|
|
801
|
+
const input = this.owner.inputs[r.index],
|
|
802
|
+
claim = readFile(inside(this.root, r.data.path));
|
|
803
|
+
if (!claim || sameId(claim.identity, r.data.placeholder)) continue;
|
|
804
|
+
const source = this.source(input);
|
|
805
|
+
if (source) {
|
|
806
|
+
if (!sameId(source.identity, claim.identity)) continue;
|
|
807
|
+
} else {
|
|
808
|
+
if (r.data.from !== input.file) continue;
|
|
809
|
+
try {
|
|
810
|
+
fs.linkSync(claim.file, inside(this.root, input.file));
|
|
811
|
+
} catch (error) {
|
|
812
|
+
const now = this.source(input);
|
|
813
|
+
if (!now || !sameId(now.identity, claim.identity))
|
|
814
|
+
fail(`source arrival conflicts with retained ${claim.file}`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const now = this.source(input);
|
|
818
|
+
if (now && sameId(now.identity, claim.identity)) {
|
|
819
|
+
this.guard();
|
|
820
|
+
if (!sameContent(claim.bytes, this.body(r)))
|
|
821
|
+
fail(
|
|
822
|
+
`changed held original was returned; preserved ${claim.file} and ${input.file}`,
|
|
823
|
+
);
|
|
824
|
+
guardedRemove(claim.file, {
|
|
825
|
+
before: claim.bytes,
|
|
826
|
+
identity: claim.identity,
|
|
827
|
+
allowLinks: true,
|
|
828
|
+
claimBase: path.basename(input.file),
|
|
829
|
+
onClaim: (packet) =>
|
|
830
|
+
this.observe({ file: input.file }, r.phase, packet),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
assertRetirement(record) {
|
|
836
|
+
// A preparation alias cannot prove that the public source was acquired.
|
|
837
|
+
const phase = this.owner.operation === "join" ? "claim" : "undo-claim";
|
|
838
|
+
if (
|
|
839
|
+
!this.records.some(
|
|
840
|
+
(intent) =>
|
|
841
|
+
intent.phase === phase &&
|
|
842
|
+
intent.index === record.index &&
|
|
843
|
+
intent.lease === record.lease &&
|
|
844
|
+
intent.pid === record.pid &&
|
|
845
|
+
intent.data.from === record.data.file &&
|
|
846
|
+
intent.data.path === record.data.path &&
|
|
847
|
+
sameId(intent.data.identity, record.data.identity),
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
fail(
|
|
851
|
+
`retirement confirmation for ${record.data.file} has no matching acquired-source intent; review ${this.dir}`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
restoreRetiredUnits(references) {
|
|
855
|
+
for (const record of this.records.filter(
|
|
856
|
+
(record) => record.phase === "retired",
|
|
857
|
+
)) {
|
|
858
|
+
const input = this.owner.inputs[record.index];
|
|
859
|
+
if (!references.has(input.file.normalize("NFC").toLowerCase())) continue;
|
|
860
|
+
this.guard();
|
|
861
|
+
if (this.source(input) !== null) continue;
|
|
862
|
+
const undo = this.owner.operation === "join",
|
|
863
|
+
body = undo ? input.before : input.next;
|
|
864
|
+
atomicWrite(inside(this.root, input.file), body, {
|
|
865
|
+
exclusive: true,
|
|
866
|
+
mode: input.mode,
|
|
867
|
+
onPrepare: (packet) =>
|
|
868
|
+
this.observe(
|
|
869
|
+
{ file: input.file },
|
|
870
|
+
undo ? "undo-stage" : "stage",
|
|
871
|
+
packet,
|
|
872
|
+
),
|
|
873
|
+
onCleanupClaim: (packet) =>
|
|
874
|
+
this.observe(
|
|
875
|
+
{ file: input.file },
|
|
876
|
+
undo ? "claim" : "undo-claim",
|
|
877
|
+
packet,
|
|
878
|
+
),
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
aliases(input, current) {
|
|
883
|
+
if (!current) return [];
|
|
884
|
+
return this.records
|
|
885
|
+
.filter((r) => r.data.file === input.file)
|
|
886
|
+
.flatMap((r) => {
|
|
887
|
+
const file = inside(this.root, r.data.path);
|
|
888
|
+
try {
|
|
889
|
+
const found = readFile(file);
|
|
890
|
+
return found &&
|
|
891
|
+
sameId(found.identity, current.identity) &&
|
|
892
|
+
sameContent(found.bytes, current.bytes)
|
|
893
|
+
? [found]
|
|
894
|
+
: [];
|
|
895
|
+
} catch {
|
|
896
|
+
return [];
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
remaining() {
|
|
901
|
+
this.assertUneditedRoots();
|
|
902
|
+
this.assertArtifacts();
|
|
903
|
+
const out = [];
|
|
904
|
+
for (const input of this.owner.inputs) {
|
|
905
|
+
const current = this.source(input);
|
|
906
|
+
if (this.after(input, current)) continue;
|
|
907
|
+
if (!this.before(input, current))
|
|
908
|
+
fail(
|
|
909
|
+
`source changed or arrived independently: ${input.file}; preserved with ${this.dir}`,
|
|
910
|
+
);
|
|
911
|
+
if (current) {
|
|
912
|
+
const aliases = this.aliases(input, current);
|
|
913
|
+
if (current.links !== aliases.length + 1)
|
|
914
|
+
fail(`${input.file} has an unproved alias`);
|
|
915
|
+
for (const alias of aliases) {
|
|
916
|
+
this.guard();
|
|
917
|
+
guardedRemove(alias.file, {
|
|
918
|
+
before: alias.bytes,
|
|
919
|
+
identity: alias.identity,
|
|
920
|
+
allowLinks: true,
|
|
921
|
+
claimBase: path.basename(input.file),
|
|
922
|
+
onClaim: (packet) =>
|
|
923
|
+
this.observe({ file: input.file }, "claim", packet),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
out.push({
|
|
928
|
+
root: "tree",
|
|
929
|
+
file: input.file,
|
|
930
|
+
before: input.before,
|
|
931
|
+
next: input.next,
|
|
932
|
+
mode: input.mode,
|
|
933
|
+
beforeIdentity: current?.identity ?? null,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return out;
|
|
937
|
+
}
|
|
938
|
+
originalsRestored() {
|
|
939
|
+
return (
|
|
940
|
+
this.changedUneditedRoot() === null &&
|
|
941
|
+
this.owner.inputs.every((input) => this.before(input, this.source(input)))
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
complete() {
|
|
945
|
+
return (
|
|
946
|
+
this.changedUneditedRoot() === null &&
|
|
947
|
+
this.owner.inputs.every((input) => this.after(input, this.source(input)))
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
cleanupSources() {
|
|
951
|
+
this.assertUneditedRoots();
|
|
952
|
+
this.assertArtifacts();
|
|
953
|
+
for (const r of [...this.records]) {
|
|
954
|
+
this.guard();
|
|
955
|
+
const found = readFile(inside(this.root, r.data.path));
|
|
956
|
+
if (!found) continue;
|
|
957
|
+
const identity =
|
|
958
|
+
r.phase.endsWith("claim") && sameId(found.identity, r.data.placeholder)
|
|
959
|
+
? r.data.placeholder
|
|
960
|
+
: r.data.identity;
|
|
961
|
+
if (
|
|
962
|
+
!sameId(found.identity, identity) ||
|
|
963
|
+
!sameContent(found.bytes, this.body(r))
|
|
964
|
+
)
|
|
965
|
+
fail(`changed source artifact retained at ${found.file}`);
|
|
966
|
+
const phase =
|
|
967
|
+
r.phase === "stage" ||
|
|
968
|
+
r.phase === "undo-claim" ||
|
|
969
|
+
(r.phase === "retired" && this.owner.operation === "split")
|
|
970
|
+
? "undo-claim"
|
|
971
|
+
: "claim";
|
|
972
|
+
guardedRemove(found.file, {
|
|
973
|
+
before: found.bytes,
|
|
974
|
+
identity: found.identity,
|
|
975
|
+
allowLinks: true,
|
|
976
|
+
claimBase: path.basename(r.data.file),
|
|
977
|
+
onClaim: (packet) => this.observe({ file: r.data.file }, phase, packet),
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
this.assertArtifacts();
|
|
981
|
+
}
|
|
982
|
+
assertArtifacts() {
|
|
983
|
+
const known = new Set(this.records.map((r) => r.data.path));
|
|
984
|
+
for (const input of this.owner.inputs) {
|
|
985
|
+
const file = inside(this.root, input.file),
|
|
986
|
+
parent = path.dirname(file),
|
|
987
|
+
base = path.basename(file);
|
|
988
|
+
if (!entry(parent)) continue;
|
|
989
|
+
for (const name of fs.readdirSync(parent)) {
|
|
990
|
+
if (
|
|
991
|
+
!name.startsWith(`.${base}.portulan-guidance.`) &&
|
|
992
|
+
!name.startsWith(`.${base}.portulan-guidance-claim.`) &&
|
|
993
|
+
!name.startsWith(`${base}.portulan-upgrade.`)
|
|
994
|
+
)
|
|
995
|
+
continue;
|
|
996
|
+
const full = path.join(parent, name),
|
|
997
|
+
rel = posix(path.relative(this.root, full));
|
|
998
|
+
if (!known.has(rel))
|
|
999
|
+
fail(
|
|
1000
|
+
`unproved alias or source preparation at ${full}; retained ${this.dir}; review the exact artifact before resuming`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
changedUneditedRoot() {
|
|
1006
|
+
for (const row of this.owner.reads.filter(
|
|
1007
|
+
(row) =>
|
|
1008
|
+
ROOTS.includes(row.file) &&
|
|
1009
|
+
!this.owner.inputs.some((input) => input.file === row.file),
|
|
1010
|
+
)) {
|
|
1011
|
+
try {
|
|
1012
|
+
const current = readFile(inside(this.root, row.file));
|
|
1013
|
+
if (
|
|
1014
|
+
row.text === null
|
|
1015
|
+
? current !== null
|
|
1016
|
+
: !current ||
|
|
1017
|
+
current.links !== 1 ||
|
|
1018
|
+
!sameId(current.identity, row.identity) ||
|
|
1019
|
+
!sameContent(current.bytes, row.text)
|
|
1020
|
+
)
|
|
1021
|
+
return row.file;
|
|
1022
|
+
} catch {
|
|
1023
|
+
return row.file;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
assertUneditedRoots() {
|
|
1029
|
+
const changed = this.changedUneditedRoot();
|
|
1030
|
+
if (changed !== null)
|
|
1031
|
+
fail(
|
|
1032
|
+
`the other observed instruction root changed: ${changed}; preserve its references and retained ${this.dir}`,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
assertRootImages(retiring = false) {
|
|
1036
|
+
this.assertUneditedRoots();
|
|
1037
|
+
for (const input of this.owner.inputs.filter((input) =>
|
|
1038
|
+
ROOTS.includes(input.file),
|
|
1039
|
+
)) {
|
|
1040
|
+
const current = this.source(input);
|
|
1041
|
+
if (
|
|
1042
|
+
!(
|
|
1043
|
+
this.after(input, current) ||
|
|
1044
|
+
(!retiring && this.before(input, current))
|
|
1045
|
+
)
|
|
1046
|
+
)
|
|
1047
|
+
fail(
|
|
1048
|
+
`published instruction root changed: ${input.file}; preserve its referenced units and retained ${this.dir}`,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
mark(state) {
|
|
1053
|
+
this.guard();
|
|
1054
|
+
if (state === "committed" ? !this.complete() : !this.originalsRestored())
|
|
1055
|
+
fail("whole source operation has not reached its terminal state");
|
|
1056
|
+
const terminal = {
|
|
1057
|
+
format: 1,
|
|
1058
|
+
kind: "terminal",
|
|
1059
|
+
state,
|
|
1060
|
+
owner: this.hash,
|
|
1061
|
+
nonce: this.owner.nonce,
|
|
1062
|
+
workspace: this.owner.workspace,
|
|
1063
|
+
root: this.owner.root,
|
|
1064
|
+
roots: this.owner.inputs
|
|
1065
|
+
.filter((i) => ROOTS.includes(i.file))
|
|
1066
|
+
.map((i) => i.file),
|
|
1067
|
+
paths: this.owner.inputs
|
|
1068
|
+
.filter((i) => (state === "committed" ? i.next : i.before) !== null)
|
|
1069
|
+
.map((i) => i.file),
|
|
1070
|
+
};
|
|
1071
|
+
this.write("terminal.json", terminal);
|
|
1072
|
+
this.terminal = terminal;
|
|
1073
|
+
}
|
|
1074
|
+
clean() {
|
|
1075
|
+
this.checkRoot();
|
|
1076
|
+
if (this.owner) this.assertArtifacts();
|
|
1077
|
+
if (!this.terminal) fail("source transaction has no terminal proof");
|
|
1078
|
+
for (const rel of this.terminal.paths)
|
|
1079
|
+
if (!readFile(inside(this.root, rel)))
|
|
1080
|
+
fail(`terminal source is missing: ${rel}; retained ${this.dir}`);
|
|
1081
|
+
if (this.owner)
|
|
1082
|
+
for (const r of this.records) {
|
|
1083
|
+
const found = readFile(inside(this.root, r.data.path));
|
|
1084
|
+
if (!found) continue;
|
|
1085
|
+
const identity = r.phase.endsWith("claim")
|
|
1086
|
+
? sameId(found.identity, r.data.placeholder)
|
|
1087
|
+
? r.data.placeholder
|
|
1088
|
+
: r.data.identity
|
|
1089
|
+
: r.data.identity;
|
|
1090
|
+
if (
|
|
1091
|
+
!sameId(found.identity, identity) ||
|
|
1092
|
+
!sameContent(found.bytes, this.body(r))
|
|
1093
|
+
)
|
|
1094
|
+
fail(`changed source artifact retained at ${found.file}`);
|
|
1095
|
+
guardedRemove(found.file, {
|
|
1096
|
+
before: found.bytes,
|
|
1097
|
+
identity: found.identity,
|
|
1098
|
+
allowLinks: true,
|
|
1099
|
+
claimBase: path.basename(r.data.file),
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
1103
|
+
if (
|
|
1104
|
+
metadataBase(name) === "owner.json" ||
|
|
1105
|
+
metadataBase(name) === "terminal.json"
|
|
1106
|
+
)
|
|
1107
|
+
continue;
|
|
1108
|
+
this.checkRoot();
|
|
1109
|
+
if (!metadataName(name))
|
|
1110
|
+
fail(`unknown metadata retained at ${path.join(this.dir, name)}`);
|
|
1111
|
+
let found = canonical(path.join(this.dir, name));
|
|
1112
|
+
if (!found) continue;
|
|
1113
|
+
const v = found.value;
|
|
1114
|
+
if (v.kind === "source") {
|
|
1115
|
+
if (!this.owner) fail(`unproved metadata retained at ${found.file}`);
|
|
1116
|
+
this.validateRecord(v);
|
|
1117
|
+
} else if (v.kind !== "lease" || v.owner !== this.hash)
|
|
1118
|
+
fail(`unknown metadata retained at ${found.file}`);
|
|
1119
|
+
removeMetadata(found);
|
|
1120
|
+
}
|
|
1121
|
+
this.checkRoot();
|
|
1122
|
+
const owner = canonical(path.join(this.dir, "owner.json"));
|
|
1123
|
+
if (owner) {
|
|
1124
|
+
if (sha(owner.bytes) !== this.hash) fail("owner changed during cleanup");
|
|
1125
|
+
removeMetadata(owner);
|
|
1126
|
+
} else
|
|
1127
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
1128
|
+
if (metadataBase(name) !== "owner.json") continue;
|
|
1129
|
+
const found = canonical(path.join(this.dir, name));
|
|
1130
|
+
if (!found) continue;
|
|
1131
|
+
if (sha(found.bytes) !== this.hash)
|
|
1132
|
+
fail("retained owner changed during cleanup");
|
|
1133
|
+
removeMetadata(found);
|
|
1134
|
+
}
|
|
1135
|
+
this.checkRoot();
|
|
1136
|
+
for (const name of fs.readdirSync(this.dir)) {
|
|
1137
|
+
const found = canonical(path.join(this.dir, name));
|
|
1138
|
+
if (!found) continue;
|
|
1139
|
+
if (
|
|
1140
|
+
!terminalShape(found.value, this.root, this.workspace) ||
|
|
1141
|
+
found.value.owner !== this.hash
|
|
1142
|
+
)
|
|
1143
|
+
fail(
|
|
1144
|
+
`unproved final metadata retained at ${path.join(this.dir, name)}`,
|
|
1145
|
+
);
|
|
1146
|
+
removeMetadata(found);
|
|
1147
|
+
}
|
|
1148
|
+
this.checkRoot();
|
|
1149
|
+
fs.rmdirSync(this.dir);
|
|
1150
|
+
if (this.lease) active.delete(this.lease.nonce);
|
|
1151
|
+
this.lease = null;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export function loadInstructionJournal(root, workspace, context) {
|
|
1156
|
+
return new InstructionJournal(root, workspace, context).load();
|
|
1157
|
+
}
|
|
1158
|
+
export function assertSourceJournalReady(root, workspace) {
|
|
1159
|
+
if (!sourceJournalPresent(root)) return;
|
|
1160
|
+
const directory = path.resolve(root, workspace),
|
|
1161
|
+
manifest = JSON.parse(
|
|
1162
|
+
fs.readFileSync(path.join(directory, "workspace.json"), "utf8"),
|
|
1163
|
+
),
|
|
1164
|
+
rel =
|
|
1165
|
+
typeof manifest.slots?.context === "string"
|
|
1166
|
+
? posix(
|
|
1167
|
+
path.relative(
|
|
1168
|
+
path.resolve(root),
|
|
1169
|
+
path.resolve(directory, manifest.slots.context),
|
|
1170
|
+
),
|
|
1171
|
+
)
|
|
1172
|
+
: null,
|
|
1173
|
+
context = rel === null ? null : rel ? rel + "/" : "";
|
|
1174
|
+
const j = loadInstructionJournal(root, directory, context);
|
|
1175
|
+
if (j.terminal) {
|
|
1176
|
+
for (const root of j.terminal.paths)
|
|
1177
|
+
if (!readFile(inside(j.root, root)))
|
|
1178
|
+
fail(`missing source ${root}; resume instructions --write`);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (!j.complete())
|
|
1182
|
+
fail(
|
|
1183
|
+
`source migration is interrupted at ${j.dir}; resume instructions --write`,
|
|
1184
|
+
);
|
|
1185
|
+
}
|