owlrunkit 0.22.0 → 0.22.1

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.
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import {
5
5
  cp,
6
+ link,
6
7
  lstat,
7
8
  mkdir,
8
9
  mkdtemp,
10
+ open,
9
11
  readFile,
10
12
  readdir,
13
+ realpath,
11
14
  rename,
12
15
  rm,
13
16
  writeFile,
@@ -17,14 +20,26 @@ import { pathToFileURL } from "node:url";
17
20
 
18
21
  import {
19
22
  currentCoreIdentity,
20
- initializeProjectRunKit,
21
23
  isDirectExecution,
22
24
  validateProjectConfigV2,
23
25
  } from "./core-contract.mjs";
24
- import { discoverFleet } from "./fleet-discovery.mjs";
26
+ import {
27
+ defaultFleetRegistryPath,
28
+ discoverFleet,
29
+ } from "./fleet-discovery.mjs";
25
30
  import { inspectProjectControlState } from "./project-control-state.mjs";
31
+ import { resolveProjectCli } from "./project-cli-resolver.mjs";
32
+ import { validateStoredRegistryAdoptionEvidence } from "./registry-adoption-gate.mjs";
26
33
 
27
34
  const MANIFEST_FILE = ".owlcoda-install-manifest.json";
35
+ const TRANSACTION_SCHEMA = "OwlCodaRunKitSharedSkillTransactionV1";
36
+ const TRANSACTION_OWNER_FILE = "shared-skill-owner.json";
37
+ const MAX_PROJECT_EVIDENCE_BYTES = 1_048_576;
38
+ const MAX_PROJECT_LOCKFILE_BYTES = 33_554_432;
39
+ const MAX_FLEET_PROJECTS = 256;
40
+ const MAX_SKILL_RECEIPT_BYTES = 134_217_728;
41
+ const MAX_V2_SKILL_RECEIPT_BYTES = 33_554_432;
42
+ const MAX_TRANSACTION_JOURNAL_BYTES = 16_777_216;
28
43
 
29
44
  function sha256(value) {
30
45
  return createHash("sha256").update(value).digest("hex");
@@ -42,6 +57,149 @@ function sameCoreIdentity(left, right) {
42
57
  ));
43
58
  }
44
59
 
60
+ function validLegacyConfigV1(config) {
61
+ return config?.schemaVersion === "OwlCodaRunKitConfigV1"
62
+ && config?.core?.contractVersion === "0.1"
63
+ && config?.authorizationPolicy === "external_explicit_authority_required";
64
+ }
65
+
66
+ function shellQuote(value) {
67
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
68
+ }
69
+
70
+ function projectRuntimeRepairDetail(issueCode, workspaceRoot, version) {
71
+ return `${issueCode}; install exact owlrunkit@${version} with the project's lockfile-owning package manager, then run npx --no-install owlrunkit bootstrap --workspace ${shellQuote(workspaceRoot)} --exact owlrunkit@${version} --apply`;
72
+ }
73
+
74
+ function projectAdoptionRepairDetail(issueCodes, workspaceRoot, version) {
75
+ return `${issueCodes.join(",")}; run npx --no-install owlrunkit adopt --workspace ${shellQuote(workspaceRoot)} --exact owlrunkit@${version}`;
76
+ }
77
+
78
+ function sameFileIdentity(left, right) {
79
+ return left.dev === right.dev
80
+ && left.ino === right.ino
81
+ && left.size === right.size
82
+ && left.mtimeMs === right.mtimeMs;
83
+ }
84
+
85
+ async function readProjectEvidenceBytes(workspaceRoot, relativePath, {
86
+ maxBytes = MAX_PROJECT_EVIDENCE_BYTES,
87
+ pathInvalidCode = "adoption_evidence_path_invalid",
88
+ tooLargeCode = "adoption_evidence_too_large",
89
+ identityChangedCode = "adoption_evidence_identity_changed",
90
+ } = {}) {
91
+ const root = await realpath(path.resolve(workspaceRoot));
92
+ const target = path.resolve(root, relativePath);
93
+ const relative = path.relative(root, target);
94
+ if (
95
+ relative.length === 0
96
+ || relative === ".."
97
+ || relative.startsWith(`..${path.sep}`)
98
+ || path.isAbsolute(relative)
99
+ ) {
100
+ throw new Error(pathInvalidCode);
101
+ }
102
+ let current = root;
103
+ const segments = relative.split(path.sep);
104
+ for (let index = 0; index < segments.length; index += 1) {
105
+ current = path.join(current, segments[index]);
106
+ const stat = await lstat(current);
107
+ const leaf = index === segments.length - 1;
108
+ if (
109
+ stat.isSymbolicLink()
110
+ || (!leaf && !stat.isDirectory())
111
+ || (leaf && !stat.isFile())
112
+ ) {
113
+ throw new Error(pathInvalidCode);
114
+ }
115
+ }
116
+ if (await realpath(target) !== target) {
117
+ throw new Error(pathInvalidCode);
118
+ }
119
+ const handle = await open(target, "r");
120
+ try {
121
+ const before = await handle.stat();
122
+ if (!before.isFile() || before.size > maxBytes) {
123
+ throw new Error(tooLargeCode);
124
+ }
125
+ const bytes = await handle.readFile();
126
+ const after = await handle.stat();
127
+ const currentStat = await lstat(target);
128
+ if (
129
+ currentStat.isSymbolicLink()
130
+ || !currentStat.isFile()
131
+ || !sameFileIdentity(before, after)
132
+ || !sameFileIdentity(after, currentStat)
133
+ || await realpath(target) !== target
134
+ ) {
135
+ throw new Error(identityChangedCode);
136
+ }
137
+ return { bytes, path: target };
138
+ } finally {
139
+ await handle.close();
140
+ }
141
+ }
142
+
143
+ async function governedProjectFiles(projectCli, {
144
+ configPath,
145
+ configBytes,
146
+ adoptionPath,
147
+ adoptionBytes,
148
+ }) {
149
+ const workspaceRoot = projectCli.workspaceRoot;
150
+ const specs = [
151
+ {
152
+ role: "workspace_package_manifest",
153
+ absolutePath: path.join(workspaceRoot, "package.json"),
154
+ maxBytes: MAX_PROJECT_EVIDENCE_BYTES,
155
+ },
156
+ {
157
+ role: "lockfile",
158
+ absolutePath: path.join(workspaceRoot, projectCli.lockfilePath),
159
+ maxBytes: MAX_PROJECT_LOCKFILE_BYTES,
160
+ },
161
+ {
162
+ role: "installed_package_manifest",
163
+ absolutePath: path.join(projectCli.packageRoot, "package.json"),
164
+ maxBytes: MAX_PROJECT_EVIDENCE_BYTES,
165
+ },
166
+ ];
167
+ const files = [];
168
+ for (const spec of specs) {
169
+ const evidence = await readProjectEvidenceBytes(
170
+ workspaceRoot,
171
+ path.relative(workspaceRoot, spec.absolutePath),
172
+ {
173
+ maxBytes: spec.maxBytes,
174
+ pathInvalidCode: "project_governed_file_invalid",
175
+ tooLargeCode: "project_governed_file_too_large",
176
+ identityChangedCode: "project_governed_file_identity_changed",
177
+ },
178
+ );
179
+ files.push({
180
+ role: spec.role,
181
+ path: evidence.path,
182
+ sha256: `sha256:${sha256(evidence.bytes)}`,
183
+ });
184
+ }
185
+ files.push(
186
+ {
187
+ role: "project_config",
188
+ path: configPath,
189
+ sha256: `sha256:${sha256(configBytes)}`,
190
+ },
191
+ {
192
+ role: "registry_adoption",
193
+ path: adoptionPath,
194
+ sha256: `sha256:${sha256(adoptionBytes)}`,
195
+ },
196
+ );
197
+ return {
198
+ files,
199
+ sha256: `sha256:${sha256(Buffer.from(`${JSON.stringify(files)}\n`))}`,
200
+ };
201
+ }
202
+
45
203
  async function exists(filePath) {
46
204
  try {
47
205
  await lstat(filePath);
@@ -59,6 +217,318 @@ async function atomicWriteBytes(filePath, bytes) {
59
217
  await rename(temporaryPath, filePath);
60
218
  }
61
219
 
220
+ function transactionJournalPath(targetRoot) {
221
+ return `${path.resolve(targetRoot)}.transaction.json`;
222
+ }
223
+
224
+ function transactionDigest(body) {
225
+ return `sha256:${sha256(Buffer.from(`${JSON.stringify(body)}\n`))}`;
226
+ }
227
+
228
+ function boundedJsonBytes(value, maxBytes, tooLargeCode) {
229
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
230
+ if (bytes.length > maxBytes) throw new Error(tooLargeCode);
231
+ return bytes;
232
+ }
233
+
234
+ function transactionEvidenceRoot(transaction) {
235
+ return path.join(
236
+ path.resolve(transaction.archiveRoot),
237
+ "transactions",
238
+ transaction.transactionId,
239
+ );
240
+ }
241
+
242
+ function transactionConfigReferenceIsValid(entry, side, transaction) {
243
+ const base64 = entry[`${side}Base64`];
244
+ const evidencePath = entry[`${side}Path`];
245
+ return typeof entry.configPath === "string"
246
+ && typeof entry[`${side}Sha256`] === "string"
247
+ && (
248
+ (typeof base64 === "string" && evidencePath === undefined)
249
+ || (
250
+ base64 === undefined
251
+ && typeof evidencePath === "string"
252
+ && pathIsStrictChild(transactionEvidenceRoot(transaction), evidencePath)
253
+ )
254
+ );
255
+ }
256
+
257
+ function pathIsStrictChild(root, candidate) {
258
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
259
+ return relative.length > 0
260
+ && relative !== ".."
261
+ && !relative.startsWith(`..${path.sep}`)
262
+ && !path.isAbsolute(relative);
263
+ }
264
+
265
+ async function writeTransactionJournal(journalPath, transaction, {
266
+ exclusive = false,
267
+ } = {}) {
268
+ const body = { ...transaction };
269
+ delete body.transactionSha256;
270
+ const stored = {
271
+ ...body,
272
+ transactionSha256: transactionDigest(body),
273
+ };
274
+ const bytes = boundedJsonBytes(
275
+ stored,
276
+ MAX_TRANSACTION_JOURNAL_BYTES,
277
+ "skill_update_transaction_journal_too_large",
278
+ );
279
+ if (exclusive) {
280
+ const temporaryPath = `${journalPath}.pending-${randomUUID()}`;
281
+ try {
282
+ await writeFile(temporaryPath, bytes, { flag: "wx" });
283
+ await link(temporaryPath, journalPath);
284
+ } finally {
285
+ await rm(temporaryPath, { force: true });
286
+ }
287
+ } else {
288
+ await atomicWriteBytes(journalPath, bytes);
289
+ }
290
+ return stored;
291
+ }
292
+
293
+ async function readTransactionJournal(journalPath, {
294
+ targetRoot,
295
+ archiveRoot,
296
+ } = {}) {
297
+ const stat = await lstat(journalPath);
298
+ if (
299
+ stat.isSymbolicLink()
300
+ || !stat.isFile()
301
+ || stat.size > MAX_TRANSACTION_JOURNAL_BYTES
302
+ ) {
303
+ throw new Error("skill_update_transaction_journal_invalid");
304
+ }
305
+ const transaction = JSON.parse(await readFile(journalPath, "utf8"));
306
+ const body = { ...transaction };
307
+ delete body.transactionSha256;
308
+ const resolvedTarget = path.resolve(targetRoot);
309
+ const resolvedArchive = path.resolve(archiveRoot);
310
+ if (
311
+ transaction.schemaVersion !== TRANSACTION_SCHEMA
312
+ || !new Set(["install", "restore", "restore_locking"]).has(
313
+ transaction.operation,
314
+ )
315
+ || transaction.targetRoot !== resolvedTarget
316
+ || transaction.archiveRoot !== resolvedArchive
317
+ || transaction.authorizationGranted !== false
318
+ || !/^[0-9a-f-]{36}$/u.test(transaction.transactionId ?? "")
319
+ || transaction.transactionSha256 !== transactionDigest(body)
320
+ || !Array.isArray(transaction.projectRoots)
321
+ || transaction.projectRoots.some(root => typeof root !== "string")
322
+ ) {
323
+ throw new Error("skill_update_transaction_journal_invalid");
324
+ }
325
+ if (transaction.operation === "restore_locking") {
326
+ if (transaction.phase !== "locking") {
327
+ throw new Error("skill_update_transaction_journal_invalid");
328
+ }
329
+ } else if (transaction.operation === "install") {
330
+ if (
331
+ !new Set([
332
+ "prepared",
333
+ "locked",
334
+ "prior_archived",
335
+ "candidate_installed",
336
+ "receipt_pending",
337
+ "committed",
338
+ "rolled_back",
339
+ ]).has(transaction.phase)
340
+ || typeof transaction.stagingRoot !== "string"
341
+ || typeof transaction.toManifestSha256 !== "string"
342
+ ) {
343
+ throw new Error("skill_update_transaction_journal_invalid");
344
+ }
345
+ const resolvedStaging = path.resolve(transaction.stagingRoot);
346
+ if (
347
+ path.dirname(resolvedStaging) !== path.dirname(resolvedTarget)
348
+ || !path.basename(resolvedStaging).startsWith(".owlcoda-runkit.install-")
349
+ ) {
350
+ throw new Error("skill_update_transaction_journal_invalid");
351
+ }
352
+ if (transaction.adoptionOnly === true && (
353
+ transaction.priorManaged !== false
354
+ || transaction.priorManifestSha256 !== transaction.toManifestSha256
355
+ || transaction.priorArchivePath !== null
356
+ )) {
357
+ throw new Error("skill_update_transaction_journal_invalid");
358
+ }
359
+ if (transaction.priorManifestSha256 === null) {
360
+ if (transaction.priorArchivePath !== null) {
361
+ throw new Error("skill_update_transaction_journal_invalid");
362
+ }
363
+ } else if (transaction.adoptionOnly !== true && (
364
+ typeof transaction.priorArchivePath !== "string"
365
+ || !pathIsStrictChild(resolvedArchive, transaction.priorArchivePath)
366
+ )) {
367
+ throw new Error("skill_update_transaction_journal_invalid");
368
+ }
369
+ } else if (transaction.operation === "restore" && (
370
+ !new Set([
371
+ "prepared",
372
+ "current_archived",
373
+ "prior_restored",
374
+ "configs_restoring",
375
+ "receipt_pending",
376
+ "committed",
377
+ "rolled_back",
378
+ ]).has(transaction.phase)
379
+ || typeof transaction.fromManifestSha256 !== "string"
380
+ || typeof transaction.toManifestSha256 !== "string"
381
+ || typeof transaction.priorManaged !== "boolean"
382
+ || typeof transaction.priorArchivePath !== "string"
383
+ || !pathIsStrictChild(resolvedArchive, transaction.priorArchivePath)
384
+ || typeof transaction.replacedInstallationArchivePath !== "string"
385
+ || !pathIsStrictChild(
386
+ resolvedArchive,
387
+ transaction.replacedInstallationArchivePath,
388
+ )
389
+ || typeof transaction.sourceUpgradeReceiptPath !== "string"
390
+ || !pathIsStrictChild(
391
+ path.join(resolvedArchive, "receipts"),
392
+ transaction.sourceUpgradeReceiptPath,
393
+ )
394
+ || typeof transaction.sourceUpgradeReceiptSha256 !== "string"
395
+ || !Array.isArray(transaction.legacyConfigRestores)
396
+ || typeof transaction.receiptPath !== "string"
397
+ || typeof transaction.receiptSha256 !== "string"
398
+ )) {
399
+ throw new Error("skill_update_transaction_journal_invalid");
400
+ }
401
+ if (transaction.operation === "restore") {
402
+ const allowedConfigPaths = new Set(transaction.projectRoots.map(root => (
403
+ path.join(path.resolve(root), ".owlcoda/runkit/config.json")
404
+ )));
405
+ for (const entry of transaction.legacyConfigRestores) {
406
+ if (!allowedConfigPaths.has(path.resolve(entry.configPath ?? ""))) {
407
+ throw new Error("skill_update_transaction_journal_invalid");
408
+ }
409
+ if (transaction.phase === "committed") {
410
+ if (
411
+ !transactionConfigReferenceIsValid(entry, "before", transaction)
412
+ || !transactionConfigReferenceIsValid(entry, "after", transaction)
413
+ ) {
414
+ throw new Error("skill_update_transaction_journal_invalid");
415
+ }
416
+ } else {
417
+ await transactionConfigBytes(entry, "before", transaction);
418
+ await transactionConfigBytes(entry, "after", transaction);
419
+ }
420
+ }
421
+ }
422
+ if (transaction.receiptPath != null && (
423
+ !pathIsStrictChild(
424
+ path.join(resolvedArchive, "receipts"),
425
+ transaction.receiptPath,
426
+ )
427
+ || typeof transaction.receiptSha256 !== "string"
428
+ )) {
429
+ throw new Error("skill_update_transaction_journal_invalid");
430
+ }
431
+ return transaction;
432
+ }
433
+
434
+ function processIsAlive(pid) {
435
+ if (!Number.isSafeInteger(pid) || pid <= 0) return true;
436
+ try {
437
+ process.kill(pid, 0);
438
+ return true;
439
+ } catch (error) {
440
+ return error?.code !== "ESRCH";
441
+ }
442
+ }
443
+
444
+ async function readOwnedLock(lockPath) {
445
+ const stat = await lstat(lockPath);
446
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
447
+ throw new Error("skill_update_lock_invalid");
448
+ }
449
+ const ownerPath = stat.isDirectory()
450
+ ? path.join(lockPath, TRANSACTION_OWNER_FILE)
451
+ : lockPath;
452
+ const ownerStat = await lstat(ownerPath);
453
+ if (
454
+ ownerStat.isSymbolicLink()
455
+ || !ownerStat.isFile()
456
+ || ownerStat.size > MAX_PROJECT_EVIDENCE_BYTES
457
+ ) {
458
+ throw new Error("skill_update_lock_invalid");
459
+ }
460
+ return JSON.parse(await readFile(ownerPath, "utf8"));
461
+ }
462
+
463
+ async function acquireOwnedDirectoryLock(lockPath, {
464
+ activeCode,
465
+ transactionId,
466
+ targetRoot,
467
+ recoverStale = false,
468
+ }) {
469
+ const owner = {
470
+ schemaVersion: "OwlCodaRunKitSharedSkillLockOwnerV1",
471
+ transactionId,
472
+ pid: process.pid,
473
+ targetRoot: path.resolve(targetRoot),
474
+ authorizationGranted: false,
475
+ };
476
+ const parent = path.dirname(lockPath);
477
+ const temporaryPath = path.join(
478
+ parent,
479
+ `.${path.basename(lockPath)}.pending-${randomUUID()}`,
480
+ );
481
+ await writeFile(
482
+ temporaryPath,
483
+ `${JSON.stringify(owner, null, 2)}\n`,
484
+ { flag: "wx" },
485
+ );
486
+ const activate = async () => {
487
+ try {
488
+ await link(temporaryPath, lockPath);
489
+ return true;
490
+ } catch (error) {
491
+ if (error?.code !== "EEXIST") throw error;
492
+ return false;
493
+ }
494
+ };
495
+ try {
496
+ if (!await activate()) {
497
+ if (recoverStale) {
498
+ let existing = null;
499
+ try {
500
+ existing = await readOwnedLock(lockPath);
501
+ } catch {
502
+ // Unknown locks are never removed automatically.
503
+ }
504
+ if (
505
+ existing?.schemaVersion === "OwlCodaRunKitSharedSkillLockOwnerV1"
506
+ && existing.targetRoot === path.resolve(targetRoot)
507
+ && existing.authorizationGranted === false
508
+ && !processIsAlive(existing.pid)
509
+ ) {
510
+ await rm(lockPath, { recursive: true, force: true });
511
+ if (!await activate()) throw new Error(activeCode);
512
+ } else {
513
+ throw new Error(activeCode);
514
+ }
515
+ } else {
516
+ throw new Error(activeCode);
517
+ }
518
+ }
519
+ } finally {
520
+ await rm(temporaryPath, { force: true });
521
+ }
522
+ return async () => {
523
+ if (!await exists(lockPath)) return;
524
+ const currentOwner = await readOwnedLock(lockPath);
525
+ if (currentOwner.transactionId !== transactionId) {
526
+ throw new Error("skill_update_lock_owner_changed");
527
+ }
528
+ await rm(lockPath, { recursive: true, force: true });
529
+ };
530
+ }
531
+
62
532
  async function nextAvailablePath(parentRoot, baseName) {
63
533
  for (let attempt = 1; ; attempt += 1) {
64
534
  const suffix = attempt === 1
@@ -89,6 +559,17 @@ async function writeAppendOnlyReceipt(receiptsRoot, baseName, receipt) {
89
559
  }
90
560
  }
91
561
 
562
+ async function nextAppendOnlyReceiptPath(receiptsRoot, baseName) {
563
+ await mkdir(receiptsRoot, { recursive: true });
564
+ for (let attempt = 1; ; attempt += 1) {
565
+ const suffix = attempt === 1
566
+ ? ""
567
+ : `-attempt-${String(attempt).padStart(3, "0")}`;
568
+ const receiptPath = path.join(receiptsRoot, `${baseName}${suffix}.json`);
569
+ if (!await exists(receiptPath)) return receiptPath;
570
+ }
571
+ }
572
+
92
573
  async function fileMap(root, { excludeManifest = true } = {}) {
93
574
  const files = {};
94
575
  async function walk(directory, prefix = "") {
@@ -230,60 +711,958 @@ export async function inspectInstalledSkillCompatibility({ targetRoot } = {}) {
230
711
  `installed_skill_core_version_mismatch:expected_${expectedCore.coreVersion}`
231
712
  + `:skill_${installedSkillVersion}:config_${installedConfigCoreVersion}`,
232
713
  );
233
- } else if (!sameCoreIdentity(installedConfigCore, expectedCore)) {
234
- issues.push(
235
- `installed_skill_core_identity_mismatch:expected_${expectedCore.coreManifestSha256}`
236
- + `:config_${installedConfigCore?.coreManifestSha256 ?? "missing"}`,
714
+ } else if (!sameCoreIdentity(installedConfigCore, expectedCore)) {
715
+ issues.push(
716
+ `installed_skill_core_identity_mismatch:expected_${expectedCore.coreManifestSha256}`
717
+ + `:config_${installedConfigCore?.coreManifestSha256 ?? "missing"}`,
718
+ );
719
+ }
720
+
721
+ return {
722
+ ...integrity,
723
+ status: issues.length === 0 ? "valid" : "version_mismatch",
724
+ valid: issues.length === 0,
725
+ integrityValid: true,
726
+ expectedCoreVersion: expectedCore.coreVersion,
727
+ expectedCore,
728
+ installedSkillVersion,
729
+ installedConfigCoreVersion,
730
+ installedConfigCore,
731
+ issues,
732
+ authorizationGranted: false,
733
+ };
734
+ }
735
+
736
+ async function inspectFleetSnapshot({
737
+ workspaceRoots = null,
738
+ fleetManifestPath = null,
739
+ fleetRoots = null,
740
+ fleetRegistryPath = null,
741
+ } = {}) {
742
+ let discovery;
743
+ try {
744
+ discovery = await discoverFleet({
745
+ workspaceRoots,
746
+ fleetManifestPath,
747
+ fleetRoots,
748
+ fleetRegistryPath,
749
+ });
750
+ } catch (error) {
751
+ if (
752
+ workspaceRoots === null
753
+ && fleetManifestPath === null
754
+ && fleetRoots === null
755
+ && error?.message === "fleet_registry_missing"
756
+ ) {
757
+ throw new Error("skill_update_fleet_registry_missing");
758
+ }
759
+ throw error;
760
+ }
761
+ if (!discovery.complete) {
762
+ const details = discovery.issues
763
+ .map(issue => `${issue.code}:${issue.path}`)
764
+ .join(",");
765
+ throw new Error(
766
+ `skill_update_fleet_discovery_incomplete:${details}`,
767
+ );
768
+ }
769
+ const projects = [];
770
+ for (const workspaceRoot of discovery.workspaceRoots) {
771
+ const inspected = await inspectProjectForSkillChange(workspaceRoot);
772
+ const activeBlockers = inspected.blockers.filter(blocker => (
773
+ blocker.code === "active_execution"
774
+ || blocker.code === "active_lease"
775
+ || blocker.code === "control_state_invalid"
776
+ ));
777
+ if (activeBlockers.length > 0) {
778
+ const detail = activeBlockers
779
+ .map(blocker => blocker.artifactId ?? blocker.detail)
780
+ .join(",");
781
+ throw new Error(
782
+ `skill_update_blocked_active_execution:${workspaceRoot}:${detail}`,
783
+ );
784
+ }
785
+ const configBlocker = inspected.blockers.find(
786
+ blocker => blocker.code === "project_config_invalid",
787
+ );
788
+ if (configBlocker) {
789
+ throw new Error(
790
+ `skill_update_fleet_config_invalid:${workspaceRoot}:${configBlocker.detail}`,
791
+ );
792
+ }
793
+ const runtimeBlocker = inspected.blockers.find(
794
+ blocker => blocker.code === "project_runtime_mismatch",
795
+ );
796
+ if (runtimeBlocker) {
797
+ throw new Error(
798
+ `skill_update_project_runtime_invalid:${workspaceRoot}:${runtimeBlocker.detail}`,
799
+ );
800
+ }
801
+ const adoptionBlocker = inspected.blockers.find(
802
+ blocker => blocker.code === "project_adoption_mismatch",
803
+ );
804
+ if (adoptionBlocker) {
805
+ throw new Error(
806
+ `skill_update_project_adoption_invalid:${workspaceRoot}:${adoptionBlocker.detail}`,
807
+ );
808
+ }
809
+ projects.push(inspected.binding);
810
+ }
811
+ return { discovery, projects };
812
+ }
813
+
814
+ export async function inspectFleetForSkillChange(options = {}) {
815
+ return (await inspectFleetSnapshot(options)).projects;
816
+ }
817
+
818
+ export async function inspectProjectForSkillChange(workspaceRoot) {
819
+ const expectedCore = currentCoreIdentity();
820
+ const blockers = [];
821
+ const control = inspectProjectControlState({ workspaceRoot }).upgradeSafety;
822
+ for (const artifactId of control.activeRunIds) {
823
+ blockers.push({ code: "active_execution", workspaceRoot, artifactId });
824
+ }
825
+ for (const artifactId of control.activeLeaseIds) {
826
+ blockers.push({ code: "active_lease", workspaceRoot, artifactId });
827
+ }
828
+ for (const issue of control.issues) {
829
+ blockers.push({ code: "control_state_invalid", workspaceRoot, detail: issue });
830
+ }
831
+
832
+ const configPath = path.join(workspaceRoot, ".owlcoda/runkit/config.json");
833
+ let configBytes = null;
834
+ let config = null;
835
+ try {
836
+ configBytes = await readFile(configPath);
837
+ config = JSON.parse(configBytes.toString("utf8"));
838
+ } catch (error) {
839
+ blockers.push({
840
+ code: "project_config_invalid",
841
+ workspaceRoot,
842
+ detail: error instanceof SyntaxError
843
+ ? "invalid_json"
844
+ : error instanceof Error ? error.message : String(error),
845
+ });
846
+ }
847
+ if (config !== null) {
848
+ const validation = config.schemaVersion === "OwlCodaRunKitConfigV2"
849
+ ? validateProjectConfigV2(config)
850
+ : null;
851
+ if ((validation !== null && !validation.valid)
852
+ || (validation === null && !validLegacyConfigV1(config))) {
853
+ blockers.push({
854
+ code: "project_config_invalid",
855
+ workspaceRoot,
856
+ detail: validation?.issues?.join(",") ?? "unsupported_config",
857
+ });
858
+ }
859
+ }
860
+
861
+ let projectCli = null;
862
+ let adoptionPath = null;
863
+ let adoptionBytes = null;
864
+ let governedFiles = null;
865
+ if (!blockers.some(blocker => blocker.code === "project_config_invalid")) {
866
+ projectCli = resolveProjectCli({
867
+ workspaceRoot,
868
+ expectedVersion: expectedCore.coreVersion,
869
+ });
870
+ if (projectCli.status !== "bound") {
871
+ const issueCode = projectCli.issueCodes?.join(",")
872
+ || "project_cli_install_binding_mismatch";
873
+ blockers.push({
874
+ code: "project_runtime_mismatch",
875
+ workspaceRoot,
876
+ detail: projectRuntimeRepairDetail(
877
+ issueCode,
878
+ workspaceRoot,
879
+ expectedCore.coreVersion,
880
+ ),
881
+ });
882
+ } else {
883
+ const adoptionRelativePath = path.join(
884
+ ".owlcoda/runkit/adoption",
885
+ `owlrunkit-${expectedCore.coreVersion}.json`,
886
+ );
887
+ let adoption = null;
888
+ try {
889
+ const evidence = await readProjectEvidenceBytes(
890
+ projectCli.workspaceRoot,
891
+ adoptionRelativePath,
892
+ );
893
+ adoptionPath = evidence.path;
894
+ adoptionBytes = evidence.bytes;
895
+ adoption = JSON.parse(adoptionBytes.toString("utf8"));
896
+ } catch (error) {
897
+ blockers.push({
898
+ code: "project_adoption_mismatch",
899
+ workspaceRoot,
900
+ detail: projectAdoptionRepairDetail(
901
+ [error instanceof SyntaxError
902
+ ? "adoption_evidence_invalid_json"
903
+ : error instanceof Error ? error.message : String(error)],
904
+ workspaceRoot,
905
+ expectedCore.coreVersion,
906
+ ),
907
+ });
908
+ }
909
+ if (adoption !== null) {
910
+ const validation = validateStoredRegistryAdoptionEvidence({
911
+ evidence: adoption,
912
+ packageName: "owlrunkit",
913
+ version: expectedCore.coreVersion,
914
+ localInstall: projectCli,
915
+ });
916
+ if (!validation.valid) {
917
+ blockers.push({
918
+ code: "project_adoption_mismatch",
919
+ workspaceRoot,
920
+ detail: projectAdoptionRepairDetail(
921
+ validation.issueCodes,
922
+ workspaceRoot,
923
+ expectedCore.coreVersion,
924
+ ),
925
+ });
926
+ }
927
+ }
928
+ if (!blockers.some(blocker => (
929
+ blocker.code === "project_runtime_mismatch"
930
+ || blocker.code === "project_adoption_mismatch"
931
+ ))) {
932
+ try {
933
+ governedFiles = await governedProjectFiles(projectCli, {
934
+ configPath,
935
+ configBytes,
936
+ adoptionPath,
937
+ adoptionBytes,
938
+ });
939
+ } catch (error) {
940
+ blockers.push({
941
+ code: "project_runtime_mismatch",
942
+ workspaceRoot,
943
+ detail: projectRuntimeRepairDetail(
944
+ error instanceof Error ? error.message : String(error),
945
+ workspaceRoot,
946
+ expectedCore.coreVersion,
947
+ ),
948
+ });
949
+ }
950
+ }
951
+ }
952
+ }
953
+
954
+ const binding = projectCli?.status === "bound"
955
+ && configBytes !== null
956
+ && adoptionBytes !== null
957
+ && governedFiles !== null
958
+ && !blockers.some(blocker => (
959
+ blocker.code === "project_config_invalid"
960
+ || blocker.code === "project_runtime_mismatch"
961
+ || blocker.code === "project_adoption_mismatch"
962
+ ))
963
+ ? {
964
+ workspaceRoot: projectCli.workspaceRoot,
965
+ configPath,
966
+ configSha256: sha256(configBytes),
967
+ packageName: projectCli.packageName,
968
+ packageVersion: projectCli.version,
969
+ packageManager: projectCli.packageManager,
970
+ lockfilePath: path.join(projectCli.workspaceRoot, projectCli.lockfilePath),
971
+ packageRoot: projectCli.packageRoot,
972
+ cliPath: projectCli.cliPath,
973
+ coreVersion: projectCli.installedCoreBinding.coreVersion,
974
+ coreManifestSha256: projectCli.installedCoreBinding.coreManifestSha256,
975
+ adoptionPath,
976
+ adoptionSha256: sha256(adoptionBytes),
977
+ governedFiles: governedFiles.files,
978
+ governedFilesSha256: governedFiles.sha256,
979
+ authorizationGranted: false,
980
+ }
981
+ : null;
982
+ return {
983
+ project: {
984
+ workspaceRoot,
985
+ configPath,
986
+ configCoreVersion: config?.core?.coreVersion ?? null,
987
+ activeRunIds: [...control.activeRunIds],
988
+ activeLeaseIds: [...control.activeLeaseIds],
989
+ upgradeSafe: blockers.length === 0,
990
+ },
991
+ blockers,
992
+ binding,
993
+ };
994
+ }
995
+
996
+ async function acquireProjectControlLocks(projects, {
997
+ transactionId,
998
+ targetRoot,
999
+ }) {
1000
+ const releases = [];
1001
+ try {
1002
+ for (const project of [...projects].sort((left, right) => (
1003
+ left.workspaceRoot < right.workspaceRoot
1004
+ ? -1
1005
+ : left.workspaceRoot > right.workspaceRoot ? 1 : 0
1006
+ ))) {
1007
+ const canonicalWorkspaceRoot = await realpath(
1008
+ path.resolve(project.workspaceRoot),
1009
+ );
1010
+ const runtimeRoot = path.join(
1011
+ canonicalWorkspaceRoot,
1012
+ ".owlcoda/runkit",
1013
+ );
1014
+ const runtimeStat = await lstat(runtimeRoot);
1015
+ if (
1016
+ runtimeStat.isSymbolicLink()
1017
+ || !runtimeStat.isDirectory()
1018
+ || await realpath(runtimeRoot) !== runtimeRoot
1019
+ ) {
1020
+ throw new Error(
1021
+ `skill_update_project_control_root_invalid:${project.workspaceRoot}`,
1022
+ );
1023
+ }
1024
+ releases.push(await acquireOwnedDirectoryLock(
1025
+ path.join(runtimeRoot, "control.lock"),
1026
+ {
1027
+ activeCode:
1028
+ `skill_update_project_control_transaction_active:${project.workspaceRoot}`,
1029
+ transactionId,
1030
+ targetRoot,
1031
+ },
1032
+ ));
1033
+ }
1034
+ } catch (error) {
1035
+ for (const release of releases.reverse()) await release();
1036
+ throw error;
1037
+ }
1038
+ return async () => {
1039
+ for (const release of releases.reverse()) await release();
1040
+ };
1041
+ }
1042
+
1043
+ function sameFleetIdentity(left, right) {
1044
+ return left.discovery.frozenManifestSha256
1045
+ === right.discovery.frozenManifestSha256
1046
+ && left.discovery.source === right.discovery.source
1047
+ && (left.discovery.registrySha256 ?? null)
1048
+ === (right.discovery.registrySha256 ?? null);
1049
+ }
1050
+
1051
+ function usesFleetRegistry(source) {
1052
+ return source.workspaceRoots === null
1053
+ && source.fleetManifestPath === null
1054
+ && source.fleetRoots === null;
1055
+ }
1056
+
1057
+ function fleetRegistryPathFor(source) {
1058
+ return path.resolve(source.fleetRegistryPath ?? defaultFleetRegistryPath());
1059
+ }
1060
+
1061
+ async function acquireFleetRegistryTransactionLock(source, {
1062
+ transactionId,
1063
+ targetRoot,
1064
+ }) {
1065
+ if (!usesFleetRegistry(source)) return null;
1066
+ const registryPath = fleetRegistryPathFor(source);
1067
+ await mkdir(path.dirname(registryPath), { recursive: true });
1068
+ return acquireOwnedDirectoryLock(`${registryPath}.lock`, {
1069
+ activeCode: "skill_update_fleet_registry_transaction_active",
1070
+ transactionId,
1071
+ targetRoot,
1072
+ recoverStale: true,
1073
+ });
1074
+ }
1075
+
1076
+ function fleetBindingFromSnapshot(snapshot) {
1077
+ const { discovery } = snapshot;
1078
+ return {
1079
+ schemaVersion: "OwlCodaRunKitFleetBindingV1",
1080
+ source: discovery.source,
1081
+ frozenManifestSha256: discovery.frozenManifestSha256,
1082
+ registryPath: discovery.registryPath ?? null,
1083
+ registrySha256: discovery.registrySha256 ?? null,
1084
+ manifestPath: discovery.manifestPath ?? null,
1085
+ coverageRoots: [...discovery.coverageRoots],
1086
+ workspaceRoots: [...discovery.workspaceRoots],
1087
+ authorizationGranted: false,
1088
+ };
1089
+ }
1090
+
1091
+ function sourceFromFleetBinding(binding) {
1092
+ if (
1093
+ binding?.schemaVersion !== "OwlCodaRunKitFleetBindingV1"
1094
+ || binding.authorizationGranted !== false
1095
+ || typeof binding.frozenManifestSha256 !== "string"
1096
+ || !Array.isArray(binding.coverageRoots)
1097
+ || !Array.isArray(binding.workspaceRoots)
1098
+ ) {
1099
+ throw new Error("skill_rollback_fleet_binding_invalid");
1100
+ }
1101
+ if (binding.source === "explicit_workspaces") {
1102
+ return {
1103
+ workspaceRoots: binding.workspaceRoots,
1104
+ fleetManifestPath: null,
1105
+ fleetRoots: null,
1106
+ fleetRegistryPath: null,
1107
+ };
1108
+ }
1109
+ if (binding.source === "fleet_manifest" && typeof binding.manifestPath === "string") {
1110
+ return {
1111
+ workspaceRoots: null,
1112
+ fleetManifestPath: binding.manifestPath,
1113
+ fleetRoots: null,
1114
+ fleetRegistryPath: null,
1115
+ };
1116
+ }
1117
+ if (binding.source === "fleet_roots") {
1118
+ return {
1119
+ workspaceRoots: null,
1120
+ fleetManifestPath: null,
1121
+ fleetRoots: binding.coverageRoots,
1122
+ fleetRegistryPath: null,
1123
+ };
1124
+ }
1125
+ if (
1126
+ new Set(["fleet_registry", "fleet_registry_membership"]).has(binding.source)
1127
+ && typeof binding.registryPath === "string"
1128
+ ) {
1129
+ return {
1130
+ workspaceRoots: null,
1131
+ fleetManifestPath: null,
1132
+ fleetRoots: null,
1133
+ fleetRegistryPath: binding.registryPath,
1134
+ };
1135
+ }
1136
+ throw new Error("skill_rollback_fleet_binding_invalid");
1137
+ }
1138
+
1139
+ function sameFleetBinding(binding, snapshot) {
1140
+ const current = fleetBindingFromSnapshot(snapshot);
1141
+ return JSON.stringify(binding) === JSON.stringify(current);
1142
+ }
1143
+
1144
+ function sameProjectBindings(left, right) {
1145
+ if (left.length !== right.length) return false;
1146
+ return left.every((project, index) => {
1147
+ const candidate = right[index];
1148
+ return project.workspaceRoot === candidate.workspaceRoot
1149
+ && project.governedFilesSha256 === candidate.governedFilesSha256;
1150
+ });
1151
+ }
1152
+
1153
+ async function lockFleetForSkillChange(source, {
1154
+ preliminary,
1155
+ transactionId,
1156
+ targetRoot,
1157
+ }) {
1158
+ let releaseProjects = null;
1159
+ try {
1160
+ const frozen = await inspectFleetSnapshot(source);
1161
+ if (!sameFleetIdentity(preliminary, frozen)) {
1162
+ throw new Error("skill_update_fleet_identity_changed");
1163
+ }
1164
+ releaseProjects = await acquireProjectControlLocks(frozen.projects, {
1165
+ transactionId,
1166
+ targetRoot,
1167
+ });
1168
+ const locked = await inspectFleetSnapshot(source);
1169
+ if (
1170
+ !sameFleetIdentity(frozen, locked)
1171
+ || !sameProjectBindings(frozen.projects, locked.projects)
1172
+ ) {
1173
+ throw new Error("skill_update_fleet_identity_changed");
1174
+ }
1175
+ return {
1176
+ snapshot: locked,
1177
+ async release() {
1178
+ await releaseProjects();
1179
+ releaseProjects = null;
1180
+ },
1181
+ };
1182
+ } catch (error) {
1183
+ if (releaseProjects !== null) await releaseProjects();
1184
+ throw error;
1185
+ }
1186
+ }
1187
+
1188
+ async function removeTransactionProjectLocks(transaction) {
1189
+ for (const workspaceRoot of [...transaction.projectRoots].sort().reverse()) {
1190
+ const lockPath = path.join(workspaceRoot, ".owlcoda/runkit/control.lock");
1191
+ if (!await exists(lockPath)) continue;
1192
+ let owner = null;
1193
+ try {
1194
+ owner = await readOwnedLock(lockPath);
1195
+ } catch {
1196
+ continue;
1197
+ }
1198
+ if (
1199
+ owner.schemaVersion !== "OwlCodaRunKitSharedSkillLockOwnerV1"
1200
+ || owner.transactionId !== transaction.transactionId
1201
+ || owner.targetRoot !== transaction.targetRoot
1202
+ || owner.authorizationGranted !== false
1203
+ ) {
1204
+ continue;
1205
+ }
1206
+ await rm(lockPath, { recursive: true, force: true });
1207
+ }
1208
+ }
1209
+
1210
+ async function inspectExpectedInstallation(root, expectedManifestSha256, {
1211
+ allowUnmanaged = false,
1212
+ } = {}) {
1213
+ if (!await exists(root)) return { status: "missing" };
1214
+ const inspected = await inspectInstalledSkill({ targetRoot: root });
1215
+ const identityValid = inspected.manifestSha256 === expectedManifestSha256
1216
+ && (inspected.valid || (allowUnmanaged && inspected.status === "unmanaged"));
1217
+ if (!identityValid) {
1218
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1219
+ }
1220
+ return inspected;
1221
+ }
1222
+
1223
+ async function transactionReceiptIsCommitted(transaction) {
1224
+ if (transaction.receiptPath === null) {
1225
+ return transaction.phase === "committed"
1226
+ && (
1227
+ transaction.operation === "restore"
1228
+ || transaction.priorManifestSha256 === null
1229
+ || transaction.adoptionOnly === true
1230
+ );
1231
+ }
1232
+ if (!await exists(transaction.receiptPath)) return false;
1233
+ const bytes = await readFile(transaction.receiptPath);
1234
+ if (`sha256:${sha256(bytes)}` !== transaction.receiptSha256) return false;
1235
+ let receipt;
1236
+ try {
1237
+ receipt = JSON.parse(bytes.toString("utf8"));
1238
+ } catch {
1239
+ return false;
1240
+ }
1241
+ if (transaction.operation === "install") {
1242
+ return receipt.schemaVersion === "OwlCodaRunKitSkillUpgradeReceiptV2"
1243
+ && receipt.operation === "upgrade"
1244
+ && receipt.targetRoot === transaction.targetRoot
1245
+ && receipt.archiveRoot === transaction.archiveRoot
1246
+ && receipt.fromManifestSha256 === transaction.priorManifestSha256
1247
+ && receipt.toManifestSha256 === transaction.toManifestSha256
1248
+ && receipt.priorArchivePath === transaction.priorArchivePath
1249
+ && receipt.priorManaged === (transaction.priorManaged !== false)
1250
+ && receipt.projectMutationPerformed === false
1251
+ && receipt.authorizationGranted === false;
1252
+ }
1253
+ return new Set([
1254
+ "OwlCodaRunKitSkillRollbackReceiptV1",
1255
+ "OwlCodaRunKitSkillRollbackReceiptV2",
1256
+ ]).has(receipt.schemaVersion)
1257
+ && receipt.operation === "rollback"
1258
+ && receipt.sourceUpgradeReceiptPath === transaction.sourceUpgradeReceiptPath
1259
+ && `sha256:${receipt.sourceUpgradeReceiptSha256}`
1260
+ === transaction.sourceUpgradeReceiptSha256
1261
+ && receipt.targetRoot === transaction.targetRoot
1262
+ && receipt.archiveRoot === transaction.archiveRoot
1263
+ && receipt.fromManifestSha256 === transaction.fromManifestSha256
1264
+ && receipt.toManifestSha256 === transaction.toManifestSha256
1265
+ && receipt.replacedInstallationArchivePath
1266
+ === transaction.replacedInstallationArchivePath
1267
+ && receipt.authorizationGranted === false;
1268
+ }
1269
+
1270
+ async function cleanRecoveredTransaction(transaction, journalPath) {
1271
+ await removeTransactionProjectLocks(transaction);
1272
+ await rm(journalPath, { force: true });
1273
+ await rm(transactionEvidenceRoot(transaction), {
1274
+ recursive: true,
1275
+ force: true,
1276
+ });
1277
+ }
1278
+
1279
+ async function recoverInterruptedInstallTransaction(
1280
+ transaction,
1281
+ { resolvedTarget, resolvedArchive, journalPath },
1282
+ ) {
1283
+ if (transaction.phase === "rolled_back") {
1284
+ await cleanRecoveredTransaction(transaction, journalPath);
1285
+ return {
1286
+ status: "recovered_rolled_back_transaction",
1287
+ transactionId: transaction.transactionId,
1288
+ authorizationGranted: false,
1289
+ };
1290
+ }
1291
+ const priorManaged = transaction.priorManaged !== false;
1292
+ if (transaction.adoptionOnly === true) {
1293
+ const target = await inspectExpectedInstallation(
1294
+ resolvedTarget,
1295
+ transaction.toManifestSha256,
1296
+ { allowUnmanaged: true },
1297
+ );
1298
+ const committed = target.valid
1299
+ && await transactionReceiptIsCommitted(transaction);
1300
+ if (target.status === "missing") {
1301
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1302
+ }
1303
+ if (!committed && target.valid) {
1304
+ await rm(path.join(resolvedTarget, MANIFEST_FILE), { force: true });
1305
+ await inspectExpectedInstallation(
1306
+ resolvedTarget,
1307
+ transaction.priorManifestSha256,
1308
+ { allowUnmanaged: true },
1309
+ );
1310
+ }
1311
+ if (await exists(transaction.stagingRoot)) {
1312
+ await inspectExpectedInstallation(
1313
+ transaction.stagingRoot,
1314
+ transaction.toManifestSha256,
1315
+ );
1316
+ await rm(transaction.stagingRoot, { recursive: true, force: true });
1317
+ }
1318
+ if (!committed) {
1319
+ transaction = await writeTransactionJournal(journalPath, {
1320
+ ...transaction,
1321
+ phase: "rolled_back",
1322
+ });
1323
+ }
1324
+ await cleanRecoveredTransaction(transaction, journalPath);
1325
+ return {
1326
+ status: committed
1327
+ ? "recovered_committed_transaction"
1328
+ : "recovered_rolled_back_transaction",
1329
+ transactionId: transaction.transactionId,
1330
+ authorizationGranted: false,
1331
+ };
1332
+ }
1333
+
1334
+ const target = await inspectExpectedInstallation(
1335
+ resolvedTarget,
1336
+ transaction.toManifestSha256,
1337
+ ).catch(async (error) => {
1338
+ if (transaction.priorManifestSha256 !== null) {
1339
+ const prior = await inspectExpectedInstallation(
1340
+ resolvedTarget,
1341
+ transaction.priorManifestSha256,
1342
+ { allowUnmanaged: !priorManaged },
1343
+ ).catch(() => null);
1344
+ if (prior !== null) return prior;
1345
+ }
1346
+ throw error;
1347
+ });
1348
+ const committed = target.status !== "missing"
1349
+ && target.manifestSha256 === transaction.toManifestSha256
1350
+ && await transactionReceiptIsCommitted(transaction);
1351
+ if (committed) {
1352
+ if (transaction.priorManifestSha256 !== null) {
1353
+ await inspectExpectedInstallation(
1354
+ transaction.priorArchivePath,
1355
+ transaction.priorManifestSha256,
1356
+ { allowUnmanaged: !priorManaged },
1357
+ );
1358
+ }
1359
+ if (await exists(transaction.stagingRoot)) {
1360
+ await inspectExpectedInstallation(
1361
+ transaction.stagingRoot,
1362
+ transaction.toManifestSha256,
1363
+ );
1364
+ await rm(transaction.stagingRoot, { recursive: true, force: true });
1365
+ }
1366
+ await cleanRecoveredTransaction(transaction, journalPath);
1367
+ return {
1368
+ status: "recovered_committed_transaction",
1369
+ transactionId: transaction.transactionId,
1370
+ authorizationGranted: false,
1371
+ };
1372
+ }
1373
+
1374
+ await mkdir(resolvedArchive, { recursive: true });
1375
+ if (transaction.priorManifestSha256 === null) {
1376
+ if (target.status !== "missing") {
1377
+ if (target.manifestSha256 !== transaction.toManifestSha256) {
1378
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1379
+ }
1380
+ const failedArchivePath = await nextAvailablePath(
1381
+ resolvedArchive,
1382
+ `owlcoda-runkit-failed-${transaction.toManifestSha256.slice(7)}`,
1383
+ );
1384
+ await rename(resolvedTarget, failedArchivePath);
1385
+ }
1386
+ } else {
1387
+ const priorArchive = await inspectExpectedInstallation(
1388
+ transaction.priorArchivePath,
1389
+ transaction.priorManifestSha256,
1390
+ { allowUnmanaged: !priorManaged },
1391
+ );
1392
+ if (
1393
+ target.status !== "missing"
1394
+ && target.manifestSha256 === transaction.priorManifestSha256
1395
+ ) {
1396
+ if (priorArchive.status !== "missing") {
1397
+ throw new Error("skill_update_transaction_prior_identity_duplicated");
1398
+ }
1399
+ } else {
1400
+ if (priorArchive.status === "missing") {
1401
+ throw new Error("skill_update_transaction_prior_identity_missing");
1402
+ }
1403
+ if (target.status !== "missing") {
1404
+ if (target.manifestSha256 !== transaction.toManifestSha256) {
1405
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1406
+ }
1407
+ const failedArchivePath = await nextAvailablePath(
1408
+ resolvedArchive,
1409
+ `owlcoda-runkit-failed-${transaction.toManifestSha256.slice(7)}`,
1410
+ );
1411
+ await rename(resolvedTarget, failedArchivePath);
1412
+ }
1413
+ await rename(transaction.priorArchivePath, resolvedTarget);
1414
+ }
1415
+ }
1416
+ if (await exists(transaction.stagingRoot)) {
1417
+ await inspectExpectedInstallation(
1418
+ transaction.stagingRoot,
1419
+ transaction.toManifestSha256,
1420
+ );
1421
+ await rm(transaction.stagingRoot, { recursive: true, force: true });
1422
+ }
1423
+ transaction = await writeTransactionJournal(journalPath, {
1424
+ ...transaction,
1425
+ phase: "rolled_back",
1426
+ });
1427
+ await cleanRecoveredTransaction(transaction, journalPath);
1428
+ return {
1429
+ status: "recovered_rolled_back_transaction",
1430
+ transactionId: transaction.transactionId,
1431
+ authorizationGranted: false,
1432
+ };
1433
+ }
1434
+
1435
+ async function transactionConfigBytes(entry, side, transaction) {
1436
+ const base64 = entry[`${side}Base64`];
1437
+ const evidencePath = entry[`${side}Path`];
1438
+ const expectedSha256 = entry[`${side}Sha256`];
1439
+ if (
1440
+ typeof entry.configPath !== "string"
1441
+ || typeof expectedSha256 !== "string"
1442
+ ) {
1443
+ throw new Error("skill_update_transaction_journal_invalid");
1444
+ }
1445
+ let bytes;
1446
+ if (typeof base64 === "string" && evidencePath === undefined) {
1447
+ bytes = Buffer.from(base64, "base64");
1448
+ if (bytes.length > MAX_PROJECT_EVIDENCE_BYTES) {
1449
+ throw new Error("skill_update_transaction_journal_invalid");
1450
+ }
1451
+ } else if (
1452
+ base64 === undefined
1453
+ && typeof evidencePath === "string"
1454
+ && transaction !== undefined
1455
+ && pathIsStrictChild(transactionEvidenceRoot(transaction), evidencePath)
1456
+ ) {
1457
+ const root = transactionEvidenceRoot(transaction);
1458
+ const evidence = await readProjectEvidenceBytes(
1459
+ root,
1460
+ path.relative(root, evidencePath),
1461
+ {
1462
+ maxBytes: MAX_PROJECT_EVIDENCE_BYTES,
1463
+ pathInvalidCode: "skill_update_transaction_journal_invalid",
1464
+ tooLargeCode: "skill_update_transaction_journal_invalid",
1465
+ identityChangedCode: "skill_update_transaction_journal_invalid",
1466
+ },
1467
+ );
1468
+ bytes = evidence.bytes;
1469
+ } else {
1470
+ throw new Error("skill_update_transaction_journal_invalid");
1471
+ }
1472
+ if (`sha256:${sha256(bytes)}` !== expectedSha256) {
1473
+ throw new Error("skill_update_transaction_journal_invalid");
1474
+ }
1475
+ return bytes;
1476
+ }
1477
+
1478
+ async function writeLegacyConfigRestoreEvidence(transaction, restores) {
1479
+ for (const restore of restores) {
1480
+ if (
1481
+ restore.beforeBytes.length > MAX_PROJECT_EVIDENCE_BYTES
1482
+ || restore.afterBytes.length > MAX_PROJECT_EVIDENCE_BYTES
1483
+ ) {
1484
+ throw new Error("skill_rollback_legacy_config_too_large");
1485
+ }
1486
+ }
1487
+ const root = transactionEvidenceRoot(transaction);
1488
+ await mkdir(path.dirname(root), { recursive: true });
1489
+ await mkdir(root);
1490
+ const entries = [];
1491
+ for (const [index, restore] of restores.entries()) {
1492
+ const stem = String(index).padStart(3, "0");
1493
+ const beforePath = path.join(root, `${stem}-before.json`);
1494
+ const afterPath = path.join(root, `${stem}-after.json`);
1495
+ await writeFile(beforePath, restore.beforeBytes, { flag: "wx" });
1496
+ await writeFile(afterPath, restore.afterBytes, { flag: "wx" });
1497
+ entries.push({
1498
+ configPath: restore.configPath,
1499
+ beforeSha256: `sha256:${sha256(restore.beforeBytes)}`,
1500
+ beforePath,
1501
+ afterSha256: `sha256:${sha256(restore.afterBytes)}`,
1502
+ afterPath,
1503
+ });
1504
+ }
1505
+ return entries;
1506
+ }
1507
+
1508
+ async function maybeInspectExpectedInstallation(root, expectedManifestSha256, options) {
1509
+ try {
1510
+ const inspected = await inspectExpectedInstallation(
1511
+ root,
1512
+ expectedManifestSha256,
1513
+ options,
1514
+ );
1515
+ return inspected.status === "missing" ? null : inspected;
1516
+ } catch {
1517
+ return null;
1518
+ }
1519
+ }
1520
+
1521
+ async function recoverInterruptedRestoreTransaction(
1522
+ transaction,
1523
+ { resolvedTarget, journalPath },
1524
+ ) {
1525
+ if (transaction.phase === "rolled_back") {
1526
+ await cleanRecoveredTransaction(transaction, journalPath);
1527
+ return {
1528
+ status: "recovered_rolled_back_transaction",
1529
+ transactionId: transaction.transactionId,
1530
+ authorizationGranted: false,
1531
+ };
1532
+ }
1533
+ const sourceReceiptBytes = await readFile(transaction.sourceUpgradeReceiptPath);
1534
+ if (`sha256:${sha256(sourceReceiptBytes)}` !== transaction.sourceUpgradeReceiptSha256) {
1535
+ throw new Error("skill_update_transaction_source_receipt_identity_mismatch");
1536
+ }
1537
+ const targetPrior = await maybeInspectExpectedInstallation(
1538
+ resolvedTarget,
1539
+ transaction.toManifestSha256,
1540
+ { allowUnmanaged: !transaction.priorManaged },
1541
+ );
1542
+ const targetCurrent = targetPrior === null
1543
+ ? await maybeInspectExpectedInstallation(
1544
+ resolvedTarget,
1545
+ transaction.fromManifestSha256,
1546
+ )
1547
+ : null;
1548
+ if (await exists(resolvedTarget) && targetPrior === null && targetCurrent === null) {
1549
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1550
+ }
1551
+ const committed = targetPrior !== null
1552
+ && await transactionReceiptIsCommitted(transaction);
1553
+ if (committed) {
1554
+ const priorArchive = await maybeInspectExpectedInstallation(
1555
+ transaction.priorArchivePath,
1556
+ transaction.toManifestSha256,
1557
+ { allowUnmanaged: !transaction.priorManaged },
1558
+ );
1559
+ const replaced = await maybeInspectExpectedInstallation(
1560
+ transaction.replacedInstallationArchivePath,
1561
+ transaction.fromManifestSha256,
237
1562
  );
1563
+ if (priorArchive !== null || replaced === null) {
1564
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1565
+ }
1566
+ for (const entry of transaction.legacyConfigRestores) {
1567
+ const actual = await readFile(entry.configPath);
1568
+ if (`sha256:${sha256(actual)}` !== entry.afterSha256) {
1569
+ throw new Error("skill_update_transaction_config_identity_mismatch");
1570
+ }
1571
+ }
1572
+ await cleanRecoveredTransaction(transaction, journalPath);
1573
+ return {
1574
+ status: "recovered_committed_transaction",
1575
+ operation: "restore",
1576
+ transactionId: transaction.transactionId,
1577
+ manifestSha256: transaction.toManifestSha256,
1578
+ rollbackReceipt: transaction.receiptPath,
1579
+ replacedInstallation: {
1580
+ manifestSha256: transaction.fromManifestSha256,
1581
+ archivePath: transaction.replacedInstallationArchivePath,
1582
+ },
1583
+ projectMutationPerformed: transaction.legacyConfigRestores.length > 0,
1584
+ authorizationGranted: false,
1585
+ };
238
1586
  }
239
1587
 
1588
+ const priorArchive = await maybeInspectExpectedInstallation(
1589
+ transaction.priorArchivePath,
1590
+ transaction.toManifestSha256,
1591
+ { allowUnmanaged: !transaction.priorManaged },
1592
+ );
1593
+ const replaced = await maybeInspectExpectedInstallation(
1594
+ transaction.replacedInstallationArchivePath,
1595
+ transaction.fromManifestSha256,
1596
+ );
1597
+ if (targetPrior !== null) {
1598
+ if (priorArchive !== null) {
1599
+ throw new Error("skill_update_transaction_prior_identity_duplicated");
1600
+ }
1601
+ await rename(resolvedTarget, transaction.priorArchivePath);
1602
+ } else if (priorArchive === null) {
1603
+ throw new Error("skill_update_transaction_prior_identity_missing");
1604
+ }
1605
+ if (targetCurrent === null) {
1606
+ if (replaced === null) {
1607
+ throw new Error("skill_update_transaction_installation_identity_mismatch");
1608
+ }
1609
+ await rename(transaction.replacedInstallationArchivePath, resolvedTarget);
1610
+ } else if (replaced !== null) {
1611
+ throw new Error("skill_update_transaction_installation_identity_duplicated");
1612
+ }
1613
+ for (const entry of transaction.legacyConfigRestores) {
1614
+ await atomicWriteBytes(
1615
+ entry.configPath,
1616
+ await transactionConfigBytes(entry, "before", transaction),
1617
+ );
1618
+ }
1619
+ transaction = await writeTransactionJournal(journalPath, {
1620
+ ...transaction,
1621
+ phase: "rolled_back",
1622
+ });
1623
+ await cleanRecoveredTransaction(transaction, journalPath);
240
1624
  return {
241
- ...integrity,
242
- status: issues.length === 0 ? "valid" : "version_mismatch",
243
- valid: issues.length === 0,
244
- integrityValid: true,
245
- expectedCoreVersion: expectedCore.coreVersion,
246
- expectedCore,
247
- installedSkillVersion,
248
- installedConfigCoreVersion,
249
- installedConfigCore,
250
- issues,
1625
+ status: "recovered_rolled_back_transaction",
1626
+ transactionId: transaction.transactionId,
251
1627
  authorizationGranted: false,
252
1628
  };
253
1629
  }
254
1630
 
255
- export async function inspectFleetForSkillChange({
256
- workspaceRoots = null,
257
- fleetManifestPath = null,
258
- fleetRoots = null,
259
- fleetRegistryPath = null,
1631
+ export async function recoverInterruptedCodexSkillTransaction({
1632
+ targetRoot,
1633
+ archiveRoot,
260
1634
  } = {}) {
261
- let discovery;
262
- try {
263
- discovery = await discoverFleet({
264
- workspaceRoots,
265
- fleetManifestPath,
266
- fleetRoots,
267
- fleetRegistryPath,
268
- });
269
- } catch (error) {
270
- if (
271
- workspaceRoots === null
272
- && fleetManifestPath === null
273
- && fleetRoots === null
274
- && error?.message === "fleet_registry_missing"
275
- ) {
276
- throw new Error("skill_update_fleet_registry_missing");
277
- }
278
- throw error;
1635
+ if (typeof targetRoot !== "string" || typeof archiveRoot !== "string") {
1636
+ throw new Error("targetRoot and archiveRoot are required");
279
1637
  }
280
- if (!discovery.complete) {
281
- throw new Error(
282
- `skill_update_fleet_discovery_incomplete:${discovery.unreachableRoots.join(",")}`,
283
- );
1638
+ const resolvedTarget = path.resolve(targetRoot);
1639
+ const resolvedArchive = path.resolve(archiveRoot);
1640
+ const journalPath = transactionJournalPath(resolvedTarget);
1641
+ if (!await exists(journalPath)) {
1642
+ return { status: "no_pending_transaction", authorizationGranted: false };
1643
+ }
1644
+ const transaction = await readTransactionJournal(journalPath, {
1645
+ targetRoot: resolvedTarget,
1646
+ archiveRoot: resolvedArchive,
1647
+ });
1648
+ const context = { resolvedTarget, resolvedArchive, journalPath };
1649
+ if (transaction.operation === "restore_locking") {
1650
+ await cleanRecoveredTransaction(transaction, journalPath);
1651
+ return {
1652
+ status: "recovered_rolled_back_transaction",
1653
+ operation: "restore_locking",
1654
+ transactionId: transaction.transactionId,
1655
+ authorizationGranted: false,
1656
+ };
284
1657
  }
1658
+ return transaction.operation === "install"
1659
+ ? recoverInterruptedInstallTransaction(transaction, context)
1660
+ : recoverInterruptedRestoreTransaction(transaction, context);
1661
+ }
1662
+
1663
+ async function inspectLegacyProjectsForRollback(workspaceRoots) {
285
1664
  const projects = [];
286
- for (const workspaceRoot of discovery.workspaceRoots) {
1665
+ for (const workspaceRoot of workspaceRoots) {
287
1666
  const safety = inspectProjectControlState({ workspaceRoot }).upgradeSafety;
288
1667
  if (safety.status !== "safe") {
289
1668
  const blockers = [
@@ -295,10 +1674,7 @@ export async function inspectFleetForSkillChange({
295
1674
  `skill_update_blocked_active_execution:${workspaceRoot}:${blockers}`,
296
1675
  );
297
1676
  }
298
- const configPath = path.join(
299
- workspaceRoot,
300
- ".owlcoda/runkit/config.json",
301
- );
1677
+ const configPath = path.join(workspaceRoot, ".owlcoda/runkit/config.json");
302
1678
  const configBytes = await readFile(configPath);
303
1679
  let config;
304
1680
  try {
@@ -306,19 +1682,13 @@ export async function inspectFleetForSkillChange({
306
1682
  } catch {
307
1683
  throw new Error(`skill_update_fleet_config_invalid:${workspaceRoot}:invalid_json`);
308
1684
  }
309
- const configV2 = config?.schemaVersion === "OwlCodaRunKitConfigV2"
1685
+ const validation = config?.schemaVersion === "OwlCodaRunKitConfigV2"
310
1686
  ? validateProjectConfigV2(config)
311
1687
  : null;
312
- const supportedV1 = config?.schemaVersion === "OwlCodaRunKitConfigV1"
313
- && config?.core?.contractVersion === "0.1"
314
- && config?.authorizationPolicy === "external_explicit_authority_required";
315
- if (
316
- (configV2 !== null && !configV2.valid)
317
- || (configV2 === null && !supportedV1)
318
- ) {
319
- const issues = configV2?.issues?.join(",") ?? "unsupported_config";
1688
+ if ((validation !== null && !validation.valid)
1689
+ || (validation === null && !validLegacyConfigV1(config))) {
320
1690
  throw new Error(
321
- `skill_update_fleet_config_invalid:${workspaceRoot}:${issues}`,
1691
+ `skill_update_fleet_config_invalid:${workspaceRoot}:${validation?.issues?.join(",") ?? "unsupported_config"}`,
322
1692
  );
323
1693
  }
324
1694
  projects.push({
@@ -331,12 +1701,6 @@ export async function inspectFleetForSkillChange({
331
1701
  return projects;
332
1702
  }
333
1703
 
334
- async function restoreProjectConfigs(projects) {
335
- for (const project of projects) {
336
- await atomicWriteBytes(project.configPath, project.configBytes);
337
- }
338
- }
339
-
340
1704
  export async function installCodexSkill({
341
1705
  repositoryRoot,
342
1706
  targetRoot,
@@ -350,119 +1714,307 @@ export async function installCodexSkill({
350
1714
  if (typeof repositoryRoot !== "string" || typeof targetRoot !== "string" || typeof archiveRoot !== "string") {
351
1715
  throw new Error("repositoryRoot, targetRoot, and archiveRoot are required");
352
1716
  }
353
- const parent = path.dirname(targetRoot);
1717
+ const resolvedTargetRoot = path.resolve(targetRoot);
1718
+ const resolvedArchiveRoot = path.resolve(archiveRoot);
1719
+ const resolvedRepositoryRoot = path.resolve(repositoryRoot);
1720
+ const source = {
1721
+ workspaceRoots,
1722
+ fleetManifestPath,
1723
+ fleetRoots,
1724
+ fleetRegistryPath,
1725
+ };
1726
+ const parent = path.dirname(resolvedTargetRoot);
354
1727
  await mkdir(parent, { recursive: true });
355
- const stagingRoot = await mkdtemp(path.join(parent, ".owlcoda-runkit.install-"));
1728
+ const transactionId = randomUUID();
1729
+ const releaseSkillLock = await acquireOwnedDirectoryLock(
1730
+ `${resolvedTargetRoot}.lock`,
1731
+ {
1732
+ activeCode: "skill_update_shared_skill_transaction_active",
1733
+ transactionId,
1734
+ targetRoot: resolvedTargetRoot,
1735
+ recoverStale: true,
1736
+ },
1737
+ );
1738
+ let stagingRoot = null;
356
1739
  let stagingPresent = true;
1740
+ let fleetLock = null;
1741
+ let releaseRegistryLock = null;
1742
+ let transaction = null;
1743
+ const journalPath = transactionJournalPath(resolvedTargetRoot);
357
1744
  try {
358
- const desired = await assemble({ repositoryRoot: path.resolve(repositoryRoot), stagingRoot });
359
- let prior = await inspectInstalledSkill({ targetRoot });
360
- if (prior.status === "missing") {
361
- await rename(stagingRoot, targetRoot);
362
- stagingPresent = false;
363
- return { status: "installed", manifestSha256: desired.manifestSha256, previousInstallation: null };
364
- }
1745
+ releaseRegistryLock = await acquireFleetRegistryTransactionLock(source, {
1746
+ transactionId,
1747
+ targetRoot: resolvedTargetRoot,
1748
+ });
1749
+ await recoverInterruptedCodexSkillTransaction({
1750
+ targetRoot: resolvedTargetRoot,
1751
+ archiveRoot: resolvedArchiveRoot,
1752
+ });
1753
+ stagingRoot = await mkdtemp(path.join(parent, ".owlcoda-runkit.install-"));
1754
+ const desired = await assemble({
1755
+ repositoryRoot: resolvedRepositoryRoot,
1756
+ stagingRoot,
1757
+ });
1758
+ const prior = await inspectInstalledSkill({ targetRoot: resolvedTargetRoot });
1759
+ const explicitFleetSource = (
1760
+ workspaceRoots !== null
1761
+ || fleetManifestPath !== null
1762
+ || fleetRoots !== null
1763
+ || fleetRegistryPath !== null
1764
+ );
1765
+ const registeredFleetExists = await exists(fleetRegistryPathFor(source));
365
1766
  if (prior.status === "unmanaged") {
366
1767
  if (prior.manifestSha256 === desired.manifestSha256) {
367
- await writeFile(path.join(targetRoot, MANIFEST_FILE), `${JSON.stringify(desired, null, 2)}\n`, { flag: "wx" });
1768
+ const preliminary = explicitFleetSource || registeredFleetExists
1769
+ ? await inspectFleetSnapshot(source)
1770
+ : null;
1771
+ transaction = await writeTransactionJournal(journalPath, {
1772
+ schemaVersion: TRANSACTION_SCHEMA,
1773
+ transactionId,
1774
+ operation: "install",
1775
+ phase: "prepared",
1776
+ targetRoot: resolvedTargetRoot,
1777
+ archiveRoot: resolvedArchiveRoot,
1778
+ stagingRoot,
1779
+ priorManifestSha256: prior.manifestSha256,
1780
+ priorArchivePath: null,
1781
+ priorManaged: false,
1782
+ adoptionOnly: true,
1783
+ toManifestSha256: desired.manifestSha256,
1784
+ projectRoots: preliminary?.projects.map(project => project.workspaceRoot) ?? [],
1785
+ receiptPath: null,
1786
+ receiptSha256: null,
1787
+ authorizationGranted: false,
1788
+ }, { exclusive: true });
1789
+ if (preliminary !== null) {
1790
+ fleetLock = await lockFleetForSkillChange(source, {
1791
+ preliminary,
1792
+ transactionId,
1793
+ targetRoot: resolvedTargetRoot,
1794
+ });
1795
+ }
1796
+ transaction = await writeTransactionJournal(journalPath, {
1797
+ ...transaction,
1798
+ phase: "locked",
1799
+ });
1800
+ await replaceManifestAtomically(resolvedTargetRoot, desired);
1801
+ transaction = await writeTransactionJournal(journalPath, {
1802
+ ...transaction,
1803
+ phase: "candidate_installed",
1804
+ });
1805
+ if (fleetLock !== null) {
1806
+ const postflight = await inspectFleetSnapshot(source);
1807
+ if (
1808
+ !sameFleetIdentity(fleetLock.snapshot, postflight)
1809
+ || !sameProjectBindings(fleetLock.snapshot.projects, postflight.projects)
1810
+ ) {
1811
+ throw new Error("skill_update_fleet_postflight_changed");
1812
+ }
1813
+ }
1814
+ transaction = await writeTransactionJournal(journalPath, {
1815
+ ...transaction,
1816
+ phase: "committed",
1817
+ });
1818
+ if (fleetLock !== null) {
1819
+ await fleetLock.release();
1820
+ fleetLock = null;
1821
+ }
1822
+ await rm(journalPath, { force: true });
1823
+ transaction = null;
368
1824
  return { status: "adopted", manifestSha256: desired.manifestSha256, previousInstallation: null };
369
1825
  }
370
1826
  if (prior.manifestSha256 !== expectedUnmanagedManifestSha256) {
371
1827
  throw new Error("installed_skill_drift: unmanaged installation differs from authoritative source and expected prior manifest");
372
1828
  }
373
- prior = { ...prior, valid: true };
374
1829
  }
375
- if (!prior.valid) throw new Error(`installed_skill_drift: ${prior.issues.join("; ")}`);
376
- if (prior.manifestSha256 === desired.manifestSha256) {
377
- if (prior.manifest.sourceRepository !== desired.sourceRepository) {
378
- await replaceManifestAtomically(targetRoot, desired);
1830
+ if (
1831
+ prior.status !== "missing"
1832
+ && prior.status !== "unmanaged"
1833
+ && !prior.valid
1834
+ ) {
1835
+ throw new Error(`installed_skill_drift: ${prior.issues.join("; ")}`);
1836
+ }
1837
+ if (
1838
+ prior.status !== "missing"
1839
+ && prior.manifestSha256 === desired.manifestSha256
1840
+ ) {
1841
+ if (prior.manifest?.sourceRepository !== desired.sourceRepository) {
1842
+ await replaceManifestAtomically(resolvedTargetRoot, desired);
379
1843
  return { status: "provenance_updated", manifestSha256: desired.manifestSha256, previousInstallation: null };
380
1844
  }
381
1845
  return { status: "unchanged", manifestSha256: desired.manifestSha256, previousInstallation: null };
382
1846
  }
383
- const projects = await inspectFleetForSkillChange({
384
- workspaceRoots,
385
- fleetManifestPath,
386
- fleetRoots,
387
- fleetRegistryPath,
1847
+ const shouldInspectFleet = prior.status !== "missing"
1848
+ || explicitFleetSource
1849
+ || registeredFleetExists;
1850
+ const preliminary = shouldInspectFleet
1851
+ ? await inspectFleetSnapshot(source)
1852
+ : null;
1853
+ let archivePath = null;
1854
+ if (prior.status !== "missing") {
1855
+ await mkdir(resolvedArchiveRoot, { recursive: true });
1856
+ archivePath = await nextAvailablePath(
1857
+ resolvedArchiveRoot,
1858
+ `owlcoda-runkit-${prior.manifestSha256.slice(7)}`,
1859
+ );
1860
+ }
1861
+ transaction = await writeTransactionJournal(journalPath, {
1862
+ schemaVersion: TRANSACTION_SCHEMA,
1863
+ transactionId,
1864
+ operation: "install",
1865
+ phase: "prepared",
1866
+ targetRoot: resolvedTargetRoot,
1867
+ archiveRoot: resolvedArchiveRoot,
1868
+ stagingRoot,
1869
+ priorManifestSha256: prior.status === "missing"
1870
+ ? null
1871
+ : prior.manifestSha256,
1872
+ priorArchivePath: archivePath,
1873
+ priorManaged: prior.status !== "unmanaged",
1874
+ adoptionOnly: false,
1875
+ toManifestSha256: desired.manifestSha256,
1876
+ projectRoots: preliminary?.projects.map(project => project.workspaceRoot) ?? [],
1877
+ receiptPath: null,
1878
+ receiptSha256: null,
1879
+ authorizationGranted: false,
1880
+ }, { exclusive: true });
1881
+ if (preliminary !== null) {
1882
+ fleetLock = await lockFleetForSkillChange(source, {
1883
+ preliminary,
1884
+ transactionId,
1885
+ targetRoot: resolvedTargetRoot,
1886
+ });
1887
+ }
1888
+ transaction = await writeTransactionJournal(journalPath, {
1889
+ ...transaction,
1890
+ phase: "locked",
388
1891
  });
389
- await mkdir(archiveRoot, { recursive: true });
390
- const archivePath = await nextAvailablePath(
391
- archiveRoot,
392
- `owlcoda-runkit-${prior.manifestSha256.slice(7)}`,
393
- );
394
- await rename(targetRoot, archivePath);
395
- try {
396
- await rename(stagingRoot, targetRoot);
397
- stagingPresent = false;
398
- const projectMigrations = [];
399
- for (const project of projects) {
400
- const initialized = await initializeProjectRunKit({
401
- workspaceRoot: project.workspaceRoot,
402
- });
403
- if (initialized.exitCode !== 0) {
404
- throw new Error(
405
- `skill_update_project_migration_blocked:${project.workspaceRoot}:${initialized.status}`,
406
- );
407
- }
408
- const currentConfigBytes = await readFile(project.configPath);
409
- projectMigrations.push({
410
- workspaceRoot: project.workspaceRoot,
411
- configPath: project.configPath,
412
- fromConfigSha256: project.configSha256,
413
- fromConfigBase64: project.configBytes.toString("base64"),
414
- toConfigSha256: sha256(currentConfigBytes),
415
- migrationReceipt: initialized.migrationReceipt ?? null,
416
- });
417
- }
418
- const receipt = {
419
- schemaVersion: "OwlCodaRunKitSkillUpgradeReceiptV1",
1892
+ let upgradeReceipt = null;
1893
+ let upgradeReceiptBytes = null;
1894
+ let projectBindings = [];
1895
+ if (archivePath !== null) {
1896
+ projectBindings = fleetLock.snapshot.projects.map(project => ({
1897
+ ...project,
1898
+ postflightConfigSha256: project.configSha256,
1899
+ postflightAdoptionSha256: project.adoptionSha256,
1900
+ postflightGovernedFiles: project.governedFiles,
1901
+ postflightGovernedFilesSha256: project.governedFilesSha256,
1902
+ }));
1903
+ upgradeReceiptBytes = boundedJsonBytes({
1904
+ schemaVersion: "OwlCodaRunKitSkillUpgradeReceiptV2",
420
1905
  operation: "upgrade",
421
- targetRoot: path.resolve(targetRoot),
422
- archiveRoot: path.resolve(archiveRoot),
1906
+ targetRoot: resolvedTargetRoot,
1907
+ archiveRoot: resolvedArchiveRoot,
423
1908
  fromManifestSha256: prior.manifestSha256,
424
1909
  toManifestSha256: desired.manifestSha256,
425
1910
  priorArchivePath: archivePath,
426
- projects: projectMigrations,
1911
+ priorManaged: prior.status !== "unmanaged",
1912
+ fleetBinding: fleetBindingFromSnapshot(fleetLock.snapshot),
1913
+ projects: projectBindings,
1914
+ projectMutationPerformed: false,
427
1915
  authorizationGranted: false,
428
- };
429
- const upgradeReceipt = await writeAppendOnlyReceipt(
430
- path.join(archiveRoot, "receipts"),
1916
+ }, MAX_V2_SKILL_RECEIPT_BYTES, "skill_update_receipt_too_large");
1917
+ upgradeReceipt = await nextAppendOnlyReceiptPath(
1918
+ path.join(resolvedArchiveRoot, "receipts"),
431
1919
  `skill-upgrade-${prior.manifestSha256.slice(7)}-to-${desired.manifestSha256.slice(7)}`,
432
- receipt,
433
1920
  );
1921
+ }
1922
+ if (archivePath !== null) {
1923
+ await rename(resolvedTargetRoot, archivePath);
1924
+ transaction = await writeTransactionJournal(journalPath, {
1925
+ ...transaction,
1926
+ phase: "prior_archived",
1927
+ });
1928
+ }
1929
+ await rename(stagingRoot, resolvedTargetRoot);
1930
+ stagingPresent = false;
1931
+ transaction = await writeTransactionJournal(journalPath, {
1932
+ ...transaction,
1933
+ phase: "candidate_installed",
1934
+ });
1935
+ let postflight = null;
1936
+ if (fleetLock !== null) {
1937
+ postflight = await inspectFleetSnapshot(source);
1938
+ if (
1939
+ !sameFleetIdentity(fleetLock.snapshot, postflight)
1940
+ || !sameProjectBindings(
1941
+ fleetLock.snapshot.projects,
1942
+ postflight.projects,
1943
+ )
1944
+ ) {
1945
+ throw new Error("skill_update_fleet_postflight_changed");
1946
+ }
1947
+ }
1948
+
1949
+ if (archivePath !== null) {
1950
+ transaction = await writeTransactionJournal(journalPath, {
1951
+ ...transaction,
1952
+ phase: "receipt_pending",
1953
+ receiptPath: upgradeReceipt,
1954
+ receiptSha256: `sha256:${sha256(upgradeReceiptBytes)}`,
1955
+ });
1956
+ await writeFile(upgradeReceipt, upgradeReceiptBytes, { flag: "wx" });
1957
+ }
1958
+ transaction = await writeTransactionJournal(journalPath, {
1959
+ ...transaction,
1960
+ phase: "committed",
1961
+ });
1962
+ if (fleetLock !== null) {
1963
+ await fleetLock.release();
1964
+ fleetLock = null;
1965
+ }
1966
+ await rm(journalPath, { force: true });
1967
+ transaction = null;
1968
+ if (archivePath === null) {
434
1969
  return {
435
- status: "updated",
1970
+ status: "installed",
436
1971
  manifestSha256: desired.manifestSha256,
437
- previousInstallation: {
438
- manifestSha256: prior.manifestSha256,
439
- archivePath,
440
- },
441
- upgradeReceipt,
442
- projectMigrations,
1972
+ previousInstallation: null,
1973
+ projectMutationPerformed: false,
443
1974
  };
444
- } catch (error) {
445
- if (await exists(targetRoot)) {
446
- const failedArchivePath = await nextAvailablePath(
447
- archiveRoot,
448
- `owlcoda-runkit-failed-${desired.manifestSha256.slice(7)}`,
1975
+ }
1976
+ return {
1977
+ status: "updated",
1978
+ manifestSha256: desired.manifestSha256,
1979
+ previousInstallation: {
1980
+ manifestSha256: prior.manifestSha256,
1981
+ archivePath,
1982
+ },
1983
+ upgradeReceipt,
1984
+ projectBindings,
1985
+ projectMutationPerformed: false,
1986
+ };
1987
+ } catch (error) {
1988
+ if (transaction !== null || await exists(journalPath)) {
1989
+ try {
1990
+ await recoverInterruptedCodexSkillTransaction({
1991
+ targetRoot: resolvedTargetRoot,
1992
+ archiveRoot: resolvedArchiveRoot,
1993
+ });
1994
+ transaction = null;
1995
+ } catch (recoveryError) {
1996
+ throw new Error(
1997
+ `${error instanceof Error ? error.message : String(error)}; `
1998
+ + `skill_update_transaction_recovery_failed:${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
449
1999
  );
450
- await rename(targetRoot, failedArchivePath);
451
2000
  }
452
- await rename(archivePath, targetRoot);
453
- await restoreProjectConfigs(projects);
454
- throw error;
455
2001
  }
2002
+ throw error;
456
2003
  } finally {
457
- if (stagingPresent) await rm(stagingRoot, { recursive: true, force: true });
2004
+ if (fleetLock !== null) await fleetLock.release();
2005
+ if (stagingPresent && stagingRoot !== null) {
2006
+ await rm(stagingRoot, { recursive: true, force: true });
2007
+ }
2008
+ if (releaseRegistryLock !== null) await releaseRegistryLock();
2009
+ await releaseSkillLock();
458
2010
  }
459
2011
  }
460
2012
 
461
- export async function restoreCodexSkill({
2013
+ async function readUpgradeReceiptForRestore({
462
2014
  targetRoot,
463
2015
  archiveRoot,
464
2016
  upgradeReceiptPath,
465
- } = {}) {
2017
+ }) {
466
2018
  if (
467
2019
  typeof targetRoot !== "string"
468
2020
  || typeof archiveRoot !== "string"
@@ -484,19 +2036,37 @@ export async function restoreCodexSkill({
484
2036
  ) {
485
2037
  throw new Error("skill_rollback_receipt_outside_archive");
486
2038
  }
2039
+ const receiptStat = await lstat(resolvedReceipt);
2040
+ if (
2041
+ receiptStat.isSymbolicLink()
2042
+ || !receiptStat.isFile()
2043
+ || receiptStat.size > MAX_SKILL_RECEIPT_BYTES
2044
+ ) {
2045
+ throw new Error("skill_rollback_receipt_too_large");
2046
+ }
487
2047
  const receiptBytes = await readFile(resolvedReceipt);
488
2048
  const receipt = JSON.parse(receiptBytes.toString("utf8"));
2049
+ const legacyReceipt = receipt.schemaVersion === "OwlCodaRunKitSkillUpgradeReceiptV1";
2050
+ const boundReceipt = receipt.schemaVersion === "OwlCodaRunKitSkillUpgradeReceiptV2";
489
2051
  if (
490
- receipt.schemaVersion !== "OwlCodaRunKitSkillUpgradeReceiptV1"
2052
+ (!legacyReceipt && !boundReceipt)
491
2053
  || receipt.operation !== "upgrade"
492
2054
  || receipt.targetRoot !== resolvedTarget
493
2055
  || receipt.archiveRoot !== resolvedArchive
494
2056
  || receipt.authorizationGranted !== false
495
2057
  || !Array.isArray(receipt.projects)
496
- || receipt.projects.length === 0
2058
+ || receipt.projects.length > MAX_FLEET_PROJECTS
2059
+ || (legacyReceipt && receipt.projects.length === 0)
2060
+ || (boundReceipt && receipt.projectMutationPerformed !== false)
497
2061
  ) {
498
2062
  throw new Error("skill_rollback_receipt_invalid");
499
2063
  }
2064
+ if (boundReceipt) {
2065
+ if (receiptBytes.length > MAX_V2_SKILL_RECEIPT_BYTES) {
2066
+ throw new Error("skill_rollback_receipt_too_large");
2067
+ }
2068
+ sourceFromFleetBinding(receipt.fleetBinding);
2069
+ }
500
2070
  const resolvedPriorArchive = path.resolve(receipt.priorArchivePath);
501
2071
  const priorArchiveRelative = path.relative(
502
2072
  resolvedArchive,
@@ -512,71 +2082,328 @@ export async function restoreCodexSkill({
512
2082
  ) {
513
2083
  throw new Error("skill_rollback_prior_archive_outside_root");
514
2084
  }
2085
+ if (legacyReceipt) {
2086
+ for (const project of receipt.projects) {
2087
+ project.workspaceRoot = await realpath(path.resolve(project.workspaceRoot));
2088
+ project.configPath = await realpath(path.resolve(project.configPath));
2089
+ }
2090
+ }
2091
+ return {
2092
+ receipt,
2093
+ receiptBytes,
2094
+ legacyReceipt,
2095
+ boundReceipt,
2096
+ resolvedTarget,
2097
+ resolvedArchive,
2098
+ resolvedReceipt,
2099
+ resolvedPriorArchive,
2100
+ };
2101
+ }
2102
+
2103
+ function restoreFleetSource(receipt, options) {
2104
+ if (receipt.schemaVersion === "OwlCodaRunKitSkillUpgradeReceiptV2") {
2105
+ return sourceFromFleetBinding(receipt.fleetBinding);
2106
+ }
2107
+ const source = {
2108
+ workspaceRoots: options.workspaceRoots ?? null,
2109
+ fleetManifestPath: options.fleetManifestPath ?? null,
2110
+ fleetRoots: options.fleetRoots ?? null,
2111
+ fleetRegistryPath: options.fleetRegistryPath ?? null,
2112
+ };
2113
+ if (
2114
+ source.workspaceRoots === null
2115
+ && source.fleetManifestPath === null
2116
+ && source.fleetRoots === null
2117
+ && source.fleetRegistryPath === null
2118
+ ) {
2119
+ throw new Error("skill_rollback_current_fleet_source_required");
2120
+ }
2121
+ return source;
2122
+ }
2123
+
2124
+ async function discoverLegacyFleetForRestore(source) {
2125
+ const discovery = await discoverFleet(source);
2126
+ if (!discovery.complete) {
2127
+ const detail = discovery.issues
2128
+ .map(issue => `${issue.code}:${issue.path}`)
2129
+ .join(",");
2130
+ throw new Error(`skill_update_fleet_discovery_incomplete:${detail}`);
2131
+ }
2132
+ return discovery;
2133
+ }
2134
+
2135
+ function sameWorkspaceRoots(left, right) {
2136
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
2137
+ }
2138
+
2139
+ async function restoreCodexSkillUnlocked({
2140
+ receiptRecord,
2141
+ fleetSource,
2142
+ transactionId,
2143
+ } = {}) {
2144
+ const {
2145
+ receipt,
2146
+ receiptBytes,
2147
+ legacyReceipt,
2148
+ boundReceipt,
2149
+ resolvedTarget,
2150
+ resolvedArchive,
2151
+ resolvedReceipt,
2152
+ resolvedPriorArchive,
2153
+ } = receiptRecord;
515
2154
  const current = await inspectInstalledSkill({ targetRoot: resolvedTarget });
516
2155
  if (!current.valid || current.manifestSha256 !== receipt.toManifestSha256) {
517
2156
  throw new Error("skill_rollback_current_identity_mismatch");
518
2157
  }
519
- const prior = await inspectInstalledSkill({
520
- targetRoot: resolvedPriorArchive,
521
- });
522
- if (!prior.valid || prior.manifestSha256 !== receipt.fromManifestSha256) {
2158
+ const priorManaged = receipt.priorManaged !== false;
2159
+ const prior = await inspectExpectedInstallation(
2160
+ resolvedPriorArchive,
2161
+ receipt.fromManifestSha256,
2162
+ { allowUnmanaged: !priorManaged },
2163
+ ).catch(() => null);
2164
+ if (prior === null) {
523
2165
  throw new Error("skill_rollback_prior_identity_mismatch");
524
2166
  }
525
- const workspaceRoots = receipt.projects.map((project) => project.workspaceRoot);
526
- const currentProjects = await inspectFleetForSkillChange({ workspaceRoots });
527
- const receiptProjectByRoot = new Map(
528
- receipt.projects.map((project) => [project.workspaceRoot, project]),
529
- );
530
- for (const project of currentProjects) {
531
- const expected = receiptProjectByRoot.get(project.workspaceRoot);
2167
+ let currentSnapshot = null;
2168
+ let currentProjects;
2169
+ if (boundReceipt) {
2170
+ currentSnapshot = await inspectFleetSnapshot(fleetSource);
532
2171
  if (
533
- !expected
534
- || expected.configPath !== project.configPath
535
- || expected.toConfigSha256 !== project.configSha256
536
- || typeof expected.fromConfigBase64 !== "string"
537
- || sha256(Buffer.from(expected.fromConfigBase64, "base64"))
538
- !== expected.fromConfigSha256
2172
+ receipt.fleetBinding !== undefined
2173
+ && !sameFleetBinding(receipt.fleetBinding, currentSnapshot)
539
2174
  ) {
540
- throw new Error(
541
- `skill_rollback_project_identity_mismatch:${project.workspaceRoot}`,
542
- );
2175
+ throw new Error("skill_rollback_fleet_identity_changed");
2176
+ }
2177
+ if (!sameWorkspaceRoots(
2178
+ currentSnapshot.discovery.workspaceRoots,
2179
+ receipt.projects.map(project => project.workspaceRoot),
2180
+ )) {
2181
+ throw new Error("skill_rollback_fleet_identity_changed");
543
2182
  }
2183
+ currentProjects = currentSnapshot.projects;
2184
+ } else {
2185
+ const discovery = await discoverLegacyFleetForRestore(fleetSource);
2186
+ if (!sameWorkspaceRoots(
2187
+ discovery.workspaceRoots,
2188
+ receipt.projects.map(project => project.workspaceRoot),
2189
+ )) {
2190
+ throw new Error("skill_rollback_fleet_identity_changed");
2191
+ }
2192
+ currentProjects = await inspectLegacyProjectsForRollback(
2193
+ discovery.workspaceRoots,
2194
+ );
544
2195
  }
545
- const currentArchivePath = await nextAvailablePath(
546
- resolvedArchive,
547
- `owlcoda-runkit-${current.manifestSha256.slice(7)}`,
548
- );
549
- await rename(resolvedTarget, currentArchivePath);
2196
+ const journalPath = transactionJournalPath(resolvedTarget);
2197
+ let transaction = await writeTransactionJournal(journalPath, {
2198
+ schemaVersion: TRANSACTION_SCHEMA,
2199
+ transactionId,
2200
+ operation: "restore_locking",
2201
+ phase: "locking",
2202
+ targetRoot: resolvedTarget,
2203
+ archiveRoot: resolvedArchive,
2204
+ projectRoots: currentProjects.map(project => project.workspaceRoot),
2205
+ authorizationGranted: false,
2206
+ }, { exclusive: true });
2207
+ let releaseProjectLocks = null;
550
2208
  try {
551
- await rename(resolvedPriorArchive, resolvedTarget);
2209
+ releaseProjectLocks = await acquireProjectControlLocks(
2210
+ currentProjects,
2211
+ { transactionId, targetRoot: resolvedTarget },
2212
+ );
2213
+ let lockedSnapshot = null;
2214
+ let lockedProjects;
2215
+ if (boundReceipt) {
2216
+ lockedSnapshot = await inspectFleetSnapshot(fleetSource);
2217
+ if (
2218
+ (receipt.fleetBinding !== undefined
2219
+ && !sameFleetBinding(receipt.fleetBinding, lockedSnapshot))
2220
+ || !sameFleetIdentity(currentSnapshot, lockedSnapshot)
2221
+ ) {
2222
+ throw new Error("skill_rollback_fleet_identity_changed");
2223
+ }
2224
+ lockedProjects = lockedSnapshot.projects;
2225
+ } else {
2226
+ const lockedDiscovery = await discoverLegacyFleetForRestore(fleetSource);
2227
+ if (!sameWorkspaceRoots(
2228
+ lockedDiscovery.workspaceRoots,
2229
+ currentProjects.map(project => project.workspaceRoot),
2230
+ )) {
2231
+ throw new Error("skill_rollback_fleet_identity_changed");
2232
+ }
2233
+ lockedProjects = await inspectLegacyProjectsForRollback(
2234
+ lockedDiscovery.workspaceRoots,
2235
+ );
2236
+ }
2237
+ const lockedIdentityValid = boundReceipt
2238
+ ? sameProjectBindings(currentProjects, lockedProjects)
2239
+ : currentProjects.every((project, index) => (
2240
+ project.workspaceRoot === lockedProjects[index]?.workspaceRoot
2241
+ && project.configSha256 === lockedProjects[index]?.configSha256
2242
+ ));
2243
+ if (!lockedIdentityValid) {
2244
+ throw new Error("skill_rollback_project_identity_changed");
2245
+ }
2246
+ currentProjects = lockedProjects;
2247
+ const receiptProjectByRoot = new Map(
2248
+ receipt.projects.map((project) => [project.workspaceRoot, project]),
2249
+ );
2250
+ const legacyConfigRestoreBytes = [];
552
2251
  for (const project of currentProjects) {
553
2252
  const expected = receiptProjectByRoot.get(project.workspaceRoot);
554
- await atomicWriteBytes(
555
- project.configPath,
556
- Buffer.from(expected.fromConfigBase64, "base64"),
557
- );
2253
+ const legacyAfterBytes = legacyReceipt
2254
+ && typeof expected?.fromConfigBase64 === "string"
2255
+ ? Buffer.from(expected.fromConfigBase64, "base64")
2256
+ : null;
2257
+ const legacyIdentityValid = legacyReceipt
2258
+ && expected?.configPath === project.configPath
2259
+ && expected.toConfigSha256 === project.configSha256
2260
+ && legacyAfterBytes !== null
2261
+ && sha256(legacyAfterBytes) === expected.fromConfigSha256;
2262
+ const boundIdentityValid = boundReceipt
2263
+ && expected?.configPath === project.configPath
2264
+ && expected.configSha256 === project.configSha256
2265
+ && expected.postflightConfigSha256 === project.configSha256
2266
+ && expected.packageName === project.packageName
2267
+ && expected.packageVersion === project.packageVersion
2268
+ && expected.packageManager === project.packageManager
2269
+ && expected.lockfilePath === project.lockfilePath
2270
+ && expected.packageRoot === project.packageRoot
2271
+ && expected.cliPath === project.cliPath
2272
+ && expected.coreVersion === project.coreVersion
2273
+ && expected.coreManifestSha256 === project.coreManifestSha256
2274
+ && expected.adoptionPath === project.adoptionPath
2275
+ && expected.adoptionSha256 === project.adoptionSha256
2276
+ && expected.postflightAdoptionSha256 === project.adoptionSha256
2277
+ && expected.governedFilesSha256 === project.governedFilesSha256
2278
+ && expected.postflightGovernedFilesSha256
2279
+ === project.governedFilesSha256
2280
+ && JSON.stringify(expected.governedFiles)
2281
+ === JSON.stringify(project.governedFiles)
2282
+ && JSON.stringify(expected.postflightGovernedFiles)
2283
+ === JSON.stringify(project.governedFiles)
2284
+ && expected.authorizationGranted === false;
2285
+ if (!legacyIdentityValid && !boundIdentityValid) {
2286
+ throw new Error(
2287
+ `skill_rollback_project_identity_mismatch:${project.workspaceRoot}`,
2288
+ );
2289
+ }
2290
+ if (legacyIdentityValid) {
2291
+ legacyConfigRestoreBytes.push({
2292
+ configPath: project.configPath,
2293
+ beforeBytes: project.configBytes,
2294
+ afterBytes: legacyAfterBytes,
2295
+ });
2296
+ }
558
2297
  }
559
- const rollbackReceipt = await writeAppendOnlyReceipt(
2298
+ const legacyConfigRestores = legacyReceipt
2299
+ ? await writeLegacyConfigRestoreEvidence({
2300
+ archiveRoot: resolvedArchive,
2301
+ transactionId,
2302
+ }, legacyConfigRestoreBytes)
2303
+ : [];
2304
+ const currentArchivePath = await nextAvailablePath(
2305
+ resolvedArchive,
2306
+ `owlcoda-runkit-${current.manifestSha256.slice(7)}`,
2307
+ );
2308
+ const rollbackReceiptBody = {
2309
+ schemaVersion: legacyReceipt
2310
+ ? "OwlCodaRunKitSkillRollbackReceiptV1"
2311
+ : "OwlCodaRunKitSkillRollbackReceiptV2",
2312
+ operation: "rollback",
2313
+ sourceUpgradeReceiptPath: resolvedReceipt,
2314
+ sourceUpgradeReceiptSha256: sha256(receiptBytes),
2315
+ targetRoot: resolvedTarget,
2316
+ archiveRoot: resolvedArchive,
2317
+ fromManifestSha256: receipt.toManifestSha256,
2318
+ toManifestSha256: receipt.fromManifestSha256,
2319
+ restoredArchivePath: resolvedPriorArchive,
2320
+ replacedInstallationArchivePath: currentArchivePath,
2321
+ priorManaged,
2322
+ ...(boundReceipt && receipt.fleetBinding !== undefined
2323
+ ? { fleetBinding: receipt.fleetBinding }
2324
+ : {}),
2325
+ projects: receipt.projects.map((project) => legacyReceipt
2326
+ ? {
2327
+ workspaceRoot: project.workspaceRoot,
2328
+ restoredConfigSha256: project.fromConfigSha256,
2329
+ }
2330
+ : {
2331
+ workspaceRoot: project.workspaceRoot,
2332
+ configSha256: project.configSha256,
2333
+ adoptionSha256: project.adoptionSha256,
2334
+ governedFilesSha256: project.governedFilesSha256,
2335
+ }),
2336
+ ...(boundReceipt ? { projectMutationPerformed: false } : {}),
2337
+ authorizationGranted: false,
2338
+ };
2339
+ const rollbackReceiptBytes = boundedJsonBytes(
2340
+ rollbackReceiptBody,
2341
+ MAX_V2_SKILL_RECEIPT_BYTES,
2342
+ "skill_rollback_receipt_too_large",
2343
+ );
2344
+ const rollbackReceipt = await nextAppendOnlyReceiptPath(
560
2345
  path.join(resolvedArchive, "receipts"),
561
2346
  `skill-rollback-${receipt.toManifestSha256.slice(7)}-to-${receipt.fromManifestSha256.slice(7)}`,
562
- {
563
- schemaVersion: "OwlCodaRunKitSkillRollbackReceiptV1",
564
- operation: "rollback",
565
- sourceUpgradeReceiptPath: resolvedReceipt,
566
- sourceUpgradeReceiptSha256: sha256(receiptBytes),
567
- targetRoot: resolvedTarget,
568
- archiveRoot: resolvedArchive,
569
- fromManifestSha256: receipt.toManifestSha256,
570
- toManifestSha256: receipt.fromManifestSha256,
571
- restoredArchivePath: resolvedPriorArchive,
572
- replacedInstallationArchivePath: currentArchivePath,
573
- projects: receipt.projects.map((project) => ({
574
- workspaceRoot: project.workspaceRoot,
575
- restoredConfigSha256: project.fromConfigSha256,
576
- })),
577
- authorizationGranted: false,
578
- },
579
2347
  );
2348
+ transaction = await writeTransactionJournal(journalPath, {
2349
+ schemaVersion: TRANSACTION_SCHEMA,
2350
+ transactionId,
2351
+ operation: "restore",
2352
+ phase: "prepared",
2353
+ targetRoot: resolvedTarget,
2354
+ archiveRoot: resolvedArchive,
2355
+ fromManifestSha256: receipt.toManifestSha256,
2356
+ toManifestSha256: receipt.fromManifestSha256,
2357
+ priorArchivePath: resolvedPriorArchive,
2358
+ replacedInstallationArchivePath: currentArchivePath,
2359
+ priorManaged,
2360
+ sourceUpgradeReceiptPath: resolvedReceipt,
2361
+ sourceUpgradeReceiptSha256: `sha256:${sha256(receiptBytes)}`,
2362
+ projectRoots: currentProjects.map(project => project.workspaceRoot),
2363
+ legacyConfigRestores,
2364
+ receiptPath: rollbackReceipt,
2365
+ receiptSha256: `sha256:${sha256(rollbackReceiptBytes)}`,
2366
+ authorizationGranted: false,
2367
+ });
2368
+ await rename(resolvedTarget, currentArchivePath);
2369
+ transaction = await writeTransactionJournal(journalPath, {
2370
+ ...transaction,
2371
+ phase: "current_archived",
2372
+ });
2373
+ await rename(resolvedPriorArchive, resolvedTarget);
2374
+ transaction = await writeTransactionJournal(journalPath, {
2375
+ ...transaction,
2376
+ phase: "prior_restored",
2377
+ });
2378
+ if (legacyReceipt) {
2379
+ transaction = await writeTransactionJournal(journalPath, {
2380
+ ...transaction,
2381
+ phase: "configs_restoring",
2382
+ });
2383
+ for (const entry of legacyConfigRestores) {
2384
+ await atomicWriteBytes(
2385
+ entry.configPath,
2386
+ await transactionConfigBytes(entry, "after", transaction),
2387
+ );
2388
+ }
2389
+ }
2390
+ transaction = await writeTransactionJournal(journalPath, {
2391
+ ...transaction,
2392
+ phase: "receipt_pending",
2393
+ });
2394
+ await writeFile(rollbackReceipt, rollbackReceiptBytes, { flag: "wx" });
2395
+ transaction = await writeTransactionJournal(journalPath, {
2396
+ ...transaction,
2397
+ phase: "committed",
2398
+ });
2399
+ await releaseProjectLocks();
2400
+ releaseProjectLocks = null;
2401
+ await rm(transactionEvidenceRoot(transaction), {
2402
+ recursive: true,
2403
+ force: true,
2404
+ });
2405
+ await rm(journalPath, { force: true });
2406
+ transaction = null;
580
2407
  return {
581
2408
  status: "restored",
582
2409
  manifestSha256: receipt.fromManifestSha256,
@@ -585,15 +2412,85 @@ export async function restoreCodexSkill({
585
2412
  manifestSha256: receipt.toManifestSha256,
586
2413
  archivePath: currentArchivePath,
587
2414
  },
2415
+ projectMutationPerformed: legacyReceipt,
588
2416
  authorizationGranted: false,
589
2417
  };
590
2418
  } catch (error) {
591
- if (await exists(resolvedTarget)) {
592
- await rename(resolvedTarget, resolvedPriorArchive);
2419
+ if (transaction !== null || await exists(journalPath)) {
2420
+ try {
2421
+ await recoverInterruptedCodexSkillTransaction({
2422
+ targetRoot: resolvedTarget,
2423
+ archiveRoot: resolvedArchive,
2424
+ });
2425
+ transaction = null;
2426
+ } catch (recoveryError) {
2427
+ throw new Error(
2428
+ `${error instanceof Error ? error.message : String(error)}; `
2429
+ + `skill_update_transaction_recovery_failed:${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
2430
+ );
2431
+ }
593
2432
  }
594
- await rename(currentArchivePath, resolvedTarget);
595
- await restoreProjectConfigs(currentProjects);
596
2433
  throw error;
2434
+ } finally {
2435
+ if (releaseProjectLocks !== null) await releaseProjectLocks();
2436
+ }
2437
+ }
2438
+
2439
+ export async function restoreCodexSkill(options = {}) {
2440
+ const { targetRoot, archiveRoot, upgradeReceiptPath } = options;
2441
+ if (typeof targetRoot !== "string" || typeof archiveRoot !== "string") {
2442
+ throw new Error("targetRoot and archiveRoot are required");
2443
+ }
2444
+ const resolvedTarget = path.resolve(targetRoot);
2445
+ await mkdir(path.dirname(resolvedTarget), { recursive: true });
2446
+ const transactionId = randomUUID();
2447
+ const releaseSkillLock = await acquireOwnedDirectoryLock(
2448
+ `${resolvedTarget}.lock`,
2449
+ {
2450
+ activeCode: "skill_update_shared_skill_transaction_active",
2451
+ transactionId,
2452
+ targetRoot: resolvedTarget,
2453
+ recoverStale: true,
2454
+ },
2455
+ );
2456
+ let releaseRegistryLock = null;
2457
+ try {
2458
+ const receiptRecord = await readUpgradeReceiptForRestore({
2459
+ targetRoot,
2460
+ archiveRoot,
2461
+ upgradeReceiptPath,
2462
+ });
2463
+ const fleetSource = restoreFleetSource(receiptRecord.receipt, options);
2464
+ releaseRegistryLock = await acquireFleetRegistryTransactionLock(fleetSource, {
2465
+ transactionId,
2466
+ targetRoot: resolvedTarget,
2467
+ });
2468
+ const recovered = await recoverInterruptedCodexSkillTransaction({
2469
+ targetRoot: resolvedTarget,
2470
+ archiveRoot,
2471
+ });
2472
+ if (
2473
+ recovered.status === "recovered_committed_transaction"
2474
+ && recovered.operation === "restore"
2475
+ ) {
2476
+ return {
2477
+ status: "restored",
2478
+ manifestSha256: recovered.manifestSha256,
2479
+ rollbackReceipt: recovered.rollbackReceipt,
2480
+ replacedInstallation: recovered.replacedInstallation,
2481
+ projectMutationPerformed: recovered.projectMutationPerformed,
2482
+ recoveredTransaction: true,
2483
+ authorizationGranted: false,
2484
+ };
2485
+ }
2486
+ return await restoreCodexSkillUnlocked({
2487
+ receiptRecord,
2488
+ fleetSource,
2489
+ transactionId,
2490
+ });
2491
+ } finally {
2492
+ if (releaseRegistryLock !== null) await releaseRegistryLock();
2493
+ await releaseSkillLock();
597
2494
  }
598
2495
  }
599
2496
 
@@ -799,6 +2696,10 @@ export async function runCli(argv = process.argv.slice(2)) {
799
2696
  targetRoot: options.target,
800
2697
  archiveRoot: options.archive,
801
2698
  upgradeReceiptPath: options.receipt,
2699
+ workspaceRoots: options.workspace ?? null,
2700
+ fleetManifestPath: options["fleet-manifest"] ?? null,
2701
+ fleetRoots: options["fleet-root"] ?? null,
2702
+ fleetRegistryPath: options.registry ?? null,
802
2703
  });
803
2704
  return { exitCode: 0, ...output };
804
2705
  }