@nowcrew/daemon 0.6.88-beta.5 → 0.6.88-beta.7
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/dist/local-memory/candidate.js +43 -0
- package/dist/local-memory/pending-match.js +29 -0
- package/dist/local-memory/pending-query.js +100 -0
- package/dist/local-memory/pending-schema.js +29 -0
- package/dist/local-memory/pending-source.js +126 -0
- package/dist/local-memory/pending.js +34 -84
- package/dist/local-memory/prompt.js +13 -11
- package/dist/local-memory/read-cli.js +57 -8
- package/dist/local-memory/runtime.js +2 -2
- package/dist/local-memory/write.js +16 -48
- package/dist/main.js +0 -0
- package/dist/project-skills/capability.js +109 -0
- package/dist/project-skills/controller-convergence.js +57 -0
- package/dist/project-skills/projection-set-switch.js +419 -0
- package/dist/project-skills/projection-state-domain.js +170 -0
- package/dist/project-skills/projection-state-store.js +842 -0
- package/dist/project-skills/projection-state-transaction.js +322 -0
- package/dist/project-skills/projection-state.js +3 -0
- package/dist/project-skills/runtime-root-bootstrap.js +47 -0
- package/dist/project-skills/runtime-root-domain.js +268 -0
- package/dist/project-skills/runtime-root-gc.js +293 -0
- package/dist/project-skills/runtime-root-lease-artifact.js +46 -0
- package/dist/project-skills/runtime-root-leases.js +487 -0
- package/dist/project-skills/runtime-root-source-identity.js +60 -0
- package/dist/project-skills/runtime-root-startup.js +49 -0
- package/dist/project-skills/runtime-root-state-artifact-domain.js +143 -0
- package/dist/project-skills/runtime-root-state-index.js +356 -0
- package/dist/project-skills/runtime-root-store.js +722 -0
- package/dist/project-skills/serve-capability.js +28 -0
- package/dist/project-skills/serve-startup.js +37 -0
- package/package.json +9 -10
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { durableAtomicPrivateWrite, durableDirectorySync, durablePrivateUnlink, } from "../atomic-private-write.js";
|
|
4
|
+
import { commitPreparedProjectSkillManifestUpdate, parseProjectSkillManifestUpdateRecord, preparedProjectSkillManifestUpdateFromRecord, projectSkillManifestUpdateRecord, publishPreparedProjectSkillManifestUpdate, rollbackPreparedProjectSkillManifestUpdate, } from "./projection-state.js";
|
|
5
|
+
export const PROJECTION_ROOT_MARKER_NAME = ".nowcrew-project-skills-root.json";
|
|
6
|
+
const JOURNAL_NAME = "project-skills-switch.json";
|
|
7
|
+
const MAX_JOURNAL_BYTES = 768 * 1024;
|
|
8
|
+
const MAX_ROOT_MARKER_BYTES = 16 * 1024;
|
|
9
|
+
const NONCE = /^[A-Za-z0-9-]{1,128}$/u;
|
|
10
|
+
const PHASES = new Set([
|
|
11
|
+
"prepared",
|
|
12
|
+
"codex:backup_moved",
|
|
13
|
+
"codex:activated",
|
|
14
|
+
"claude:backup_moved",
|
|
15
|
+
"claude:activated",
|
|
16
|
+
"committed",
|
|
17
|
+
"rolling_back",
|
|
18
|
+
"rollback_failed",
|
|
19
|
+
]);
|
|
20
|
+
const errorCode = (error) => error.code;
|
|
21
|
+
export const projectionPathExists = async (path, statPath = lstat) => {
|
|
22
|
+
try {
|
|
23
|
+
await statPath(path);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (errorCode(error) === "ENOENT")
|
|
28
|
+
return false;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const exists = projectionPathExists;
|
|
33
|
+
const sameIdentity = (left, right) => left.dev === right.dev
|
|
34
|
+
&& left.ino === right.ino
|
|
35
|
+
&& left.birthtimeNs === right.birthtimeNs;
|
|
36
|
+
const directoryIdentity = async (path) => {
|
|
37
|
+
try {
|
|
38
|
+
const info = await lstat(path, { bigint: true });
|
|
39
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
40
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
dev: String(info.dev),
|
|
43
|
+
ino: String(info.ino),
|
|
44
|
+
birthtimeNs: String(info.birthtimeNs),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (errorCode(error) === "ENOENT")
|
|
49
|
+
return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
export const projectionSwitchJournalPath = (agentRoot) => join(agentRoot, ".crew", JOURNAL_NAME);
|
|
54
|
+
export const projectionTargets = (agentRoot, nonce) => Object.freeze([
|
|
55
|
+
Object.freeze({
|
|
56
|
+
runtime: "codex",
|
|
57
|
+
target: join(agentRoot, ".agents", "skills"),
|
|
58
|
+
staging: join(agentRoot, ".agents", `.skills-next-${nonce}`),
|
|
59
|
+
backup: join(agentRoot, ".agents", `.skills-previous-${nonce}`),
|
|
60
|
+
discard: join(agentRoot, ".agents", `.skills-discard-${nonce}`),
|
|
61
|
+
}),
|
|
62
|
+
Object.freeze({
|
|
63
|
+
runtime: "claude",
|
|
64
|
+
target: join(agentRoot, ".crew", "claude-skills", ".claude", "skills"),
|
|
65
|
+
staging: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-next-${nonce}`),
|
|
66
|
+
backup: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-previous-${nonce}`),
|
|
67
|
+
discard: join(agentRoot, ".crew", "claude-skills", ".claude", `.skills-discard-${nonce}`),
|
|
68
|
+
}),
|
|
69
|
+
]);
|
|
70
|
+
const rootMarker = (target, nonce) => Object.freeze({
|
|
71
|
+
managedBy: "nowcrew-project-skills-root",
|
|
72
|
+
version: 1,
|
|
73
|
+
nonce,
|
|
74
|
+
runtime: target.runtime,
|
|
75
|
+
target: target.target,
|
|
76
|
+
});
|
|
77
|
+
const readRootMarker = async (root) => {
|
|
78
|
+
const markerPath = join(root, PROJECTION_ROOT_MARKER_NAME);
|
|
79
|
+
try {
|
|
80
|
+
const info = await lstat(markerPath);
|
|
81
|
+
if (!info.isFile() || info.isSymbolicLink() || Number(info.size) > MAX_ROOT_MARKER_BYTES)
|
|
82
|
+
return null;
|
|
83
|
+
const candidate = JSON.parse(await readFile(markerPath, "utf8"));
|
|
84
|
+
return candidate.managedBy === "nowcrew-project-skills-root"
|
|
85
|
+
&& candidate.version === 1
|
|
86
|
+
&& NONCE.test(candidate.nonce ?? "")
|
|
87
|
+
&& (candidate.runtime === "codex" || candidate.runtime === "claude")
|
|
88
|
+
&& typeof candidate.target === "string"
|
|
89
|
+
? candidate
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (errorCode(error) === "ENOENT" || error instanceof SyntaxError)
|
|
94
|
+
return null;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const markerMatches = (marker, target, nonce) => marker !== null
|
|
99
|
+
&& marker.runtime === target.runtime
|
|
100
|
+
&& marker.target === target.target
|
|
101
|
+
&& (nonce === undefined || marker.nonce === nonce);
|
|
102
|
+
const assertOwnedRoot = async (root, target, nonce) => {
|
|
103
|
+
const info = await directoryIdentity(root);
|
|
104
|
+
if (info === null || !markerMatches(await readRootMarker(root), target, nonce)) {
|
|
105
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
export const markProjectionStaging = async (target, nonce) => {
|
|
109
|
+
await writeFile(join(target.staging, PROJECTION_ROOT_MARKER_NAME), JSON.stringify(rootMarker(target, nonce)), {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
mode: 0o600,
|
|
112
|
+
flag: "wx",
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
export const isManagedProjectionRoot = async (root, target) => markerMatches(await readRootMarker(root), target);
|
|
116
|
+
export const cleanupProjectionStaging = async (target, nonce, syncDirectory = durableDirectorySync) => {
|
|
117
|
+
if (!await exists(target.staging))
|
|
118
|
+
return;
|
|
119
|
+
await assertOwnedRoot(target.staging, target, nonce);
|
|
120
|
+
await rm(target.staging, { recursive: true, force: true });
|
|
121
|
+
await syncDirectory(dirname(target.target));
|
|
122
|
+
};
|
|
123
|
+
const exactJournalTargets = (agentRoot, nonce, candidates) => {
|
|
124
|
+
const expected = projectionTargets(agentRoot, nonce);
|
|
125
|
+
return candidates.length === expected.length && expected.every((target, index) => {
|
|
126
|
+
const candidate = candidates[index];
|
|
127
|
+
return candidate?.runtime === target.runtime
|
|
128
|
+
&& candidate.target === target.target
|
|
129
|
+
&& candidate.staging === target.staging
|
|
130
|
+
&& candidate.backup === target.backup
|
|
131
|
+
&& candidate.discard === target.discard
|
|
132
|
+
&& typeof candidate.hadPrevious === "boolean"
|
|
133
|
+
&& (candidate.previousIdentity === null
|
|
134
|
+
|| (typeof candidate.previousIdentity?.dev === "string"
|
|
135
|
+
&& typeof candidate.previousIdentity.ino === "string"
|
|
136
|
+
&& typeof candidate.previousIdentity.birthtimeNs === "string"))
|
|
137
|
+
&& typeof candidate.activatedIdentity?.dev === "string"
|
|
138
|
+
&& typeof candidate.activatedIdentity.ino === "string"
|
|
139
|
+
&& typeof candidate.activatedIdentity.birthtimeNs === "string";
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const parseJournal = (raw, agentRoot) => {
|
|
143
|
+
const candidate = JSON.parse(raw);
|
|
144
|
+
if (candidate.managedBy !== "nowcrew-project-skills-switch"
|
|
145
|
+
|| candidate.version !== 1
|
|
146
|
+
|| !NONCE.test(candidate.nonce ?? "")
|
|
147
|
+
|| !PHASES.has(candidate.phase)
|
|
148
|
+
|| !Array.isArray(candidate.targets)
|
|
149
|
+
|| !exactJournalTargets(agentRoot, candidate.nonce, candidate.targets)
|
|
150
|
+
|| Object.keys(candidate).some((key) => ![
|
|
151
|
+
"managedBy", "version", "nonce", "phase", "targets", "manifestUpdate",
|
|
152
|
+
].includes(key))) {
|
|
153
|
+
throw new Error("projection_switch_journal_invalid");
|
|
154
|
+
}
|
|
155
|
+
const manifestUpdate = candidate.manifestUpdate === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: parseProjectSkillManifestUpdateRecord(candidate.manifestUpdate);
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
managedBy: "nowcrew-project-skills-switch",
|
|
160
|
+
version: 1,
|
|
161
|
+
nonce: candidate.nonce,
|
|
162
|
+
phase: candidate.phase,
|
|
163
|
+
targets: candidate.targets,
|
|
164
|
+
...(manifestUpdate === undefined ? {} : { manifestUpdate }),
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const readJournal = async (agentRoot) => {
|
|
168
|
+
const path = projectionSwitchJournalPath(agentRoot);
|
|
169
|
+
let info;
|
|
170
|
+
try {
|
|
171
|
+
info = await lstat(path);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (errorCode(error) === "ENOENT")
|
|
175
|
+
return null;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
if (!info.isFile() || info.isSymbolicLink() || Number(info.size) > MAX_JOURNAL_BYTES) {
|
|
179
|
+
throw new Error("projection_switch_journal_invalid");
|
|
180
|
+
}
|
|
181
|
+
return parseJournal(await readFile(path, "utf8"), agentRoot);
|
|
182
|
+
};
|
|
183
|
+
const writeJournal = async (agentRoot, journal) => durableAtomicPrivateWrite(projectionSwitchJournalPath(agentRoot), `${JSON.stringify(journal)}\n`);
|
|
184
|
+
const withPhase = (journal, phase) => Object.freeze({ ...journal, phase });
|
|
185
|
+
const unlinkExactJournal = async (agentRoot, nonce) => {
|
|
186
|
+
const current = await readJournal(agentRoot);
|
|
187
|
+
if (current === null)
|
|
188
|
+
return;
|
|
189
|
+
if (current.nonce !== nonce)
|
|
190
|
+
throw new Error("projection_switch_journal_invalid");
|
|
191
|
+
await durablePrivateUnlink(projectionSwitchJournalPath(agentRoot));
|
|
192
|
+
};
|
|
193
|
+
const assertPrevious = async (path, expected) => {
|
|
194
|
+
const actual = await directoryIdentity(path);
|
|
195
|
+
if (actual === null || !sameIdentity(actual, expected))
|
|
196
|
+
throw new Error("projection_switch_backup_unmanaged");
|
|
197
|
+
};
|
|
198
|
+
const assertActivated = async (path, target, nonce) => {
|
|
199
|
+
await assertOwnedRoot(path, target, nonce);
|
|
200
|
+
await assertPrevious(path, target.activatedIdentity);
|
|
201
|
+
};
|
|
202
|
+
const cleanupDiscard = async (target, journal, syncDirectory) => {
|
|
203
|
+
const discardIdentity = await directoryIdentity(target.discard);
|
|
204
|
+
if (discardIdentity === null)
|
|
205
|
+
return;
|
|
206
|
+
if (target.previousIdentity !== null && sameIdentity(discardIdentity, target.previousIdentity)) {
|
|
207
|
+
await rm(target.discard, { recursive: true, force: true });
|
|
208
|
+
await syncDirectory(dirname(target.target));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await assertActivated(target.discard, target, journal.nonce);
|
|
212
|
+
await rm(target.discard, { recursive: true, force: true });
|
|
213
|
+
await syncDirectory(dirname(target.target));
|
|
214
|
+
};
|
|
215
|
+
const finalizeCommitted = async (agentRoot, journal, syncDirectory, manifestOperations, unlinkJournal = unlinkExactJournal) => {
|
|
216
|
+
for (const target of journal.targets)
|
|
217
|
+
await assertActivated(target.target, target, journal.nonce);
|
|
218
|
+
const manifestUpdate = journal.manifestUpdate === undefined
|
|
219
|
+
? null
|
|
220
|
+
: preparedProjectSkillManifestUpdateFromRecord(agentRoot, journal.manifestUpdate);
|
|
221
|
+
if (manifestUpdate !== null)
|
|
222
|
+
await manifestOperations.publish(manifestUpdate);
|
|
223
|
+
for (const target of journal.targets) {
|
|
224
|
+
if (target.hadPrevious && await exists(target.backup)) {
|
|
225
|
+
if (target.previousIdentity === null)
|
|
226
|
+
throw new Error("projection_switch_backup_unmanaged");
|
|
227
|
+
await assertPrevious(target.backup, target.previousIdentity);
|
|
228
|
+
if (await exists(target.discard))
|
|
229
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
230
|
+
await rename(target.backup, target.discard);
|
|
231
|
+
await syncDirectory(dirname(target.target));
|
|
232
|
+
await assertPrevious(target.discard, target.previousIdentity);
|
|
233
|
+
}
|
|
234
|
+
if (await exists(target.discard)) {
|
|
235
|
+
if (target.previousIdentity === null)
|
|
236
|
+
throw new Error("projection_switch_backup_unmanaged");
|
|
237
|
+
await assertPrevious(target.discard, target.previousIdentity);
|
|
238
|
+
await rm(target.discard, { recursive: true, force: true });
|
|
239
|
+
await syncDirectory(dirname(target.target));
|
|
240
|
+
}
|
|
241
|
+
await cleanupProjectionStaging(target, journal.nonce, syncDirectory);
|
|
242
|
+
}
|
|
243
|
+
for (const target of journal.targets)
|
|
244
|
+
await syncDirectory(dirname(target.target));
|
|
245
|
+
if (manifestUpdate !== null)
|
|
246
|
+
await manifestOperations.commit(manifestUpdate);
|
|
247
|
+
await unlinkJournal(agentRoot, journal.nonce);
|
|
248
|
+
};
|
|
249
|
+
const rollback = async (agentRoot, journal, syncDirectory, manifestOperations) => {
|
|
250
|
+
let currentJournal = withPhase(journal, "rolling_back");
|
|
251
|
+
await writeJournal(agentRoot, currentJournal);
|
|
252
|
+
try {
|
|
253
|
+
for (const target of [...currentJournal.targets].reverse()) {
|
|
254
|
+
const currentIdentity = await directoryIdentity(target.target);
|
|
255
|
+
const currentIsPrevious = currentIdentity !== null
|
|
256
|
+
&& target.previousIdentity !== null
|
|
257
|
+
&& sameIdentity(currentIdentity, target.previousIdentity);
|
|
258
|
+
if (!currentIsPrevious && currentIdentity !== null) {
|
|
259
|
+
await assertActivated(target.target, target, currentJournal.nonce);
|
|
260
|
+
if (await exists(target.discard))
|
|
261
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
262
|
+
await rename(target.target, target.discard);
|
|
263
|
+
await syncDirectory(dirname(target.target));
|
|
264
|
+
await assertActivated(target.discard, target, currentJournal.nonce);
|
|
265
|
+
}
|
|
266
|
+
const afterActivationRemoved = await directoryIdentity(target.target);
|
|
267
|
+
if (target.hadPrevious) {
|
|
268
|
+
if (target.previousIdentity === null)
|
|
269
|
+
throw new Error("projection_switch_backup_unmanaged");
|
|
270
|
+
if (afterActivationRemoved === null) {
|
|
271
|
+
await assertPrevious(target.backup, target.previousIdentity);
|
|
272
|
+
await rename(target.backup, target.target);
|
|
273
|
+
await syncDirectory(dirname(target.target));
|
|
274
|
+
await assertPrevious(target.target, target.previousIdentity);
|
|
275
|
+
}
|
|
276
|
+
else if (!sameIdentity(afterActivationRemoved, target.previousIdentity)) {
|
|
277
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (afterActivationRemoved !== null) {
|
|
281
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
282
|
+
}
|
|
283
|
+
await cleanupDiscard(target, currentJournal, syncDirectory);
|
|
284
|
+
await cleanupProjectionStaging(target, currentJournal.nonce, syncDirectory);
|
|
285
|
+
}
|
|
286
|
+
if (currentJournal.manifestUpdate !== undefined) {
|
|
287
|
+
await manifestOperations.rollback(preparedProjectSkillManifestUpdateFromRecord(agentRoot, currentJournal.manifestUpdate));
|
|
288
|
+
}
|
|
289
|
+
for (const target of currentJournal.targets)
|
|
290
|
+
await syncDirectory(dirname(target.target));
|
|
291
|
+
await unlinkExactJournal(agentRoot, currentJournal.nonce);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
currentJournal = withPhase(currentJournal, "rollback_failed");
|
|
295
|
+
await writeJournal(agentRoot, currentJournal);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
export const recoverProjectionSwitch = async (agentRoot, syncDirectory = durableDirectorySync, manifestOperations = {}) => {
|
|
300
|
+
const operations = {
|
|
301
|
+
publish: manifestOperations.publish ?? publishPreparedProjectSkillManifestUpdate,
|
|
302
|
+
commit: manifestOperations.commit ?? commitPreparedProjectSkillManifestUpdate,
|
|
303
|
+
rollback: manifestOperations.rollback ?? rollbackPreparedProjectSkillManifestUpdate,
|
|
304
|
+
};
|
|
305
|
+
const journal = await readJournal(agentRoot);
|
|
306
|
+
if (journal === null)
|
|
307
|
+
return;
|
|
308
|
+
if (journal.phase === "committed") {
|
|
309
|
+
await finalizeCommitted(agentRoot, journal, syncDirectory, operations);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
await rollback(agentRoot, journal, syncDirectory, operations);
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Switches the Codex/Claude roots as one recoverable projection set while the Agent coordinator lock is held.
|
|
316
|
+
* This is not a mythical two-directory filesystem transaction: each root is individually published by rename,
|
|
317
|
+
* so a mixed pair may exist inside the locked switch interval. Every bounded, path-exact journal phase fsyncs its
|
|
318
|
+
* temporary contents before rename and its parent directory afterward. Each target-parent mutation is also fsynced:
|
|
319
|
+
* activations before the durable commit phase, and restoration/cleanup plus a final parent barrier before journal
|
|
320
|
+
* deletion. A v2 state publication is bound into the same journal: recovery rolls every pre-commit phase back to
|
|
321
|
+
* the recorded prior roots and manifest, while a durable committed phase can only finish the next pair's cleanup.
|
|
322
|
+
* No launch using this coordinator observes a successfully returned mixed set. Activated roots require both their
|
|
323
|
+
* nonce marker and their recorded filesystem identity before rollback cleanup.
|
|
324
|
+
*/
|
|
325
|
+
export const switchProjectionSet = async (agentRoot, targets, nonce, fault, syncDirectory = durableDirectorySync, publication) => {
|
|
326
|
+
const manifestOperations = {
|
|
327
|
+
publish: publication?.operations?.publish ?? publishPreparedProjectSkillManifestUpdate,
|
|
328
|
+
commit: publication?.operations?.commit ?? commitPreparedProjectSkillManifestUpdate,
|
|
329
|
+
rollback: publication?.operations?.rollback ?? rollbackPreparedProjectSkillManifestUpdate,
|
|
330
|
+
};
|
|
331
|
+
const journalTargets = [];
|
|
332
|
+
try {
|
|
333
|
+
if (!NONCE.test(nonce) || targets.length !== 2)
|
|
334
|
+
throw new Error("projection_switch_journal_invalid");
|
|
335
|
+
const expected = projectionTargets(agentRoot, nonce);
|
|
336
|
+
if (targets.some((target, index) => JSON.stringify(target) !== JSON.stringify(expected[index]))) {
|
|
337
|
+
throw new Error("projection_switch_journal_invalid");
|
|
338
|
+
}
|
|
339
|
+
for (const target of targets) {
|
|
340
|
+
await assertOwnedRoot(target.staging, target, nonce);
|
|
341
|
+
if (await exists(target.backup) || await exists(target.discard)) {
|
|
342
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
343
|
+
}
|
|
344
|
+
const previousIdentity = await directoryIdentity(target.target);
|
|
345
|
+
const activatedIdentity = await directoryIdentity(target.staging);
|
|
346
|
+
if (activatedIdentity === null)
|
|
347
|
+
throw new Error("projection_switch_path_unmanaged");
|
|
348
|
+
journalTargets.push(Object.freeze({
|
|
349
|
+
...target,
|
|
350
|
+
hadPrevious: previousIdentity !== null,
|
|
351
|
+
previousIdentity,
|
|
352
|
+
activatedIdentity,
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (publication !== undefined)
|
|
358
|
+
await manifestOperations.rollback(publication.update);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
let journal = Object.freeze({
|
|
362
|
+
managedBy: "nowcrew-project-skills-switch",
|
|
363
|
+
version: 1,
|
|
364
|
+
nonce,
|
|
365
|
+
phase: "prepared",
|
|
366
|
+
targets: Object.freeze(journalTargets),
|
|
367
|
+
...(publication === undefined
|
|
368
|
+
? {}
|
|
369
|
+
: { manifestUpdate: projectSkillManifestUpdateRecord(publication.update) }),
|
|
370
|
+
});
|
|
371
|
+
try {
|
|
372
|
+
await writeJournal(agentRoot, journal);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (publication !== undefined)
|
|
376
|
+
await manifestOperations.rollback(publication.update);
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
let committedDurable = false;
|
|
380
|
+
try {
|
|
381
|
+
await fault?.(journal.phase);
|
|
382
|
+
for (const target of journal.targets) {
|
|
383
|
+
if (target.hadPrevious) {
|
|
384
|
+
await rename(target.target, target.backup);
|
|
385
|
+
await syncDirectory(dirname(target.target));
|
|
386
|
+
await assertPrevious(target.backup, target.previousIdentity);
|
|
387
|
+
}
|
|
388
|
+
journal = withPhase(journal, `${target.runtime}:backup_moved`);
|
|
389
|
+
await writeJournal(agentRoot, journal);
|
|
390
|
+
await fault?.(journal.phase);
|
|
391
|
+
await rename(target.staging, target.target);
|
|
392
|
+
await syncDirectory(dirname(target.target));
|
|
393
|
+
await assertActivated(target.target, target, nonce);
|
|
394
|
+
journal = withPhase(journal, `${target.runtime}:activated`);
|
|
395
|
+
await writeJournal(agentRoot, journal);
|
|
396
|
+
await fault?.(journal.phase);
|
|
397
|
+
}
|
|
398
|
+
if (publication !== undefined) {
|
|
399
|
+
await manifestOperations.publish(publication.update);
|
|
400
|
+
await publication.verify();
|
|
401
|
+
await publication.afterPublishedBeforeCommit?.();
|
|
402
|
+
}
|
|
403
|
+
const committedJournal = withPhase(journal, "committed");
|
|
404
|
+
await writeJournal(agentRoot, committedJournal);
|
|
405
|
+
journal = committedJournal;
|
|
406
|
+
committedDurable = true;
|
|
407
|
+
await fault?.(journal.phase);
|
|
408
|
+
await finalizeCommitted(agentRoot, journal, syncDirectory, manifestOperations, publication?.unlinkJournal);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (committedDurable)
|
|
412
|
+
throw error;
|
|
413
|
+
const persisted = await readJournal(agentRoot);
|
|
414
|
+
if (persisted?.phase === "committed")
|
|
415
|
+
throw error;
|
|
416
|
+
await rollback(agentRoot, persisted ?? journal, syncDirectory, manifestOperations);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { compareProjectSkillRefs, isProjectId, isProjectSkillName, MAX_AGENT_PROJECT_SKILL_BINDINGS, MAX_PROJECT_ID_LENGTH, MAX_PROJECT_SKILL_NAME_LENGTH, } from "./types.js";
|
|
5
|
+
const MANAGED_BY = "nowcrew-project-skills-v2";
|
|
6
|
+
const MAX_SOURCE_PATH_BYTES = 16 * 1024;
|
|
7
|
+
const MAX_GENERATION = 2_147_483_647;
|
|
8
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
9
|
+
const CANONICAL_ROOT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
10
|
+
export class ProjectSkillProjectionStateError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "ProjectSkillProjectionStateError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const corrupt = () => {
|
|
19
|
+
throw new ProjectSkillProjectionStateError("skill_projection_state_corrupt");
|
|
20
|
+
};
|
|
21
|
+
const unmanaged = () => {
|
|
22
|
+
throw new ProjectSkillProjectionStateError("skill_projection_state_unmanaged");
|
|
23
|
+
};
|
|
24
|
+
const immutableBinding = (binding) => Object.freeze({
|
|
25
|
+
projectId: binding.projectId,
|
|
26
|
+
skillName: binding.skillName,
|
|
27
|
+
});
|
|
28
|
+
const canonicalBindings = (bindings) => {
|
|
29
|
+
if (bindings.length > MAX_AGENT_PROJECT_SKILL_BINDINGS)
|
|
30
|
+
return corrupt();
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const canonical = bindings.map((binding) => {
|
|
33
|
+
if (!isProjectId(binding.projectId) || !isProjectSkillName(binding.skillName))
|
|
34
|
+
return corrupt();
|
|
35
|
+
const key = `${binding.projectId}\0${binding.skillName}`;
|
|
36
|
+
if (seen.has(key))
|
|
37
|
+
return corrupt();
|
|
38
|
+
seen.add(key);
|
|
39
|
+
return immutableBinding(binding);
|
|
40
|
+
}).sort(compareProjectSkillRefs);
|
|
41
|
+
return Object.freeze(canonical);
|
|
42
|
+
};
|
|
43
|
+
const digest = (value) => `sha256:${createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex")}`;
|
|
44
|
+
export function computeProjectSkillBindingDigest(bindings) {
|
|
45
|
+
return digest(canonicalBindings(bindings));
|
|
46
|
+
}
|
|
47
|
+
const normalizeWindowsPath = (sourcePath) => {
|
|
48
|
+
let value = sourcePath.replaceAll("/", "\\");
|
|
49
|
+
if (/^\\\\\?\\UNC\\/iu.test(value))
|
|
50
|
+
value = `\\\\${value.slice(8)}`;
|
|
51
|
+
else if (/^\\\\\?\\[A-Za-z]:\\/u.test(value))
|
|
52
|
+
value = value.slice(4);
|
|
53
|
+
if (!win32.isAbsolute(value))
|
|
54
|
+
return corrupt();
|
|
55
|
+
return win32.normalize(value).toLowerCase();
|
|
56
|
+
};
|
|
57
|
+
const normalizeSourcePath = (sourcePath, platform) => {
|
|
58
|
+
if (sourcePath.includes("\0") || Buffer.byteLength(sourcePath, "utf8") > MAX_SOURCE_PATH_BYTES) {
|
|
59
|
+
return corrupt();
|
|
60
|
+
}
|
|
61
|
+
if (platform === "win32")
|
|
62
|
+
return normalizeWindowsPath(sourcePath);
|
|
63
|
+
if (!posix.isAbsolute(sourcePath))
|
|
64
|
+
return corrupt();
|
|
65
|
+
return posix.normalize(sourcePath);
|
|
66
|
+
};
|
|
67
|
+
export function normalizeProjectSkillResolutionRecords(records, platform = process.platform) {
|
|
68
|
+
if (records.length > MAX_AGENT_PROJECT_SKILL_BINDINGS)
|
|
69
|
+
return corrupt();
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const normalized = records.map((record) => {
|
|
72
|
+
if (!isProjectId(record.projectId) || !isProjectSkillName(record.skillName))
|
|
73
|
+
return corrupt();
|
|
74
|
+
const key = `${record.projectId}\0${record.skillName}`;
|
|
75
|
+
if (seen.has(key))
|
|
76
|
+
return corrupt();
|
|
77
|
+
seen.add(key);
|
|
78
|
+
if (record.mode === "missing") {
|
|
79
|
+
if (record.sourcePath !== null)
|
|
80
|
+
return corrupt();
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
projectId: record.projectId,
|
|
83
|
+
skillName: record.skillName,
|
|
84
|
+
sourcePath: null,
|
|
85
|
+
mode: "missing",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (record.mode !== "resolved" || record.sourcePath === null)
|
|
89
|
+
return corrupt();
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
projectId: record.projectId,
|
|
92
|
+
skillName: record.skillName,
|
|
93
|
+
sourcePath: normalizeSourcePath(record.sourcePath, platform),
|
|
94
|
+
mode: "resolved",
|
|
95
|
+
});
|
|
96
|
+
}).sort(compareProjectSkillRefs);
|
|
97
|
+
return Object.freeze(normalized);
|
|
98
|
+
}
|
|
99
|
+
export function computeProjectSkillResolutionDigest(records, platform = process.platform) {
|
|
100
|
+
return digest(normalizeProjectSkillResolutionRecords(records, platform));
|
|
101
|
+
}
|
|
102
|
+
const sameBinding = (left, right) => left.projectId === right.projectId && left.skillName === right.skillName;
|
|
103
|
+
export function createAppliedProjectSkillManifest(input) {
|
|
104
|
+
if (!Number.isInteger(input.generation) || input.generation < 0 || input.generation > MAX_GENERATION) {
|
|
105
|
+
return corrupt();
|
|
106
|
+
}
|
|
107
|
+
const bindings = canonicalBindings(input.bindings);
|
|
108
|
+
const resolutions = normalizeProjectSkillResolutionRecords(input.resolutions, input.platform);
|
|
109
|
+
if (bindings.length !== resolutions.length
|
|
110
|
+
|| bindings.some((binding, index) => !sameBinding(binding, resolutions[index]))
|
|
111
|
+
|| !CANONICAL_ROOT_ID.test(input.rootId)) {
|
|
112
|
+
return corrupt();
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
managedBy: MANAGED_BY,
|
|
116
|
+
generation: input.generation,
|
|
117
|
+
bindings,
|
|
118
|
+
bindingDigest: digest(bindings),
|
|
119
|
+
resolutionDigest: digest(resolutions),
|
|
120
|
+
rootId: input.rootId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const ProjectSkillRefSchema = z.object({
|
|
124
|
+
projectId: z.string().min(1).max(MAX_PROJECT_ID_LENGTH),
|
|
125
|
+
skillName: z.string().min(1).max(MAX_PROJECT_SKILL_NAME_LENGTH),
|
|
126
|
+
}).strict();
|
|
127
|
+
const LegacyAppliedManifestSchema = z.object({
|
|
128
|
+
managedBy: z.literal(MANAGED_BY),
|
|
129
|
+
generation: z.number().int().min(0).max(MAX_GENERATION),
|
|
130
|
+
bindings: z.array(ProjectSkillRefSchema).max(MAX_AGENT_PROJECT_SKILL_BINDINGS),
|
|
131
|
+
bindingDigest: z.string().regex(DIGEST),
|
|
132
|
+
resolutionDigest: z.string().regex(DIGEST),
|
|
133
|
+
}).strict();
|
|
134
|
+
const AppliedManifestSchema = z.union([
|
|
135
|
+
LegacyAppliedManifestSchema.extend({
|
|
136
|
+
rootId: z.string().regex(CANONICAL_ROOT_ID),
|
|
137
|
+
}).strict(),
|
|
138
|
+
LegacyAppliedManifestSchema,
|
|
139
|
+
]);
|
|
140
|
+
/** Strict local-store boundary parser; not re-exported from the public projection-state facade. */
|
|
141
|
+
export const parseAppliedProjectSkillManifest = (candidate) => {
|
|
142
|
+
if (candidate && typeof candidate === "object" && "managedBy" in candidate
|
|
143
|
+
&& candidate.managedBy !== MANAGED_BY) {
|
|
144
|
+
return unmanaged();
|
|
145
|
+
}
|
|
146
|
+
const parsed = AppliedManifestSchema.safeParse(candidate);
|
|
147
|
+
if (!parsed.success)
|
|
148
|
+
return corrupt();
|
|
149
|
+
const bindings = canonicalBindings(parsed.data.bindings);
|
|
150
|
+
if (bindings.some((binding, index) => !sameBinding(binding, parsed.data.bindings[index]))) {
|
|
151
|
+
return corrupt();
|
|
152
|
+
}
|
|
153
|
+
if (parsed.data.bindingDigest !== digest(bindings))
|
|
154
|
+
return corrupt();
|
|
155
|
+
return Object.freeze({
|
|
156
|
+
managedBy: MANAGED_BY,
|
|
157
|
+
generation: parsed.data.generation,
|
|
158
|
+
bindings,
|
|
159
|
+
bindingDigest: parsed.data.bindingDigest,
|
|
160
|
+
resolutionDigest: parsed.data.resolutionDigest,
|
|
161
|
+
rootId: "rootId" in parsed.data ? parsed.data.rootId : null,
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
/** Publication boundary parser; it must run before creating or settling any filesystem state. */
|
|
165
|
+
export const parsePublishableAppliedProjectSkillManifest = (candidate) => {
|
|
166
|
+
const parsed = parseAppliedProjectSkillManifest(candidate);
|
|
167
|
+
if (parsed.rootId === null)
|
|
168
|
+
return corrupt();
|
|
169
|
+
return Object.freeze({ ...parsed, rootId: parsed.rootId });
|
|
170
|
+
};
|