owlrunkit 0.17.2 → 0.18.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.
@@ -0,0 +1,1176 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ linkSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ rmdirSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import path from "node:path";
20
+
21
+ import {
22
+ assertAllowedKeys,
23
+ isRecord,
24
+ relativeToWorkspace,
25
+ safeIdentifier,
26
+ } from "./provenance-common.mjs";
27
+ import {
28
+ emptyTeamProjectTruthHashV1,
29
+ normalizeTeamProjectDefinitionV1,
30
+ readTeamProjectStatusV1,
31
+ } from "./team-project.mjs";
32
+
33
+ const RECEIPT_SCHEMA = "OwlCodaRunKitTeamProjectSuccessorReceiptV1";
34
+ const LOCK_SCHEMA = "OwlCodaRunKitTeamProjectSuccessorLockV1";
35
+ const RUNKIT_ROOT = ".owlcoda/runkit";
36
+ const ACTIVE_PROJECT = `${RUNKIT_ROOT}/project`;
37
+ const ARCHIVE_ROOT = `${RUNKIT_ROOT}/project-archives`;
38
+ const TRANSITION_ROOT = `${RUNKIT_ROOT}/project-successor-transitions`;
39
+ const STAGING_ROOT = `${RUNKIT_ROOT}/project-successor-staging`;
40
+ const LIFECYCLE_LOCK = `${RUNKIT_ROOT}/project-successor.lock`;
41
+ const MANIFEST_ALGORITHM = "sha256-canonical-json-v1";
42
+ const ARCHIVE_SEAL_POLICY = Object.freeze({
43
+ policy: "posix-owner-write-denied-v1",
44
+ regularFileMode: "0444",
45
+ directoryMode: "0555",
46
+ });
47
+ const ARCHIVE_FILE_MODE = 0o444;
48
+ const ARCHIVE_DIRECTORY_MODE = 0o555;
49
+ const PHASES = new Set(["prepared", "archived", "active_installed", "completed"]);
50
+ const REPOSITORY_ACTIONS = Object.freeze({
51
+ staged: false,
52
+ committed: false,
53
+ pushed: false,
54
+ tagged: false,
55
+ published: false,
56
+ deployed: false,
57
+ });
58
+
59
+ function canonical(value) {
60
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
61
+ if (isRecord(value)) {
62
+ return `{${Object.keys(value).sort().map(key => (
63
+ `${JSON.stringify(key)}:${canonical(value[key])}`
64
+ )).join(",")}}`;
65
+ }
66
+ const encoded = JSON.stringify(value);
67
+ if (encoded === undefined) throw new Error("Team project successor artifact is not canonical JSON.");
68
+ return encoded;
69
+ }
70
+
71
+ function sha256(value) {
72
+ return createHash("sha256").update(value).digest("hex");
73
+ }
74
+
75
+ function compareBytes(left, right) {
76
+ return Buffer.compare(Buffer.from(left), Buffer.from(right));
77
+ }
78
+
79
+ function pathEntryExists(filePath) {
80
+ try {
81
+ lstatSync(filePath);
82
+ return true;
83
+ } catch (error) {
84
+ if (error?.code === "ENOENT") return false;
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ function nonEmptyString(value, label) {
90
+ if (typeof value !== "string" || value.trim().length === 0) {
91
+ throw new Error(`${label} must be a non-empty string.`);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ function validateTimestamp(value) {
97
+ if (typeof value !== "string"
98
+ || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
99
+ || Number.isNaN(Date.parse(value))) {
100
+ throw new Error("Team project successor --at must be an ISO-8601 UTC timestamp.");
101
+ }
102
+ return value;
103
+ }
104
+
105
+ function assertHash(value, label) {
106
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) {
107
+ throw new Error(`${label} must be a SHA-256 hash.`);
108
+ }
109
+ return value;
110
+ }
111
+
112
+ function assertWorkspaceRoot(workspaceRoot) {
113
+ const requested = path.resolve(nonEmptyString(workspaceRoot, "workspaceRoot"));
114
+ const stat = lstatSync(requested);
115
+ if (stat.isSymbolicLink()
116
+ || !stat.isDirectory()
117
+ || realpathSync(requested) !== requested) {
118
+ throw new Error("Team project successor workspace must be a real directory without symlinks.");
119
+ }
120
+ return requested;
121
+ }
122
+
123
+ function assertRealDirectory(directoryPath, label) {
124
+ const stat = lstatSync(directoryPath);
125
+ if (stat.isSymbolicLink()
126
+ || !stat.isDirectory()
127
+ || realpathSync(directoryPath) !== path.resolve(directoryPath)) {
128
+ throw new Error(`${label} must be a real directory without symlinks.`);
129
+ }
130
+ return directoryPath;
131
+ }
132
+
133
+ function assertWithin(root, candidate, label) {
134
+ const relative = path.relative(root, candidate);
135
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
136
+ throw new Error(`${label} escapes the RunKit workspace.`);
137
+ }
138
+ }
139
+
140
+ function ensureRealDirectory(root, directoryPath, label) {
141
+ assertWithin(root, directoryPath, label);
142
+ const relative = path.relative(root, directoryPath);
143
+ let current = root;
144
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
145
+ current = path.join(current, segment);
146
+ if (!pathEntryExists(current)) {
147
+ try {
148
+ mkdirSync(current);
149
+ } catch (error) {
150
+ if (error?.code !== "EEXIST") throw error;
151
+ }
152
+ }
153
+ assertRealDirectory(current, label);
154
+ }
155
+ return directoryPath;
156
+ }
157
+
158
+ function readRegularBytes(filePath, label) {
159
+ const stat = lstatSync(filePath);
160
+ if (stat.isSymbolicLink()
161
+ || !stat.isFile()
162
+ || realpathSync(filePath) !== path.resolve(filePath)) {
163
+ throw new Error(`${label} must be a regular file without symlinks.`);
164
+ }
165
+ return readFileSync(filePath);
166
+ }
167
+
168
+ function fsyncDirectory(directoryPath) {
169
+ const descriptor = openSync(directoryPath, "r");
170
+ try {
171
+ fsyncSync(descriptor);
172
+ } finally {
173
+ closeSync(descriptor);
174
+ }
175
+ }
176
+
177
+ function writeDurableFile(filePath, bytes, { exclusive }) {
178
+ const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
179
+ let descriptor;
180
+ try {
181
+ descriptor = openSync(temporaryPath, "wx", 0o600);
182
+ writeFileSync(descriptor, bytes);
183
+ fsyncSync(descriptor);
184
+ closeSync(descriptor);
185
+ descriptor = undefined;
186
+ if (exclusive) linkSync(temporaryPath, filePath);
187
+ else renameSync(temporaryPath, filePath);
188
+ fsyncDirectory(path.dirname(filePath));
189
+ } finally {
190
+ if (descriptor !== undefined) closeSync(descriptor);
191
+ rmSync(temporaryPath, { force: true });
192
+ }
193
+ }
194
+
195
+ function renameDirectoryDurably(fromPath, toPath) {
196
+ const fromParent = path.dirname(fromPath);
197
+ const toParent = path.dirname(toPath);
198
+ renameSync(fromPath, toPath);
199
+ fsyncDirectory(fromParent);
200
+ if (toParent !== fromParent) fsyncDirectory(toParent);
201
+ }
202
+
203
+ function removeFileDurably(filePath) {
204
+ unlinkSync(filePath);
205
+ fsyncDirectory(path.dirname(filePath));
206
+ }
207
+
208
+ function parseDefinition(bytes, label) {
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(bytes.toString("utf8"));
212
+ } catch {
213
+ throw new Error(`${label} must contain valid JSON.`);
214
+ }
215
+ return normalizeTeamProjectDefinitionV1(parsed);
216
+ }
217
+
218
+ function definitionSourceExistsWithoutSymlinks(sourcePath, { allowMissing }) {
219
+ const root = path.parse(sourcePath).root;
220
+ const segments = path.relative(root, sourcePath).split(path.sep).filter(Boolean);
221
+ let current = root;
222
+ for (let index = 0; index < segments.length; index += 1) {
223
+ current = path.join(current, segments[index]);
224
+ if (!pathEntryExists(current)) {
225
+ if (allowMissing) return false;
226
+ throw new Error("Team project successor definition input does not exist.");
227
+ }
228
+ const stat = lstatSync(current);
229
+ if (stat.isSymbolicLink()) {
230
+ throw new Error("Team project successor definition input path must not contain symlinks.");
231
+ }
232
+ const isLeaf = index === segments.length - 1;
233
+ if ((!isLeaf && !stat.isDirectory()) || (isLeaf && !stat.isFile())) {
234
+ throw new Error("Team project successor definition input must be a regular file without symlinks.");
235
+ }
236
+ }
237
+ return true;
238
+ }
239
+
240
+ function readDefinitionSource(definitionPath, { allowMissing = false } = {}) {
241
+ const sourcePath = path.resolve(nonEmptyString(definitionPath, "definitionPath"));
242
+ if (!definitionSourceExistsWithoutSymlinks(sourcePath, { allowMissing })) {
243
+ return { sourcePath, missing: true };
244
+ }
245
+ const bytes = readRegularBytes(
246
+ sourcePath,
247
+ "Team project successor definition input",
248
+ );
249
+ return {
250
+ sourcePath,
251
+ sourceSha256: sha256(bytes),
252
+ definition: parseDefinition(bytes, "Team project successor definition input"),
253
+ missing: false,
254
+ };
255
+ }
256
+
257
+ function manifestForDirectory(directoryPath, label, { excludeRootControlLock = false } = {}) {
258
+ assertRealDirectory(directoryPath, label);
259
+ const directories = [];
260
+ const files = [];
261
+ function walk(current, prefix = "") {
262
+ const entries = readdirSync(current, { withFileTypes: true })
263
+ .sort((left, right) => compareBytes(left.name, right.name));
264
+ for (const entry of entries) {
265
+ if (excludeRootControlLock && prefix === "" && entry.name === "control.lock") continue;
266
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
267
+ const absolute = path.join(current, entry.name);
268
+ if (entry.isSymbolicLink()) {
269
+ throw new Error(`${label} contains a symlink: ${relative}.`);
270
+ }
271
+ if (entry.isDirectory()) {
272
+ assertRealDirectory(absolute, `${label} directory ${relative}`);
273
+ directories.push(relative);
274
+ walk(absolute, relative);
275
+ continue;
276
+ }
277
+ if (!entry.isFile()) {
278
+ throw new Error(`${label} contains a non-regular input: ${relative}.`);
279
+ }
280
+ const bytes = readRegularBytes(absolute, `${label} file ${relative}`);
281
+ files.push({ path: relative, byteLength: bytes.byteLength, sha256: sha256(bytes) });
282
+ }
283
+ }
284
+ walk(directoryPath);
285
+ directories.sort(compareBytes);
286
+ files.sort((left, right) => compareBytes(left.path, right.path));
287
+ const manifestSha256 = sha256(canonical({
288
+ algorithm: MANIFEST_ALGORITHM,
289
+ directories,
290
+ files,
291
+ }));
292
+ return { directories, files, manifestSha256 };
293
+ }
294
+
295
+ function assertManifestMatches(directoryPath, archive, label = "Team project archive") {
296
+ const actual = manifestForDirectory(directoryPath, label);
297
+ if (canonical(actual.directories) !== canonical(archive.directories)
298
+ || canonical(actual.files) !== canonical(archive.files)
299
+ || actual.manifestSha256 !== archive.manifestSha256) {
300
+ throw new Error(`${label} bytes do not match the transition journal.`);
301
+ }
302
+ }
303
+
304
+ function fsyncRegularFile(filePath) {
305
+ const descriptor = openSync(filePath, "r");
306
+ try {
307
+ fsyncSync(descriptor);
308
+ } finally {
309
+ closeSync(descriptor);
310
+ }
311
+ }
312
+
313
+ function sealArchiveTree(directoryPath) {
314
+ assertRealDirectory(directoryPath, "Team project archive being sealed");
315
+ const directories = [directoryPath];
316
+ const files = [];
317
+ function walk(current) {
318
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
319
+ const absolute = path.join(current, entry.name);
320
+ if (entry.isSymbolicLink()) {
321
+ throw new Error("Team project archive being sealed contains a symlink.");
322
+ }
323
+ if (entry.isDirectory()) {
324
+ assertRealDirectory(absolute, "Team project archive directory being sealed");
325
+ directories.push(absolute);
326
+ walk(absolute);
327
+ continue;
328
+ }
329
+ if (!entry.isFile()) {
330
+ throw new Error("Team project archive being sealed contains a non-regular input.");
331
+ }
332
+ readRegularBytes(absolute, "Team project archive file being sealed");
333
+ files.push(absolute);
334
+ }
335
+ }
336
+ walk(directoryPath);
337
+ for (const filePath of files) {
338
+ chmodSync(filePath, ARCHIVE_FILE_MODE);
339
+ fsyncRegularFile(filePath);
340
+ }
341
+ directories.sort((left, right) => right.length - left.length || compareBytes(right, left));
342
+ for (const archivedDirectory of directories) {
343
+ chmodSync(archivedDirectory, ARCHIVE_DIRECTORY_MODE);
344
+ fsyncDirectory(archivedDirectory);
345
+ }
346
+ }
347
+
348
+ function assertArchiveSealMatches(directoryPath, archive, label = "Team project archive") {
349
+ if (canonical(archive.sealPolicy) !== canonical(ARCHIVE_SEAL_POLICY)) {
350
+ throw new Error(`${label} seal policy is invalid.`);
351
+ }
352
+ const directoryPaths = [directoryPath, ...archive.directories.map(relative => (
353
+ path.join(directoryPath, ...relative.split("/"))
354
+ ))];
355
+ for (const archivedDirectory of directoryPaths) {
356
+ const stat = lstatSync(archivedDirectory);
357
+ if (stat.isSymbolicLink()
358
+ || !stat.isDirectory()
359
+ || realpathSync(archivedDirectory) !== path.resolve(archivedDirectory)
360
+ || (stat.mode & 0o7777) !== ARCHIVE_DIRECTORY_MODE) {
361
+ throw new Error(`${label} seal does not match the transition journal.`);
362
+ }
363
+ }
364
+ for (const row of archive.files) {
365
+ const filePath = path.join(directoryPath, ...row.path.split("/"));
366
+ const stat = lstatSync(filePath);
367
+ if (stat.isSymbolicLink()
368
+ || !stat.isFile()
369
+ || realpathSync(filePath) !== path.resolve(filePath)
370
+ || (stat.mode & 0o7777) !== ARCHIVE_FILE_MODE) {
371
+ throw new Error(`${label} seal does not match the transition journal.`);
372
+ }
373
+ }
374
+ }
375
+
376
+ function assertArchiveIntegrity(directoryPath, archive, label = "Team project archive") {
377
+ if (!pathEntryExists(directoryPath)) {
378
+ throw new Error(`${label} is missing.`);
379
+ }
380
+ assertManifestMatches(directoryPath, archive, label);
381
+ assertArchiveSealMatches(directoryPath, archive, label);
382
+ }
383
+
384
+ function requestSha256({
385
+ transitionId,
386
+ occurredAt,
387
+ reason,
388
+ definitionSourcePath,
389
+ definitionSourceSha256,
390
+ definition,
391
+ }) {
392
+ return sha256(canonical({
393
+ transitionId,
394
+ occurredAt,
395
+ reason,
396
+ definitionSourcePath,
397
+ definitionSourceSha256,
398
+ definition,
399
+ }));
400
+ }
401
+
402
+ function receiptSha256(receipt) {
403
+ const { receiptSha256: ignored, ...unsigned } = receipt;
404
+ return sha256(canonical(unsigned));
405
+ }
406
+
407
+ function withReceiptHash(receipt) {
408
+ return { ...receipt, receiptSha256: receiptSha256(receipt) };
409
+ }
410
+
411
+ function validateManifestRows(archive) {
412
+ if (archive.manifestAlgorithm !== MANIFEST_ALGORITHM
413
+ || !Array.isArray(archive.directories)
414
+ || !Array.isArray(archive.files)) {
415
+ throw new Error("Team project successor archive manifest is invalid.");
416
+ }
417
+ const directories = [...archive.directories].sort(compareBytes);
418
+ if (canonical(directories) !== canonical(archive.directories)
419
+ || new Set(directories).size !== directories.length
420
+ || directories.some(value => (
421
+ typeof value !== "string"
422
+ || value.length === 0
423
+ || path.isAbsolute(value)
424
+ || value.includes("\\")
425
+ || value.split("/").some(segment => !segment || segment === "." || segment === "..")
426
+ ))) {
427
+ throw new Error("Team project successor archive directories are invalid.");
428
+ }
429
+ for (const row of archive.files) {
430
+ assertAllowedKeys(row, "Team project successor archive file", ["path", "byteLength", "sha256"]);
431
+ if (typeof row.path !== "string"
432
+ || row.path.length === 0
433
+ || path.isAbsolute(row.path)
434
+ || row.path.includes("\\")
435
+ || row.path.split("/").some(segment => !segment || segment === "." || segment === "..")
436
+ || !Number.isSafeInteger(row.byteLength)
437
+ || row.byteLength < 0) {
438
+ throw new Error("Team project successor archive file manifest is invalid.");
439
+ }
440
+ assertHash(row.sha256, "Team project successor archive file sha256");
441
+ }
442
+ const files = [...archive.files].sort((left, right) => compareBytes(left.path, right.path));
443
+ if (canonical(files) !== canonical(archive.files)
444
+ || new Set(files.map(row => row.path)).size !== files.length) {
445
+ throw new Error("Team project successor archive files are not uniquely sorted.");
446
+ }
447
+ const expected = sha256(canonical({
448
+ algorithm: MANIFEST_ALGORITHM,
449
+ directories: archive.directories,
450
+ files: archive.files,
451
+ }));
452
+ if (archive.manifestSha256 !== expected) {
453
+ throw new Error("Team project successor archive manifest hash is invalid.");
454
+ }
455
+ }
456
+
457
+ function validateSealPolicy(sealPolicy) {
458
+ if (!isRecord(sealPolicy)) {
459
+ throw new Error("Team project successor archive seal policy is invalid.");
460
+ }
461
+ assertAllowedKeys(sealPolicy, "Team project successor archive seal policy", [
462
+ "policy", "regularFileMode", "directoryMode",
463
+ ]);
464
+ if (canonical(sealPolicy) !== canonical(ARCHIVE_SEAL_POLICY)) {
465
+ throw new Error("Team project successor archive seal policy is invalid.");
466
+ }
467
+ }
468
+
469
+ function validateDefinitionBinding(binding, label) {
470
+ const normalized = normalizeTeamProjectDefinitionV1(binding.definition);
471
+ if (canonical(normalized) !== canonical(binding.definition)) {
472
+ throw new Error(`${label} definition is not normalized.`);
473
+ }
474
+ if (binding.projectId !== normalized.projectId) {
475
+ throw new Error(`${label} projectId does not match its definition.`);
476
+ }
477
+ assertHash(binding.definitionRawSha256, `${label} definitionRawSha256`);
478
+ assertHash(binding.definitionCanonicalSha256, `${label} definitionCanonicalSha256`);
479
+ if (binding.definitionCanonicalSha256 !== sha256(canonical(normalized))) {
480
+ throw new Error(`${label} definition canonical hash is invalid.`);
481
+ }
482
+ assertHash(binding.projectTruthHash, `${label} projectTruthHash`);
483
+ if (!Number.isSafeInteger(binding.eventCount) || binding.eventCount < 0) {
484
+ throw new Error(`${label} eventCount is invalid.`);
485
+ }
486
+ return normalized;
487
+ }
488
+
489
+ function validateJournal(receipt, { expectedPath, expectedTransitionId }) {
490
+ assertAllowedKeys(receipt, "Team project successor receipt", [
491
+ "schemaVersion", "status", "transitionId", "phase", "occurredAt", "reason",
492
+ "requestSha256", "receiptPath", "from", "to", "archive",
493
+ "repositoryActions", "authorizationGranted", "receiptSha256",
494
+ ]);
495
+ if (receipt.schemaVersion !== RECEIPT_SCHEMA
496
+ || receipt.status !== "team_project_successor"
497
+ || !PHASES.has(receipt.phase)
498
+ || receipt.transitionId !== expectedTransitionId) {
499
+ throw new Error("Team project successor receipt identity is invalid.");
500
+ }
501
+ validateTimestamp(receipt.occurredAt);
502
+ nonEmptyString(receipt.reason, "Team project successor reason");
503
+ assertHash(receipt.requestSha256, "Team project successor requestSha256");
504
+ assertHash(receipt.receiptSha256, "Team project successor receiptSha256");
505
+ if (receipt.receiptSha256 !== receiptSha256(receipt)) {
506
+ throw new Error("Team project successor receipt hash is invalid.");
507
+ }
508
+ if (receipt.receiptPath !== expectedPath
509
+ || receipt.authorizationGranted !== false
510
+ || canonical(receipt.repositoryActions) !== canonical(REPOSITORY_ACTIONS)) {
511
+ throw new Error("Team project successor receipt authority or path is invalid.");
512
+ }
513
+ assertAllowedKeys(receipt.from, "Team project successor from binding", [
514
+ "projectId", "definition", "definitionRawSha256", "definitionCanonicalSha256",
515
+ "projectTruthHash", "eventCount",
516
+ ]);
517
+ assertAllowedKeys(receipt.to, "Team project successor to binding", [
518
+ "projectId", "definitionSourcePath", "definitionSourceSha256", "definition",
519
+ "definitionRawSha256", "definitionCanonicalSha256", "projectTruthHash", "eventCount",
520
+ ]);
521
+ const fromDefinition = validateDefinitionBinding(receipt.from, "Team project successor from");
522
+ const toDefinition = validateDefinitionBinding(receipt.to, "Team project successor to");
523
+ if (fromDefinition.projectId === toDefinition.projectId || receipt.to.eventCount !== 0) {
524
+ throw new Error("Team project successor definitions must have different identities and an empty target event set.");
525
+ }
526
+ if (typeof receipt.to.definitionSourcePath !== "string"
527
+ || path.resolve(receipt.to.definitionSourcePath) !== receipt.to.definitionSourcePath) {
528
+ throw new Error("Team project successor definition source path is invalid.");
529
+ }
530
+ assertHash(receipt.to.definitionSourceSha256, "Team project successor definitionSourceSha256");
531
+ if (receipt.to.projectTruthHash !== emptyTeamProjectTruthHashV1(toDefinition)) {
532
+ throw new Error("Team project successor target truth hash is invalid.");
533
+ }
534
+ assertAllowedKeys(receipt.archive, "Team project successor archive binding", [
535
+ "path", "manifestAlgorithm", "manifestSha256", "directories", "files", "sealPolicy",
536
+ ]);
537
+ validateManifestRows(receipt.archive);
538
+ validateSealPolicy(receipt.archive.sealPolicy);
539
+ const expectedArchivePath = `${ARCHIVE_ROOT}/${receipt.from.projectId}-${receipt.from.projectTruthHash}-${receipt.archive.manifestSha256}`;
540
+ if (receipt.archive.path !== expectedArchivePath) {
541
+ throw new Error("Team project successor archive identity is invalid.");
542
+ }
543
+ const definitionEntry = receipt.archive.files.find(row => row.path === "definition.json");
544
+ if (!definitionEntry || definitionEntry.sha256 !== receipt.from.definitionRawSha256) {
545
+ throw new Error("Team project successor archive is not bound to the original definition bytes.");
546
+ }
547
+ const eventCount = receipt.archive.files.filter(row => (
548
+ row.path.startsWith("events/") && row.path.endsWith(".json")
549
+ )).length;
550
+ if (eventCount !== receipt.from.eventCount) {
551
+ throw new Error("Team project successor archive event count is invalid.");
552
+ }
553
+ const expectedRequest = requestSha256({
554
+ transitionId: receipt.transitionId,
555
+ occurredAt: receipt.occurredAt,
556
+ reason: receipt.reason,
557
+ definitionSourcePath: receipt.to.definitionSourcePath,
558
+ definitionSourceSha256: receipt.to.definitionSourceSha256,
559
+ definition: receipt.to.definition,
560
+ });
561
+ if (receipt.requestSha256 !== expectedRequest) {
562
+ throw new Error("Team project successor request hash is invalid.");
563
+ }
564
+ return receipt;
565
+ }
566
+
567
+ function loadJournal(journalPath, root, transitionId) {
568
+ const relativePath = relativeToWorkspace(root, journalPath);
569
+ if (!pathEntryExists(journalPath)) return null;
570
+ const bytes = readRegularBytes(journalPath, "Team project successor receipt/journal");
571
+ let receipt;
572
+ try {
573
+ receipt = JSON.parse(bytes.toString("utf8"));
574
+ } catch {
575
+ throw new Error("Team project successor receipt/journal contains invalid JSON.");
576
+ }
577
+ return validateJournal(receipt, { expectedPath: relativePath, expectedTransitionId: transitionId });
578
+ }
579
+
580
+ function validateCompletedTransitionArchives(root) {
581
+ const transitionRoot = path.join(root, TRANSITION_ROOT);
582
+ if (!pathEntryExists(transitionRoot)) return;
583
+ assertRealDirectory(
584
+ transitionRoot,
585
+ "Team project successor completed-transition namespace",
586
+ );
587
+ const entries = readdirSync(transitionRoot, { withFileTypes: true })
588
+ .sort((left, right) => compareBytes(left.name, right.name));
589
+ for (const entry of entries) {
590
+ if (entry.isSymbolicLink() || !entry.isFile() || !entry.name.endsWith(".json")) {
591
+ throw new Error("Team project successor transition namespace contains an unexpected input.");
592
+ }
593
+ const historicalTransitionId = entry.name.slice(0, -".json".length);
594
+ safeIdentifier(historicalTransitionId, "historical transitionId");
595
+ const receipt = loadJournal(
596
+ path.join(transitionRoot, entry.name),
597
+ root,
598
+ historicalTransitionId,
599
+ );
600
+ if (receipt.phase !== "completed") continue;
601
+ const label = `Historical team project archive for ${historicalTransitionId}`;
602
+ assertArchiveIntegrity(path.join(root, receipt.archive.path), receipt.archive, label);
603
+ }
604
+ }
605
+
606
+ function writeJournal(journalPath, receipt, { exclusive = false } = {}) {
607
+ const hashed = withReceiptHash(receipt);
608
+ writeDurableFile(
609
+ journalPath,
610
+ Buffer.from(`${JSON.stringify(hashed, null, 2)}\n`),
611
+ { exclusive },
612
+ );
613
+ return hashed;
614
+ }
615
+
616
+ function isPidAlive(pid) {
617
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
618
+ try {
619
+ process.kill(pid, 0);
620
+ return true;
621
+ } catch (error) {
622
+ return error?.code === "EPERM";
623
+ }
624
+ }
625
+
626
+ function readLock(lockPath, label) {
627
+ const bytes = readRegularBytes(lockPath, label);
628
+ let lock;
629
+ try {
630
+ lock = JSON.parse(bytes.toString("utf8"));
631
+ } catch {
632
+ throw new Error(`${label} is malformed.`);
633
+ }
634
+ assertAllowedKeys(lock, label, [
635
+ "schemaVersion", "transitionId", "requestSha256", "pid", "nonce", "createdAt",
636
+ ]);
637
+ if (lock.schemaVersion !== LOCK_SCHEMA) throw new Error(`${label} has an unknown schema.`);
638
+ safeIdentifier(lock.transitionId, "transitionId");
639
+ assertHash(lock.requestSha256, `${label} requestSha256`);
640
+ if (!Number.isSafeInteger(lock.pid) || lock.pid <= 0
641
+ || typeof lock.nonce !== "string"
642
+ || !/^[a-f0-9-]{36}$/u.test(lock.nonce)) {
643
+ throw new Error(`${label} owner is invalid.`);
644
+ }
645
+ validateTimestamp(lock.createdAt);
646
+ return lock;
647
+ }
648
+
649
+ function acquireLifecycleLock({ root, transitionId, requestHash, occurredAt }) {
650
+ const lockPath = path.join(root, LIFECYCLE_LOCK);
651
+ const lock = {
652
+ schemaVersion: LOCK_SCHEMA,
653
+ transitionId,
654
+ requestSha256: requestHash,
655
+ pid: process.pid,
656
+ nonce: randomUUID(),
657
+ createdAt: occurredAt,
658
+ };
659
+ for (;;) {
660
+ if (!pathEntryExists(lockPath)) {
661
+ try {
662
+ writeDurableFile(
663
+ lockPath,
664
+ Buffer.from(`${JSON.stringify(lock, null, 2)}\n`),
665
+ { exclusive: true },
666
+ );
667
+ return { path: lockPath, value: lock };
668
+ } catch (error) {
669
+ if (error?.code === "EEXIST") continue;
670
+ throw error;
671
+ }
672
+ }
673
+ const existing = readLock(lockPath, "Team project successor lifecycle lock");
674
+ if (existing.transitionId !== transitionId || existing.requestSha256 !== requestHash) {
675
+ throw new Error("Another team project lifecycle transaction is active.");
676
+ }
677
+ if (isPidAlive(existing.pid)) {
678
+ throw new Error("Another team project lifecycle transaction is active.");
679
+ }
680
+ const stalePath = `${lockPath}.stale-${randomUUID()}`;
681
+ try {
682
+ renameSync(lockPath, stalePath);
683
+ fsyncDirectory(path.dirname(lockPath));
684
+ } catch (error) {
685
+ if (error?.code === "ENOENT") continue;
686
+ throw error;
687
+ }
688
+ rmSync(stalePath, { force: true });
689
+ fsyncDirectory(path.dirname(lockPath));
690
+ }
691
+ }
692
+
693
+ function releaseLifecycleLock(lock) {
694
+ if (!lock || !pathEntryExists(lock.path)) return;
695
+ const current = readLock(lock.path, "Team project successor lifecycle lock");
696
+ if (current.nonce !== lock.value.nonce) {
697
+ throw new Error("Team project successor lifecycle lock ownership changed.");
698
+ }
699
+ removeFileDurably(lock.path);
700
+ }
701
+
702
+ function activeControlOwnerPath(projectPath) {
703
+ return path.join(projectPath, "control.lock", "successor-owner.json");
704
+ }
705
+
706
+ function acquireActiveControlLock({ projectPath, transitionId, requestHash, occurredAt }) {
707
+ const lockPath = path.join(projectPath, "control.lock");
708
+ const owner = {
709
+ schemaVersion: LOCK_SCHEMA,
710
+ transitionId,
711
+ requestSha256: requestHash,
712
+ pid: process.pid,
713
+ nonce: randomUUID(),
714
+ createdAt: occurredAt,
715
+ };
716
+ for (;;) {
717
+ try {
718
+ mkdirSync(lockPath);
719
+ writeDurableFile(
720
+ activeControlOwnerPath(projectPath),
721
+ Buffer.from(`${JSON.stringify(owner, null, 2)}\n`),
722
+ { exclusive: true },
723
+ );
724
+ fsyncDirectory(projectPath);
725
+ return { projectPath, lockPath, owner };
726
+ } catch (error) {
727
+ if (error?.code !== "EEXIST") throw error;
728
+ }
729
+ assertRealDirectory(lockPath, "Team project control lock");
730
+ const ownerPath = activeControlOwnerPath(projectPath);
731
+ if (!pathEntryExists(ownerPath)) {
732
+ throw new Error("Another team project control transaction is active.");
733
+ }
734
+ const existing = readLock(ownerPath, "Team project successor control lock");
735
+ if (existing.transitionId !== transitionId
736
+ || existing.requestSha256 !== requestHash
737
+ || isPidAlive(existing.pid)) {
738
+ throw new Error("Another team project control transaction is active.");
739
+ }
740
+ const entries = readdirSync(lockPath);
741
+ if (entries.length !== 1 || entries[0] !== "successor-owner.json") {
742
+ throw new Error("Team project successor control lock contains unexpected entries.");
743
+ }
744
+ removeFileDurably(ownerPath);
745
+ rmdirSync(lockPath);
746
+ fsyncDirectory(projectPath);
747
+ }
748
+ }
749
+
750
+ function releaseActiveControlLock(control) {
751
+ if (!control || !pathEntryExists(control.projectPath)) return;
752
+ if (!pathEntryExists(control.lockPath)) return;
753
+ const ownerPath = activeControlOwnerPath(control.projectPath);
754
+ const existing = readLock(ownerPath, "Team project successor control lock");
755
+ if (existing.nonce !== control.owner.nonce) {
756
+ throw new Error("Team project successor control lock ownership changed.");
757
+ }
758
+ removeFileDurably(ownerPath);
759
+ rmdirSync(control.lockPath);
760
+ fsyncDirectory(control.projectPath);
761
+ }
762
+
763
+ function removeArchivedControlLock(projectPath, receipt) {
764
+ const lockPath = path.join(projectPath, "control.lock");
765
+ if (!pathEntryExists(lockPath)) return;
766
+ assertRealDirectory(lockPath, "Archived team project control lock");
767
+ const ownerPath = activeControlOwnerPath(projectPath);
768
+ const existing = readLock(ownerPath, "Archived team project successor control lock");
769
+ if (existing.transitionId !== receipt.transitionId
770
+ || existing.requestSha256 !== receipt.requestSha256) {
771
+ throw new Error("Archived team project control lock is not owned by this transition.");
772
+ }
773
+ const entries = readdirSync(lockPath);
774
+ if (entries.length !== 1 || entries[0] !== "successor-owner.json") {
775
+ throw new Error("Archived team project control lock contains unexpected entries.");
776
+ }
777
+ removeFileDurably(ownerPath);
778
+ rmdirSync(lockPath);
779
+ fsyncDirectory(projectPath);
780
+ }
781
+
782
+ function assertInvocationMatchesJournal(receipt, invocation) {
783
+ if (receipt.transitionId !== invocation.transitionId
784
+ || receipt.occurredAt !== invocation.occurredAt
785
+ || receipt.reason !== invocation.reason
786
+ || receipt.to.definitionSourcePath !== invocation.definitionSourcePath) {
787
+ throw new Error("Team project successor retry does not match the durable transition request.");
788
+ }
789
+ const source = readDefinitionSource(invocation.definitionSourcePath, { allowMissing: true });
790
+ if (!source.missing && (
791
+ source.sourceSha256 !== receipt.to.definitionSourceSha256
792
+ || canonical(source.definition) !== canonical(receipt.to.definition)
793
+ )) {
794
+ throw new Error("Team project successor retry definition differs from the durable transition request.");
795
+ }
796
+ }
797
+
798
+ function createPreparedProject(projectPath, receipt, root) {
799
+ ensureRealDirectory(root, path.dirname(projectPath), "Team project successor staging namespace");
800
+ if (!pathEntryExists(projectPath)) mkdirSync(projectPath);
801
+ assertRealDirectory(projectPath, "Team project successor staged active project");
802
+ const allowed = new Set(["definition.json", "events"]);
803
+ for (const entry of readdirSync(projectPath)) {
804
+ if (!allowed.has(entry)) {
805
+ throw new Error(`Team project successor staged active project contains unexpected entry: ${entry}.`);
806
+ }
807
+ }
808
+ const definitionPath = path.join(projectPath, "definition.json");
809
+ const definitionBytes = Buffer.from(`${JSON.stringify(receipt.to.definition, null, 2)}\n`);
810
+ if (!pathEntryExists(definitionPath)) {
811
+ writeDurableFile(definitionPath, definitionBytes, { exclusive: true });
812
+ } else if (!readRegularBytes(
813
+ definitionPath,
814
+ "Team project successor staged definition",
815
+ ).equals(definitionBytes)) {
816
+ throw new Error("Team project successor staged definition bytes differ.");
817
+ }
818
+ const eventsPath = path.join(projectPath, "events");
819
+ if (!pathEntryExists(eventsPath)) mkdirSync(eventsPath);
820
+ assertRealDirectory(eventsPath, "Team project successor staged event directory");
821
+ if (readdirSync(eventsPath).length !== 0) {
822
+ throw new Error("Team project successor staged event set must be empty.");
823
+ }
824
+ fsyncDirectory(projectPath);
825
+ }
826
+
827
+ function assertFreshActiveProject(root, receipt) {
828
+ const activePath = path.join(root, ACTIVE_PROJECT);
829
+ assertRealDirectory(activePath, "Team project successor active project");
830
+ const entries = readdirSync(activePath).sort(compareBytes);
831
+ if (canonical(entries) !== canonical(["definition.json", "events"])) {
832
+ throw new Error("Team project successor active project contains borrowed or unexpected truth.");
833
+ }
834
+ const expectedDefinitionBytes = Buffer.from(`${JSON.stringify(receipt.to.definition, null, 2)}\n`);
835
+ const actualDefinitionBytes = readRegularBytes(
836
+ path.join(activePath, "definition.json"),
837
+ "Team project successor active definition",
838
+ );
839
+ if (!actualDefinitionBytes.equals(expectedDefinitionBytes)
840
+ || sha256(actualDefinitionBytes) !== receipt.to.definitionRawSha256) {
841
+ throw new Error("Team project successor active definition bytes differ from the transition receipt.");
842
+ }
843
+ const eventsPath = path.join(activePath, "events");
844
+ assertRealDirectory(eventsPath, "Team project successor active event directory");
845
+ if (readdirSync(eventsPath).length !== 0) {
846
+ throw new Error("Team project successor active event set is not empty.");
847
+ }
848
+ const status = readTeamProjectStatusV1({ workspaceRoot: root });
849
+ if (status.projectId !== receipt.to.projectId
850
+ || status.projectTruthHash !== receipt.to.projectTruthHash
851
+ || status.overall !== "planned") {
852
+ throw new Error("Team project successor active project does not match the fresh target truth.");
853
+ }
854
+ }
855
+
856
+ function createInitialJournal({
857
+ root,
858
+ transitionId,
859
+ occurredAt,
860
+ reason,
861
+ definitionSource,
862
+ requestHash,
863
+ journalPath,
864
+ activePath,
865
+ }) {
866
+ const status = readTeamProjectStatusV1({ workspaceRoot: root });
867
+ if (status.overall !== "completed") {
868
+ throw new Error("Team project successor requires active project derived status to be completed.");
869
+ }
870
+ const fromDefinitionBytes = readRegularBytes(
871
+ path.join(activePath, "definition.json"),
872
+ "Active team project definition",
873
+ );
874
+ const fromDefinition = parseDefinition(fromDefinitionBytes, "Active team project definition");
875
+ if (fromDefinition.projectId === definitionSource.definition.projectId) {
876
+ throw new Error("Team project successor definition must use a different project identity.");
877
+ }
878
+ const manifest = manifestForDirectory(
879
+ activePath,
880
+ "Active team project",
881
+ { excludeRootControlLock: true },
882
+ );
883
+ const definitionEntry = manifest.files.find(row => row.path === "definition.json");
884
+ if (!definitionEntry) throw new Error("Active team project definition is missing from its raw manifest.");
885
+ const archivePath = `${ARCHIVE_ROOT}/${fromDefinition.projectId}-${status.projectTruthHash}-${manifest.manifestSha256}`;
886
+ const archiveAbsolutePath = path.join(root, archivePath);
887
+ if (pathEntryExists(archiveAbsolutePath)) {
888
+ const stat = lstatSync(archiveAbsolutePath);
889
+ if (stat.isSymbolicLink()) throw new Error("Team project archive collision is a symlink.");
890
+ throw new Error("Team project archive collision already exists before transition preparation.");
891
+ }
892
+ const targetDefinitionBytes = Buffer.from(`${JSON.stringify(definitionSource.definition, null, 2)}\n`);
893
+ const relativeJournalPath = relativeToWorkspace(root, journalPath);
894
+ return {
895
+ schemaVersion: RECEIPT_SCHEMA,
896
+ status: "team_project_successor",
897
+ transitionId,
898
+ phase: "prepared",
899
+ occurredAt,
900
+ reason,
901
+ requestSha256: requestHash,
902
+ receiptPath: relativeJournalPath,
903
+ from: {
904
+ projectId: fromDefinition.projectId,
905
+ definition: fromDefinition,
906
+ definitionRawSha256: definitionEntry.sha256,
907
+ definitionCanonicalSha256: sha256(canonical(fromDefinition)),
908
+ projectTruthHash: status.projectTruthHash,
909
+ eventCount: manifest.files.filter(row => (
910
+ row.path.startsWith("events/") && row.path.endsWith(".json")
911
+ )).length,
912
+ },
913
+ to: {
914
+ projectId: definitionSource.definition.projectId,
915
+ definitionSourcePath: definitionSource.sourcePath,
916
+ definitionSourceSha256: definitionSource.sourceSha256,
917
+ definition: definitionSource.definition,
918
+ definitionRawSha256: sha256(targetDefinitionBytes),
919
+ definitionCanonicalSha256: sha256(canonical(definitionSource.definition)),
920
+ projectTruthHash: emptyTeamProjectTruthHashV1(definitionSource.definition),
921
+ eventCount: 0,
922
+ },
923
+ archive: {
924
+ path: archivePath,
925
+ manifestAlgorithm: MANIFEST_ALGORITHM,
926
+ manifestSha256: manifest.manifestSha256,
927
+ directories: manifest.directories,
928
+ files: manifest.files,
929
+ sealPolicy: { ...ARCHIVE_SEAL_POLICY },
930
+ },
931
+ repositoryActions: { ...REPOSITORY_ACTIONS },
932
+ authorizationGranted: false,
933
+ };
934
+ }
935
+
936
+ function projectLooksLikeTarget(root, receipt) {
937
+ const activePath = path.join(root, ACTIVE_PROJECT);
938
+ if (!pathEntryExists(activePath)) return false;
939
+ try {
940
+ const bytes = readRegularBytes(
941
+ path.join(activePath, "definition.json"),
942
+ "Team project successor active definition",
943
+ );
944
+ return sha256(bytes) === receipt.to.definitionRawSha256;
945
+ } catch {
946
+ return false;
947
+ }
948
+ }
949
+
950
+ function cleanupEmptyStaging(stagePath) {
951
+ if (!pathEntryExists(stagePath)) return;
952
+ assertRealDirectory(stagePath, "Team project successor staging directory");
953
+ if (readdirSync(stagePath).length !== 0) return;
954
+ rmdirSync(stagePath);
955
+ fsyncDirectory(path.dirname(stagePath));
956
+ }
957
+
958
+ function resultFor(receipt, resumed) {
959
+ return {
960
+ status: "team_project_successor",
961
+ phase: receipt.phase,
962
+ transitionId: receipt.transitionId,
963
+ fromProjectId: receipt.from.projectId,
964
+ toProjectId: receipt.to.projectId,
965
+ archivePath: receipt.archive.path,
966
+ receiptPath: receipt.receiptPath,
967
+ receiptSha256: receipt.receiptSha256,
968
+ resumed,
969
+ authorizationGranted: false,
970
+ exitCode: 0,
971
+ };
972
+ }
973
+
974
+ export function successorTeamProjectV1({
975
+ workspaceRoot,
976
+ transitionId,
977
+ occurredAt,
978
+ definitionPath,
979
+ reason,
980
+ onDurableStep,
981
+ }) {
982
+ const root = assertWorkspaceRoot(workspaceRoot);
983
+ safeIdentifier(transitionId, "transitionId");
984
+ validateTimestamp(occurredAt);
985
+ nonEmptyString(reason, "Team project successor reason");
986
+ const runkitPath = path.join(root, RUNKIT_ROOT);
987
+ assertRealDirectory(path.join(root, ".owlcoda"), "OwlCoda control directory");
988
+ assertRealDirectory(runkitPath, "RunKit control directory");
989
+ const activePath = path.join(root, ACTIVE_PROJECT);
990
+ if (pathEntryExists(activePath)) {
991
+ assertRealDirectory(activePath, "Active project");
992
+ }
993
+ const journalPath = path.join(root, TRANSITION_ROOT, `${transitionId}.json`);
994
+ const invocation = {
995
+ transitionId,
996
+ occurredAt,
997
+ reason,
998
+ definitionSourcePath: path.resolve(nonEmptyString(definitionPath, "definitionPath")),
999
+ };
1000
+ const existingBeforeLock = pathEntryExists(path.dirname(journalPath))
1001
+ ? loadJournal(journalPath, root, transitionId)
1002
+ : null;
1003
+ let definitionSource;
1004
+ let requestHash;
1005
+ if (existingBeforeLock) {
1006
+ assertInvocationMatchesJournal(existingBeforeLock, invocation);
1007
+ definitionSource = {
1008
+ sourcePath: existingBeforeLock.to.definitionSourcePath,
1009
+ sourceSha256: existingBeforeLock.to.definitionSourceSha256,
1010
+ definition: existingBeforeLock.to.definition,
1011
+ missing: false,
1012
+ };
1013
+ requestHash = existingBeforeLock.requestSha256;
1014
+ } else {
1015
+ definitionSource = readDefinitionSource(definitionPath);
1016
+ requestHash = requestSha256({
1017
+ transitionId,
1018
+ occurredAt,
1019
+ reason,
1020
+ definitionSourcePath: definitionSource.sourcePath,
1021
+ definitionSourceSha256: definitionSource.sourceSha256,
1022
+ definition: definitionSource.definition,
1023
+ });
1024
+ }
1025
+
1026
+ const lifecycleLock = acquireLifecycleLock({
1027
+ root,
1028
+ transitionId,
1029
+ requestHash,
1030
+ occurredAt,
1031
+ });
1032
+ let activeControl = null;
1033
+ let activeControlMoved = false;
1034
+ const resumed = existingBeforeLock !== null;
1035
+ try {
1036
+ let receipt = pathEntryExists(path.dirname(journalPath))
1037
+ ? loadJournal(journalPath, root, transitionId)
1038
+ : null;
1039
+ if (receipt) {
1040
+ assertInvocationMatchesJournal(receipt, invocation);
1041
+ if (receipt.requestSha256 !== requestHash) {
1042
+ throw new Error("Team project successor lifecycle lock does not match the journal request.");
1043
+ }
1044
+ }
1045
+ validateCompletedTransitionArchives(root);
1046
+ if (!receipt) {
1047
+ if (!pathEntryExists(activePath)) throw new Error("Team project is not initialized.");
1048
+ activeControl = acquireActiveControlLock({
1049
+ projectPath: activePath,
1050
+ transitionId,
1051
+ requestHash,
1052
+ occurredAt,
1053
+ });
1054
+ const prepared = createInitialJournal({
1055
+ root,
1056
+ transitionId,
1057
+ occurredAt,
1058
+ reason,
1059
+ definitionSource,
1060
+ requestHash,
1061
+ journalPath,
1062
+ activePath,
1063
+ });
1064
+ ensureRealDirectory(root, path.dirname(journalPath), "Team project successor receipt namespace");
1065
+ receipt = writeJournal(journalPath, prepared, { exclusive: true });
1066
+ onDurableStep?.({ phase: "prepared", receiptPath: receipt.receiptPath });
1067
+ }
1068
+
1069
+ const archivePath = path.join(root, receipt.archive.path);
1070
+ const stagePath = path.join(root, STAGING_ROOT, transitionId);
1071
+ const stagedNextPath = path.join(stagePath, "next-project");
1072
+ const stagedArchivePath = path.join(stagePath, "archived-project");
1073
+
1074
+ if (receipt.phase === "completed") {
1075
+ assertFreshActiveProject(root, receipt);
1076
+ return resultFor(receipt, true);
1077
+ }
1078
+
1079
+ if (pathEntryExists(archivePath)) {
1080
+ const stat = lstatSync(archivePath);
1081
+ if (stat.isSymbolicLink()) throw new Error("Team project archive input must not be a symlink.");
1082
+ assertManifestMatches(archivePath, receipt.archive);
1083
+ if (receipt.phase === "prepared") sealArchiveTree(archivePath);
1084
+ assertArchiveIntegrity(archivePath, receipt.archive);
1085
+ }
1086
+
1087
+ if (!pathEntryExists(archivePath)) {
1088
+ createPreparedProject(stagedNextPath, receipt, root);
1089
+ if (!pathEntryExists(stagedArchivePath)) {
1090
+ if (!pathEntryExists(activePath)) {
1091
+ throw new Error("Team project successor cannot recover the missing active project or archive.");
1092
+ }
1093
+ if (projectLooksLikeTarget(root, receipt)) {
1094
+ throw new Error("Team project successor target is active without its immutable archive.");
1095
+ }
1096
+ if (!activeControl) {
1097
+ activeControl = acquireActiveControlLock({
1098
+ projectPath: activePath,
1099
+ transitionId,
1100
+ requestHash,
1101
+ occurredAt,
1102
+ });
1103
+ }
1104
+ const status = readTeamProjectStatusV1({ workspaceRoot: root });
1105
+ const manifest = manifestForDirectory(
1106
+ activePath,
1107
+ "Active team project",
1108
+ { excludeRootControlLock: true },
1109
+ );
1110
+ if (status.overall !== "completed"
1111
+ || status.projectId !== receipt.from.projectId
1112
+ || status.projectTruthHash !== receipt.from.projectTruthHash
1113
+ || manifest.manifestSha256 !== receipt.archive.manifestSha256
1114
+ || canonical(manifest.directories) !== canonical(receipt.archive.directories)
1115
+ || canonical(manifest.files) !== canonical(receipt.archive.files)) {
1116
+ throw new Error("Active team project changed after successor preparation.");
1117
+ }
1118
+ ensureRealDirectory(root, stagePath, "Team project successor staging directory");
1119
+ renameDirectoryDurably(activePath, stagedArchivePath);
1120
+ activeControlMoved = true;
1121
+ } else {
1122
+ assertRealDirectory(stagedArchivePath, "Staged team project archive");
1123
+ }
1124
+ removeArchivedControlLock(stagedArchivePath, receipt);
1125
+ assertManifestMatches(stagedArchivePath, receipt.archive);
1126
+ ensureRealDirectory(root, path.dirname(archivePath), "Team project archive namespace");
1127
+ if (pathEntryExists(archivePath)) {
1128
+ throw new Error("Team project archive collision appeared during transition.");
1129
+ }
1130
+ renameDirectoryDurably(stagedArchivePath, archivePath);
1131
+ sealArchiveTree(archivePath);
1132
+ }
1133
+ assertArchiveIntegrity(archivePath, receipt.archive);
1134
+ if (receipt.phase === "prepared") {
1135
+ receipt = writeJournal(journalPath, { ...receipt, phase: "archived" });
1136
+ onDurableStep?.({ phase: "archived", receiptPath: receipt.receiptPath });
1137
+ }
1138
+
1139
+ if (pathEntryExists(activePath)) {
1140
+ if (pathEntryExists(stagedNextPath)) {
1141
+ throw new Error("Team project successor has both an active and staged target project.");
1142
+ }
1143
+ assertFreshActiveProject(root, receipt);
1144
+ } else {
1145
+ if (!pathEntryExists(stagedNextPath)) createPreparedProject(stagedNextPath, receipt, root);
1146
+ renameDirectoryDurably(stagedNextPath, activePath);
1147
+ assertFreshActiveProject(root, receipt);
1148
+ }
1149
+ if (receipt.phase === "archived") {
1150
+ receipt = writeJournal(journalPath, { ...receipt, phase: "active_installed" });
1151
+ onDurableStep?.({ phase: "active_installed", receiptPath: receipt.receiptPath });
1152
+ }
1153
+
1154
+ if (receipt.phase === "active_installed") {
1155
+ receipt = writeJournal(journalPath, { ...receipt, phase: "completed" });
1156
+ }
1157
+ assertArchiveIntegrity(archivePath, receipt.archive);
1158
+ assertFreshActiveProject(root, receipt);
1159
+ cleanupEmptyStaging(stagePath);
1160
+ return resultFor(receipt, resumed);
1161
+ } finally {
1162
+ if (!activeControlMoved) releaseActiveControlLock(activeControl);
1163
+ releaseLifecycleLock(lifecycleLock);
1164
+ }
1165
+ }
1166
+
1167
+ export function formatTeamProjectSuccessorHumanV1(result) {
1168
+ return [
1169
+ `Archived completed project ${result.fromProjectId}.`,
1170
+ `Active project: ${result.toProjectId}`,
1171
+ `Archive: ${result.archivePath}`,
1172
+ `Receipt: ${result.receiptPath}`,
1173
+ `Authorization granted: ${result.authorizationGranted}`,
1174
+ "",
1175
+ ].join("\n");
1176
+ }