akm-cli 0.9.0-rc.7 → 0.9.0-rc.9

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.
@@ -7,13 +7,13 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { MAX_CONFIG_FILE_BYTES, MAX_LOCAL_METADATA_BYTES, readTextFileWithLimit, writeFileAtomic, } from "../core/common.js";
9
9
  import { parseAndValidateConfigText, resetConfigCache, sanitizeConfigForWrite, } from "../core/config/config.js";
10
- import { backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "../core/config/config-io.js";
10
+ import { backupExistingConfig, parseConfigText, withConfigLock, writeConfigAtomic } from "../core/config/config-io.js";
11
11
  import { ConfigError } from "../core/errors.js";
12
12
  import { withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
13
13
  import { assertNoArtifactReplacementBlockers, ensureMigrationBackupWithConfigLockHeld, fingerprintMigrationGeneration, getMigrationApplyJournalPath, getMigrationBackupDir, getMigrationBackupRoot, getMigrationRestoreJournalPath, inspectMigrationState, MIGRATION_BACKUP_VERSION, recoverInterruptedRestoreWithLocksHeld, restoreMigrationBackupWithLocksHeld, sameMigrationGeneration, verifyMigrationBackup, } from "../core/migration-backup.js";
14
14
  import { getConfigPath, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
15
15
  import { runMigrations as runStateMigrations } from "../core/state/migrations.js";
16
- import { mergeLockEntriesSync } from "../integrations/lockfile.js";
16
+ import { isValidLockfileEntry, mergeLockEntriesSync, readLockfile } from "../integrations/lockfile.js";
17
17
  import { migrateConfigSourcesToBundles, migratedLockEntries } from "../migrate/legacy/config-source-migration.js";
18
18
  import { runContentMigration } from "../migrate/legacy/content-migration.js";
19
19
  import { getLegacyWorkflowDbPath } from "../migrate/legacy/legacy-paths.js";
@@ -79,6 +79,54 @@ function isGenerationFingerprint(value) {
79
79
  function sameArtifactFingerprint(left, right) {
80
80
  return JSON.stringify(left) === JSON.stringify(right);
81
81
  }
82
+ function isEmptyFileFingerprint(value) {
83
+ return value?.byteSize === 0 && value.sha256 === createHash("sha256").digest("hex");
84
+ }
85
+ function sameWorkflowWal(current, expected) {
86
+ return JSON.stringify(current) === JSON.stringify(expected) || (expected === null && isEmptyFileFingerprint(current));
87
+ }
88
+ function sameApplyJournalGeneration(current, journal) {
89
+ if (sameMigrationGeneration(current, journal.generation))
90
+ return true;
91
+ if (journal.phase !== "workflow-applied")
92
+ return false;
93
+ return (sameArtifactFingerprint(current.config, journal.generation.config) &&
94
+ sameArtifactFingerprint(current.state, journal.generation.state) &&
95
+ JSON.stringify(current.workflow.main) === JSON.stringify(journal.generation.workflow.main) &&
96
+ sameWorkflowWal(current.workflow.wal, journal.generation.workflow.wal));
97
+ }
98
+ function isMigrationLockEntries(value) {
99
+ return (Array.isArray(value) &&
100
+ value.every((entry) => isValidLockfileEntry(entry) && typeof entry.localRoot === "string" && entry.localRoot.length > 0));
101
+ }
102
+ function recoverV2MigrationLockEntries(journal) {
103
+ const configBackupPath = path.join(journal.backupPath, "config.json");
104
+ const backupEntries = fs.existsSync(configBackupPath)
105
+ ? migratedLockEntries(parseConfigText(readTextFileWithLimit(configBackupPath, MAX_CONFIG_FILE_BYTES, "Migration backup config"), configBackupPath))
106
+ : [];
107
+ const bundles = journal.targetConfig.bundles;
108
+ if (!bundles || typeof bundles !== "object" || Array.isArray(bundles))
109
+ return [];
110
+ const managedBundleIds = new Set(Object.entries(bundles)
111
+ .filter(([, entry]) => {
112
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
113
+ return false;
114
+ return "git" in entry || "npm" in entry;
115
+ })
116
+ .map(([id]) => id));
117
+ const candidates = [...backupEntries, ...readLockfile()];
118
+ const byId = new Map();
119
+ for (const entry of candidates) {
120
+ if (managedBundleIds.has(entry.id) && entry.localRoot)
121
+ byId.set(entry.id, entry);
122
+ }
123
+ const missing = [...managedBundleIds].filter((id) => !byId.has(id));
124
+ if (missing.length > 0) {
125
+ throw new ConfigError(`Cannot recover materialized roots for format-2 migration journal bundle(s): ${missing.join(", ")}. ` +
126
+ "Restore the journal's verified backup or supply the missing lock roots before resuming.", "INVALID_CONFIG_FILE");
127
+ }
128
+ return [...byId.values()];
129
+ }
82
130
  /**
83
131
  * Collapse state.db to a SINGLE FILE (DELETE journal) for the rest of the
84
132
  * apply. A WAL-mode state.db carries `-wal`/`-shm` sidecars that the migration
@@ -602,7 +650,7 @@ function inspectExactApplyJournalGeneration(journal) {
602
650
  applyPreflightHookForTests?.(journal.phase);
603
651
  const capture = captureMigrationInspection(journal.phase);
604
652
  try {
605
- if (!sameMigrationGeneration(capture.generation, journal.generation)) {
653
+ if (!sameApplyJournalGeneration(capture.generation, journal)) {
606
654
  throw new MigrationPreflightGenerationError(`Migration apply journal phase ${journal.phase} changed before its next mutation; the external generation was preserved.`, "INVALID_CONFIG_FILE");
607
655
  }
608
656
  return capture.artifacts;
@@ -685,6 +733,14 @@ function assertRollbackTransitionAllowed(journal, current) {
685
733
  : ["config", "state", "workflow"];
686
734
  for (const name of unchanged) {
687
735
  if (!sameArtifactFingerprint(journal.generation[name], current[name])) {
736
+ if (name === "state" &&
737
+ (journal.phase === "state-applied" || journal.phase === "workflow-applied") &&
738
+ readSingleFileBoundStateMarker(journal)?.phase === "state-applied") {
739
+ // SQLite rollback preserves the logical generation but may rewrite
740
+ // physical pages. The operation-bound canonical digest covers the full
741
+ // schema and every row, so it safely authenticates that rollback.
742
+ continue;
743
+ }
688
744
  throw new ConfigError(`Refusing migration rollback because ${name} changed outside the journaled ${journal.phase} transition.`, "INVALID_CONFIG_FILE");
689
745
  }
690
746
  }
@@ -755,6 +811,7 @@ function readApplyJournalMetadata() {
755
811
  if (!fs.existsSync(journalPath))
756
812
  return {};
757
813
  let journal;
814
+ let wasFormat2 = false;
758
815
  try {
759
816
  const value = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Migration apply journal"));
760
817
  const phases = [
@@ -772,27 +829,29 @@ function readApplyJournalMetadata() {
772
829
  "rollback-prepared",
773
830
  "committed",
774
831
  ];
832
+ const formatVersion = typeof value === "object" && value !== null && !Array.isArray(value)
833
+ ? value.formatVersion
834
+ : undefined;
835
+ const requiredKeys = [
836
+ "backupPath",
837
+ "backupRunId",
838
+ "formatVersion",
839
+ "generation",
840
+ "installationId",
841
+ ...(formatVersion === 3 ? ["migrationLockEntries"] : []),
842
+ "operationId",
843
+ "phase",
844
+ "targetConfig",
845
+ "version",
846
+ ];
775
847
  if (typeof value !== "object" ||
776
848
  value === null ||
777
849
  Array.isArray(value) ||
778
- Object.keys(value).sort().join(",") !==
779
- [
780
- "backupPath",
781
- "backupRunId",
782
- "formatVersion",
783
- "generation",
784
- "installationId",
785
- "operationId",
786
- "phase",
787
- "targetConfig",
788
- "version",
789
- ]
790
- .sort()
791
- .join(",")) {
850
+ Object.keys(value).sort().join(",") !== requiredKeys.sort().join(",")) {
792
851
  return { error: `Invalid migration apply journal at ${journalPath}.` };
793
852
  }
794
853
  const candidate = value;
795
- if (candidate.formatVersion !== 2 ||
854
+ if ((candidate.formatVersion !== 2 && candidate.formatVersion !== 3) ||
796
855
  candidate.version !== MIGRATION_BACKUP_VERSION ||
797
856
  typeof candidate.operationId !== "string" ||
798
857
  !/^[A-Za-z0-9._-]+$/.test(candidate.operationId) ||
@@ -804,10 +863,16 @@ function readApplyJournalMetadata() {
804
863
  !candidate.targetConfig ||
805
864
  typeof candidate.targetConfig !== "object" ||
806
865
  Array.isArray(candidate.targetConfig) ||
807
- !phases.includes(candidate.phase)) {
866
+ !phases.includes(candidate.phase) ||
867
+ (candidate.formatVersion === 3 && !isMigrationLockEntries(candidate.migrationLockEntries))) {
808
868
  return { error: `Invalid or foreign migration apply journal at ${journalPath}.` };
809
869
  }
810
- journal = candidate;
870
+ wasFormat2 = candidate.formatVersion === 2;
871
+ journal = {
872
+ ...candidate,
873
+ formatVersion: 3,
874
+ migrationLockEntries: candidate.formatVersion === 3 ? (candidate.migrationLockEntries ?? []) : [],
875
+ };
811
876
  }
812
877
  catch (error) {
813
878
  return {
@@ -826,6 +891,8 @@ function readApplyJournalMetadata() {
826
891
  if (manifest.runId !== journal.backupRunId || manifest.installationId !== journal.installationId) {
827
892
  throw new ConfigError(`Migration apply journal backup provenance does not match its manifest.`, "INVALID_CONFIG_FILE");
828
893
  }
894
+ if (wasFormat2)
895
+ journal.migrationLockEntries = recoverV2MigrationLockEntries(journal);
829
896
  return { journal, config, manifest };
830
897
  }
831
898
  catch (error) {
@@ -889,6 +956,10 @@ function isAuthenticatedCutoverAdjacent(journal, current, stateSnapshotPath) {
889
956
  function workflowArtifactIsDeletionSubset(expected, current) {
890
957
  return ["main", "wal", "shm"].every((component) => {
891
958
  const actual = current.workflow[component];
959
+ if (component === "shm" && expected.workflow.shm === null)
960
+ return true;
961
+ if (component === "wal" && sameWorkflowWal(actual, expected.workflow.wal))
962
+ return true;
892
963
  return actual === null || JSON.stringify(actual) === JSON.stringify(expected.workflow[component]);
893
964
  });
894
965
  }
@@ -919,7 +990,7 @@ function readApplyJournal() {
919
990
  if (isPostCutoverPhase(journal.phase)) {
920
991
  if (isTaskOnlyRepair(manifest)) {
921
992
  const artifacts = validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
922
- if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
993
+ if (!sameApplyJournalGeneration(rawGeneration, journal)) {
923
994
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
924
995
  }
925
996
  return { journal, config, artifacts };
@@ -930,7 +1001,7 @@ function readApplyJournal() {
930
1001
  artifacts: postCutoverArtifacts(journal, manifest, rawGeneration, capture.paths, inspectedArtifacts),
931
1002
  };
932
1003
  }
933
- if (!sameMigrationGeneration(rawGeneration, journal.generation) &&
1004
+ if (!sameApplyJournalGeneration(rawGeneration, journal) &&
934
1005
  isAuthenticatedCutoverAdjacent(journal, rawGeneration, capture.paths.stateDbPath)) {
935
1006
  const artifacts = postCutoverArtifacts({ ...journal, phase: "cutover-applied" }, manifest, rawGeneration, capture.paths, inspectedArtifacts);
936
1007
  return {
@@ -947,7 +1018,7 @@ function readApplyJournal() {
947
1018
  if (isPreConversionCompatiblePhase(journal.phase)) {
948
1019
  const stateMarker = readSingleFileBoundStateMarker(journal, capture.paths.stateDbPath);
949
1020
  if (stateMarker?.phase === "state-applied") {
950
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1021
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
951
1022
  return { journal, config, artifacts: inspectedArtifacts };
952
1023
  }
953
1024
  if (isAuthenticatedWorkflowAdjacent(journal, rawGeneration, capture.paths.workflowDbPath)) {
@@ -966,7 +1037,7 @@ function readApplyJournal() {
966
1037
  }
967
1038
  }
968
1039
  if (isPreConversionCompatiblePhase(journal.phase)) {
969
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1040
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
970
1041
  return {
971
1042
  journal,
972
1043
  config,
@@ -990,7 +1061,7 @@ function readApplyJournal() {
990
1061
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
991
1062
  }
992
1063
  if (journal.phase === "state-converting") {
993
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1064
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
994
1065
  return {
995
1066
  journal,
996
1067
  config,
@@ -1015,7 +1086,7 @@ function readApplyJournal() {
1015
1086
  throw new ConfigError("Migration apply journal phase state-converting does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
1016
1087
  }
1017
1088
  if (journal.phase === "state-collapsing") {
1018
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1089
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
1019
1090
  return {
1020
1091
  journal,
1021
1092
  config,
@@ -1040,7 +1111,7 @@ function readApplyJournal() {
1040
1111
  throw new ConfigError("Migration apply journal phase state-collapsing does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
1041
1112
  }
1042
1113
  validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
1043
- if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
1114
+ if (!sameApplyJournalGeneration(rawGeneration, journal)) {
1044
1115
  const adjacent = detectAdjacentGeneration(journal, manifest, inspectedArtifacts, rawGeneration, capture.paths.workflowDbPath);
1045
1116
  if (adjacent.adjacent || adjacent.rollbackCompleted) {
1046
1117
  return { journal, config, artifacts: inspectedArtifacts, ...adjacent };
@@ -1079,14 +1150,14 @@ function authenticatePreConversionJournalForApply() {
1079
1150
  if (isAuthenticatedCutoverAdjacent(journal, current))
1080
1151
  return;
1081
1152
  if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied") {
1082
- if (sameMigrationGeneration(current, journal.generation) ||
1153
+ if (sameApplyJournalGeneration(current, journal) ||
1083
1154
  isAuthenticatedWorkflowAdjacent(journal, current) ||
1084
1155
  isAuthenticatedCutoverAdjacent(journal, current)) {
1085
1156
  return;
1086
1157
  }
1087
1158
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
1088
1159
  }
1089
- if (!sameMigrationGeneration(current, journal.generation) &&
1160
+ if (!sameApplyJournalGeneration(current, journal) &&
1090
1161
  !isAuthenticatedWorkflowAdjacent(journal, current) &&
1091
1162
  !isAuthenticatedCutoverAdjacent(journal, current)) {
1092
1163
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
@@ -1104,7 +1175,7 @@ function preparePreConversionJournalForApply() {
1104
1175
  return;
1105
1176
  if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied")
1106
1177
  return;
1107
- if (!sameMigrationGeneration(current, journal.generation)) {
1178
+ if (!sameApplyJournalGeneration(current, journal)) {
1108
1179
  if (!isAuthenticatedWorkflowAdjacent(journal, current)) {
1109
1180
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
1110
1181
  }
@@ -1213,7 +1284,7 @@ function runStateMigrationStep(journal) {
1213
1284
  crashAfterForTests("state-marker");
1214
1285
  }
1215
1286
  if (journal.phase === "state-collapsing") {
1216
- if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
1287
+ if (!sameApplyJournalGeneration(fingerprintMigrationGeneration(), journal)) {
1217
1288
  const marker = readBoundStateGenerationMarkerFromDisk(journal.operationId);
1218
1289
  if (!marker) {
1219
1290
  throw new ConfigError("state.db does not match the exact marker-bound generation recorded before conversion.", "INVALID_CONFIG_FILE");
@@ -1314,7 +1385,7 @@ function expandTilde(p) {
1314
1385
  * Source (a) (the index `item_ref` join) is authoritative, so an unresolved root
1315
1386
  * only costs a few origin aliases.
1316
1387
  */
1317
- function cutoverStashRootsFromConfig(config) {
1388
+ function cutoverStashRootsFromConfig(config, migrationLockEntries = []) {
1318
1389
  const roots = [];
1319
1390
  const bundles = config.bundles;
1320
1391
  if (bundles && typeof bundles === "object") {
@@ -1325,10 +1396,23 @@ function cutoverStashRootsFromConfig(config) {
1325
1396
  const registryId = entry.registryId ?? id;
1326
1397
  roots.push({
1327
1398
  path: path.resolve(expandTilde(bundlePath)),
1399
+ bundleId: id,
1328
1400
  registryId,
1329
1401
  primary: config.defaultBundle === id,
1330
1402
  });
1331
1403
  }
1404
+ for (const lock of migrationLockEntries) {
1405
+ const localRoot = lock.localRoot;
1406
+ if (!localRoot || roots.some((root) => path.resolve(root.path) === path.resolve(localRoot)))
1407
+ continue;
1408
+ const entry = bundles[lock.id];
1409
+ roots.push({
1410
+ path: path.resolve(expandTilde(localRoot)),
1411
+ bundleId: lock.id,
1412
+ registryId: entry?.registryId ?? lock.id,
1413
+ primary: config.defaultBundle === lock.id,
1414
+ });
1415
+ }
1332
1416
  }
1333
1417
  return roots;
1334
1418
  }
@@ -1380,7 +1464,7 @@ function runCutoverStep(journal, target) {
1380
1464
  const statePath = getStateDbPathInDataDir();
1381
1465
  const workflowPath = getLegacyWorkflowDbPath();
1382
1466
  const indexPath = getDbPath();
1383
- const stashRoots = cutoverStashRootsFromConfig(target);
1467
+ const stashRoots = cutoverStashRootsFromConfig(target, journal.migrationLockEntries);
1384
1468
  if (!cutoverMergeCommitted(statePath, journal.operationId)) {
1385
1469
  const refMap = buildCutoverRefMap({
1386
1470
  oldIndexDbPath: indexPath,
@@ -1397,9 +1481,8 @@ function runCutoverStep(journal, target) {
1397
1481
  else {
1398
1482
  assertPostCutoverWorkflowAuthenticated(journal, fingerprintMigrationGeneration());
1399
1483
  }
1400
- // Boundary ops run AFTER the committed state txn, OUTSIDE the fail-closed gate
1401
- // (cutover-design.md §2 step 5/6). Idempotent + best-effort they log and
1402
- // return, never throw, so a rename/unlink hiccup never rolls back the merge.
1484
+ // Boundary ops run AFTER the committed state txn. Index quarantine remains
1485
+ // best-effort; workflow deletion must complete before the journal advances.
1403
1486
  quarantineIndexDb(journal.operationId, indexPath);
1404
1487
  deleteWorkflowDb(workflowPath);
1405
1488
  // WI-8.5d: the content migration (`.stash.json` fold + delete, D-R6 reserved-
@@ -1519,6 +1602,7 @@ function loadTargetConfig(preparedConfigPath, artifacts) {
1519
1602
  path: targetPath,
1520
1603
  },
1521
1604
  config: parseMigrationTargetConfig(text, targetPath),
1605
+ migrationLockEntries: migratedLockEntries(parseConfigText(text, targetPath)),
1522
1606
  };
1523
1607
  }
1524
1608
  catch (error) {
@@ -1550,6 +1634,7 @@ function buildMigrationPlan(preparedConfigPath, activeApply) {
1550
1634
  unsafeArtifact("config.json", artifacts.config),
1551
1635
  unsafeArtifact("state.db", artifacts.state),
1552
1636
  unsafeArtifact("workflow.db", artifacts.workflow),
1637
+ unsafeArtifact("index.db", artifacts.index),
1553
1638
  ].filter((blocker) => blocker !== undefined);
1554
1639
  if (target.state.status !== "current")
1555
1640
  blockers.push(target.state.detail ?? "A current target config is required.");
@@ -1609,11 +1694,13 @@ export async function runMigrationStatus(options = {}) {
1609
1694
  }
1610
1695
  function requireEligiblePlan(preparedConfigPath, active = readApplyJournal()) {
1611
1696
  const plan = buildMigrationPlan(preparedConfigPath, active);
1612
- const loaded = active.journal ? { config: active.config } : loadTargetConfig(preparedConfigPath, plan.artifacts);
1697
+ const loaded = active.journal
1698
+ ? { config: active.config, migrationLockEntries: active.journal.migrationLockEntries }
1699
+ : loadTargetConfig(preparedConfigPath, plan.artifacts);
1613
1700
  if (plan.status === "blocked" || !loaded.config) {
1614
1701
  throw new ConfigError(`Migration is blocked: ${plan.blockers.join("; ")}`, "INVALID_CONFIG_FILE");
1615
1702
  }
1616
- return { plan, target: loaded.config };
1703
+ return { plan, target: loaded.config, migrationLockEntries: loaded.migrationLockEntries ?? [] };
1617
1704
  }
1618
1705
  export async function runMigrationApply(options = {}) {
1619
1706
  if (options.dryRun) {
@@ -1648,14 +1735,14 @@ export async function runMigrationApply(options = {}) {
1648
1735
  active.journal.phase = active.adjacent.phase;
1649
1736
  writeApplyJournal(active.journal);
1650
1737
  }
1651
- const { plan, target } = requireEligiblePlan(options.preparedConfigPath, active);
1738
+ const { plan, target, migrationLockEntries } = requireEligiblePlan(options.preparedConfigPath, active);
1652
1739
  if (plan.status === "current")
1653
1740
  return { plan };
1654
1741
  const backup = active.journal
1655
1742
  ? { path: active.journal.backupPath, manifest: verifyMigrationBackup(active.journal.backupPath) }
1656
1743
  : ensureMigrationBackupWithConfigLockHeld();
1657
1744
  const journal = active.journal ?? {
1658
- formatVersion: 2,
1745
+ formatVersion: 3,
1659
1746
  version: MIGRATION_BACKUP_VERSION,
1660
1747
  operationId: `${process.pid}-${randomUUID()}`,
1661
1748
  installationId: backup.manifest.installationId,
@@ -1663,6 +1750,7 @@ export async function runMigrationApply(options = {}) {
1663
1750
  phase: "prepared",
1664
1751
  backupPath: backup.path,
1665
1752
  targetConfig: sanitizeConfigForWrite(target),
1753
+ migrationLockEntries,
1666
1754
  generation: fingerprintMigrationGeneration(),
1667
1755
  };
1668
1756
  if (!active.journal)
@@ -1713,15 +1801,7 @@ export async function runMigrationApply(options = {}) {
1713
1801
  }
1714
1802
  if (journal.phase === "cutover-applied") {
1715
1803
  // The cutover is committed, so all remaining work is forward-only.
1716
- try {
1717
- const preCutoverText = readConfigText(getConfigPath());
1718
- if (preCutoverText !== undefined) {
1719
- mergeLockEntriesSync(migratedLockEntries(parseConfigText(preCutoverText, getConfigPath())));
1720
- }
1721
- }
1722
- catch {
1723
- // Advisory lock re-key only; the committed cutover is unaffected.
1724
- }
1804
+ mergeLockEntriesSync(journal.migrationLockEntries);
1725
1805
  backupExistingConfig(getConfigPath());
1726
1806
  writeConfigAtomic(getConfigPath(), sanitizeConfigForWrite(target));
1727
1807
  resetConfigCache();
@@ -1770,7 +1850,7 @@ export async function runMigrationApply(options = {}) {
1770
1850
  journal.phase = "rollback-prepared";
1771
1851
  journal.generation = rollbackGeneration;
1772
1852
  writeApplyJournal(journal);
1773
- if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
1853
+ if (!sameApplyJournalGeneration(fingerprintMigrationGeneration(), journal)) {
1774
1854
  throw new ConfigError(`Refusing migration rollback because live artifacts no longer match journal phase ${journal.phase}.`, "INVALID_CONFIG_FILE");
1775
1855
  }
1776
1856
  restoreMigrationBackupWithLocksHeld(backup.path);
@@ -136,6 +136,14 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
136
136
  // xrefs, and `displayRef` output spelling. `.itemRef` below stays the
137
137
  // fully-qualified durable key.
138
138
  const ref = conceptIdFromTypeName(indexed.entry.type, indexed.entry.name);
139
+ try {
140
+ parseRefInput(ref);
141
+ }
142
+ catch (error) {
143
+ if (error instanceof UsageError || error instanceof NotFoundError)
144
+ continue;
145
+ throw error;
146
+ }
139
147
  // Chunk-5 flip F5d (Step 4): the durable `item_ref` (`<bundle>//<concept-id>`),
140
148
  // reconstructed from the mapper-unlocked provenance columns with ZERO extra
141
149
  // queries (D-R3 — derived from the resolved index entry, never raw input).
@@ -188,9 +196,9 @@ async function collectEligibleRefsFromIndex(scope, stashDir, improveProfile, rea
188
196
  };
189
197
  }
190
198
  catch (error) {
191
- // The bun-test isolation guard must never be downgraded to "empty plan".
199
+ // Empty-stash setup paths can open index.db before its schema exists.
192
200
  rethrowIfTestIsolationError(error);
193
- if (error instanceof NotFoundError || error instanceof Error) {
201
+ if (error instanceof Error && /no such table:\s*entries/i.test(error.message)) {
194
202
  return { plannedRefs: [], memorySummary: { eligible: 0, derived: 0 }, strategyFilteredRefs: [] };
195
203
  }
196
204
  throw error;
@@ -130,6 +130,18 @@ function parseFrontmatterLenient(frontmatter) {
130
130
  export function mutateFrontmatter(filePath, mutator) {
131
131
  const raw = fs.readFileSync(filePath, "utf8");
132
132
  const parsed = parseFrontmatter(raw);
133
+ if (parsed.frontmatter?.trim()) {
134
+ let strict;
135
+ try {
136
+ strict = yamlParse(parsed.frontmatter);
137
+ }
138
+ catch {
139
+ throw new Error(`Cannot mutate malformed YAML frontmatter in ${filePath}.`);
140
+ }
141
+ if (strict === null || typeof strict !== "object" || Array.isArray(strict)) {
142
+ throw new Error(`Cannot mutate non-mapping YAML frontmatter in ${filePath}.`);
143
+ }
144
+ }
133
145
  const nextFrontmatter = mutator(parsed);
134
146
  if (nextFrontmatter === null)
135
147
  return false;
@@ -279,16 +279,15 @@ export function inspectMigrationState(paths = {}) {
279
279
  index: inspectIndexDbArtifact(paths.indexDbPath ?? getDbPath()),
280
280
  };
281
281
  }
282
- function assertBackupEligible(state) {
283
- // index.db is deliberately absent here: an unreadable index.db is excluded
284
- // from the backup (regenerable cache; the cutover's usage_events rescue
285
- // reports an empty result) rather than blocking migration entirely.
282
+ function assertBackupEligible(state, allowCorruptIndex = false) {
286
283
  const entries = [
287
284
  ["config.json", state.config],
288
285
  ["state.db", state.state],
289
286
  ["workflow.db", state.workflow],
287
+ ["index.db", state.index],
290
288
  ];
291
- const unsafe = entries.filter(([, artifact]) => ["newer", "inconsistent", "corrupt"].includes(artifact.status));
289
+ const unsafe = entries.filter(([name, artifact]) => ["newer", "inconsistent", "corrupt"].includes(artifact.status) &&
290
+ !(allowCorruptIndex && name === "index.db" && artifact.status === "corrupt"));
292
291
  if (unsafe.length > 0) {
293
292
  throw new ConfigError(`Refusing migration backup because artifact state is unsafe: ${unsafe
294
293
  .map(([name, artifact]) => `${name}=${artifact.status}${artifact.detail ? ` (${artifact.detail})` : ""}`)
@@ -322,7 +321,7 @@ function parseManifest(bundlePath) {
322
321
  for (const name of artifactNamesFor(manifest.formatVersion)) {
323
322
  const artifact = manifest.artifacts?.[name];
324
323
  // index.db is never ledger-classified: "current" (readable) or "missing" only.
325
- const allowedStatuses = name === "index.db" ? ["current", "missing"] : ["old", "current", "missing"];
324
+ const allowedStatuses = name === "index.db" ? ["current", "missing", "corrupt"] : ["old", "current", "missing"];
326
325
  if (!artifact ||
327
326
  artifact.sourcePath !== expected[name] ||
328
327
  typeof artifact.present !== "boolean" ||
@@ -460,9 +459,9 @@ function stateForName(state, name) {
460
459
  return state.workflow;
461
460
  return state.index;
462
461
  }
463
- function createMigrationBackupUnlocked() {
462
+ function createMigrationBackupUnlocked(options = {}) {
464
463
  const state = inspectMigrationState();
465
- assertBackupEligible(state);
464
+ assertBackupEligible(state, options.allowCorruptIndex);
466
465
  const root = getMigrationBackupRoot();
467
466
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
468
467
  fs.chmodSync(root, 0o700);
@@ -477,25 +476,23 @@ function createMigrationBackupUnlocked() {
477
476
  for (const name of ARTIFACT_NAMES) {
478
477
  const sourcePath = sources[name];
479
478
  const sourceState = stateForName(state, name);
480
- // index.db never blocks a backup: anything short of a clean read
481
- // (corrupt cache) is recorded as absent — it is regenerable, and the
482
- // cutover's usage_events rescue reports the empty result.
483
- const effectiveState = name === "index.db" && sourceState.status !== "current" ? { status: "missing" } : sourceState;
484
479
  const destination = path.join(temporary, name);
485
- const present = effectiveState.status !== "missing";
480
+ const present = sourceState.status !== "missing";
486
481
  if (present) {
487
482
  if (name === "config.json")
488
483
  copyFileDurable(sourcePath, destination);
484
+ else if (name === "index.db" && sourceState.status === "corrupt")
485
+ copyFileDurable(sourcePath, destination);
489
486
  else
490
487
  backupSqlite(sourcePath, destination);
491
488
  }
492
489
  const inspected = present ? inspectArtifactAt(name, destination) : { status: "missing" };
493
- const expectedArtifact = { ...effectiveState, sourcePath, present, byteSize: 0, sha256: null, createdAt };
490
+ const expectedArtifact = { ...sourceState, sourcePath, present, byteSize: 0, sha256: null, createdAt };
494
491
  if (!sameState(inspected, expectedArtifact)) {
495
492
  throw new ConfigError(`Snapshot ${name} does not match its source migration state.`, "INVALID_CONFIG_FILE");
496
493
  }
497
494
  artifacts[name] = {
498
- ...effectiveState,
495
+ ...sourceState,
499
496
  sourcePath,
500
497
  present,
501
498
  byteSize: present ? fs.statSync(destination).size : 0,
@@ -1227,7 +1224,7 @@ export function restoreMigrationBackup(confirm, runId) {
1227
1224
  assertNoArtifactReplacementBlockers(bundlePath);
1228
1225
  recoverInterruptedRestore();
1229
1226
  const manifest = verifyMigrationBackup(bundlePath);
1230
- const rescue = createMigrationBackupUnlocked();
1227
+ const rescue = createMigrationBackupUnlocked({ allowCorruptIndex: true });
1231
1228
  replaceArtifactsFromBundle(bundlePath, manifest, rescue.manifest.runId);
1232
1229
  return { path: bundlePath, created: false, manifest, rescuePath: rescue.path };
1233
1230
  })));
@@ -113,7 +113,21 @@ export async function upsertLockEntry(entry) {
113
113
  export function mergeLockEntriesSync(entries) {
114
114
  if (entries.length === 0)
115
115
  return;
116
- const existing = readLockfile();
116
+ let existing = [];
117
+ const lockfilePath = getLockfilePath();
118
+ if (fs.existsSync(lockfilePath)) {
119
+ let raw;
120
+ try {
121
+ raw = JSON.parse(fs.readFileSync(lockfilePath, "utf8"));
122
+ }
123
+ catch (error) {
124
+ throw new ConfigError(`Cannot merge migration lock entries into unreadable lockfile ${lockfilePath}: ${error instanceof Error ? error.message : String(error)}.`, "INVALID_CONFIG_FILE");
125
+ }
126
+ if (!Array.isArray(raw) || !raw.every(isValidLockfileEntry)) {
127
+ throw new ConfigError(`Cannot merge migration lock entries into malformed lockfile ${lockfilePath}.`, "INVALID_CONFIG_FILE");
128
+ }
129
+ existing = raw;
130
+ }
117
131
  const incoming = new Set(entries.map((e) => e.id));
118
132
  writeLockfileUnlocked([...existing.filter((e) => !incoming.has(e.id)), ...entries]);
119
133
  }
@@ -130,7 +144,7 @@ export async function removeLockEntry(id) {
130
144
  }
131
145
  }
132
146
  // ── Helpers ─────────────────────────────────────────────────────────────────
133
- function isValidLockfileEntry(value) {
147
+ export function isValidLockfileEntry(value) {
134
148
  if (typeof value !== "object" || value === null || Array.isArray(value))
135
149
  return false;
136
150
  const obj = value;
@@ -31,6 +31,7 @@
31
31
  * original registry id is preserved in the entry's `registryId` locator so
32
32
  * nothing is lost.
33
33
  */
34
+ import fs from "node:fs";
34
35
  import os from "node:os";
35
36
  import path from "node:path";
36
37
  import { installedSourceDescriptor } from "../../core/config/config-sources.js";
@@ -99,7 +100,7 @@ export function oldConfigMigratableSources(raw) {
99
100
  });
100
101
  }
101
102
  }
102
- else if (stashDir) {
103
+ if (!out.some((source) => source.primary) && stashDir) {
103
104
  out.push({
104
105
  derivationPath: path.resolve(expandTilde(stashDir)),
105
106
  writable: true,
@@ -143,7 +144,33 @@ export function oldConfigMigratableSources(raw) {
143
144
  ...(needsLock ? { lock: { source: source, ref: ref, localRoot: resolvedRoot } } : {}),
144
145
  });
145
146
  }
146
- return out;
147
+ const deduplicated = [];
148
+ const byPath = new Map();
149
+ for (const source of out) {
150
+ let pathIdentity;
151
+ if (path.isAbsolute(source.derivationPath)) {
152
+ const resolved = path.resolve(source.derivationPath);
153
+ try {
154
+ pathIdentity = fs.realpathSync(resolved);
155
+ }
156
+ catch {
157
+ pathIdentity = resolved;
158
+ }
159
+ if (process.platform === "win32")
160
+ pathIdentity = pathIdentity.toLowerCase();
161
+ }
162
+ const prior = pathIdentity ? byPath.get(pathIdentity) : undefined;
163
+ if (!prior) {
164
+ deduplicated.push(source);
165
+ if (pathIdentity)
166
+ byPath.set(pathIdentity, source);
167
+ continue;
168
+ }
169
+ prior.registryId ??= source.registryId;
170
+ prior.writable ||= source.writable;
171
+ prior.primary ||= source.primary;
172
+ }
173
+ return deduplicated;
147
174
  }
148
175
  /**
149
176
  * Derive the §10.2 lock entries the config-shape migration produces for
@@ -197,6 +224,7 @@ export function migrateConfigSourcesToBundles(raw) {
197
224
  const migratable = oldConfigMigratableSources(raw);
198
225
  const usedIds = new Set();
199
226
  const bundles = {};
227
+ const selectorToBundle = new Map();
200
228
  let defaultBundle;
201
229
  for (const src of migratable) {
202
230
  // The ONE shared derivation, so the emitted key equals the runtime
@@ -210,6 +238,8 @@ export function migrateConfigSourcesToBundles(raw) {
210
238
  if (src.registryId && src.registryId !== id)
211
239
  entry.registryId = src.registryId;
212
240
  bundles[id] = entry;
241
+ if (src.registryId)
242
+ selectorToBundle.set(src.registryId, id);
213
243
  if (src.primary)
214
244
  defaultBundle = id;
215
245
  }
@@ -219,5 +249,9 @@ export function migrateConfigSourcesToBundles(raw) {
219
249
  out.bundles = bundles;
220
250
  if (defaultBundle !== undefined)
221
251
  out.defaultBundle = defaultBundle;
252
+ const defaultWriteTarget = readString(raw.defaultWriteTarget);
253
+ const migratedWriteTarget = defaultWriteTarget ? selectorToBundle.get(defaultWriteTarget) : undefined;
254
+ if (migratedWriteTarget)
255
+ out.defaultWriteTarget = migratedWriteTarget;
222
256
  return out;
223
257
  }
@@ -17,12 +17,13 @@
17
17
  * the indexer reads curated frontmatter for `.md` files only
18
18
  * ({@link applyCuratedFrontmatter} runs on `ext === ".md"`), and prepending a
19
19
  * `---` block to a shell/script/env asset would corrupt it — non-markdown
20
- * entries are counted + logged, never rewritten.
20
+ * entries are counted + logged, never rewritten. A sidecar is deleted only
21
+ * after every entry folds successfully; otherwise its source bytes remain.
21
22
  * 2. **D-R6 reserved-filename conformance.** OKF reserves `index.md`/`log.md` as
22
23
  * bundle structure at every depth (never concept documents). A pre-existing
23
- * stash file so named that actually carries akm ASSET frontmatter (a
24
- * `description`/`when_to_use`-bearing concept mis-placed under a reserved
25
- * name) is renamed to a collision-safe reported name (`index-content.md`,
24
+ * stash file so named that the frozen layout treated as an asset (or that
25
+ * carries explicit asset frontmatter outside a canonical type root) is
26
+ * renamed to a collision-safe reported name (`index-content.md`,
26
27
  * `log-content.md`, appending `-2`/`-3`/… on collision) so the akm adapter's
27
28
  * new reserved-file exclusion never silently drops it. A structural
28
29
  * `index.md`/`log.md` (no asset frontmatter) is left in place.
@@ -52,7 +53,8 @@ import path from "node:path";
52
53
  import { mutateFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
53
54
  import { asNonEmptyString } from "../../core/common.js";
54
55
  import { warn } from "../../core/warn.js";
55
- import { legacyStashFilePath, readLegacyStashOverrides } from "./legacy-stash-json.js";
56
+ import { isRelevantAssetFile, TYPE_DIRS } from "./legacy-layout.js";
57
+ import { inspectLegacyStashOverrides, legacyStashFilePath } from "./legacy-stash-json.js";
56
58
  /** OKF reserved structural filenames (case-insensitive, any depth). */
57
59
  const RESERVED_BASENAMES = new Set(["index.md", "log.md"]);
58
60
  /**
@@ -148,7 +150,7 @@ export function runContentMigration(stashRoots) {
148
150
  for (const dir of dirs)
149
151
  foldSidecarInDir(dir, report);
150
152
  for (const dir of dirs)
151
- renameReservedConceptsInDir(dir, report);
153
+ renameReservedConceptsInDir(resolved, dir, report);
152
154
  for (const dir of dirs)
153
155
  rewriteSourceBackrefsInDir(dir, report);
154
156
  }
@@ -208,9 +210,18 @@ function foldSidecarInDir(dir, report) {
208
210
  if (!fs.existsSync(sidecarPath))
209
211
  return;
210
212
  try {
211
- const overrides = readLegacyStashOverrides(dir);
212
- for (const entry of overrides?.entries ?? [])
213
- foldEntry(dir, entry, report);
213
+ const inspected = inspectLegacyStashOverrides(dir);
214
+ if (inspected.status !== "valid") {
215
+ warn(`[akm] content-migration: retained unreadable sidecar ${sidecarPath}.`);
216
+ return;
217
+ }
218
+ let complete = inspected.complete;
219
+ for (const entry of inspected.stash.entries)
220
+ complete = foldEntry(dir, entry, report) && complete;
221
+ if (!complete) {
222
+ warn(`[akm] content-migration: retained partially migrated sidecar ${sidecarPath}.`);
223
+ return;
224
+ }
214
225
  fs.rmSync(sidecarPath, { force: true });
215
226
  report.sidecarsFolded++;
216
227
  }
@@ -222,20 +233,32 @@ function foldSidecarInDir(dir, report) {
222
233
  function foldEntry(dir, entry, report) {
223
234
  if (!entry.filename) {
224
235
  report.entriesSkipped++;
225
- return;
236
+ return false;
237
+ }
238
+ const target = path.resolve(dir, entry.filename);
239
+ const relative = path.relative(path.resolve(dir), target);
240
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
241
+ report.entriesSkipped++;
242
+ return false;
226
243
  }
227
- const target = path.join(dir, entry.filename);
228
244
  if (!fs.existsSync(target) || path.extname(target).toLowerCase() !== ".md") {
229
245
  report.entriesSkipped++;
230
- return;
246
+ return false;
231
247
  }
232
248
  try {
249
+ const realRelative = path.relative(fs.realpathSync(dir), fs.realpathSync(target));
250
+ if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
251
+ report.entriesSkipped++;
252
+ return false;
253
+ }
233
254
  mutateFrontmatter(target, (parsed) => foldCuratedFields(parsed.data, entry));
234
255
  report.entriesFolded++;
256
+ return true;
235
257
  }
236
258
  catch (error) {
237
259
  report.entriesSkipped++;
238
260
  warn(`[akm] content-migration: could not fold entry into ${target}: ${errMsg(error)}`);
261
+ return false;
239
262
  }
240
263
  }
241
264
  /** Merge the sidecar entry's curated fields onto the existing frontmatter (sidecar wins). */
@@ -250,7 +273,7 @@ function foldCuratedFields(existing, entry) {
250
273
  return next;
251
274
  }
252
275
  /** D-R6: rename any mis-named reserved-filename concept in `dir` to a collision-safe name. */
253
- function renameReservedConceptsInDir(dir, report) {
276
+ function renameReservedConceptsInDir(root, dir, report) {
254
277
  let entries;
255
278
  try {
256
279
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -263,7 +286,10 @@ function renameReservedConceptsInDir(dir, report) {
263
286
  continue;
264
287
  const filePath = path.join(dir, entry.name);
265
288
  try {
266
- if (!carriesAssetFrontmatter(filePath))
289
+ const legacyType = legacyTypeForDirectory(root, dir);
290
+ if (legacyType === "wiki")
291
+ continue;
292
+ if (!carriesAssetFrontmatter(filePath) && !(legacyType && isRelevantAssetFile(legacyType, entry.name)))
267
293
  continue;
268
294
  const target = collisionSafeTarget(dir, entry.name);
269
295
  fs.renameSync(filePath, target);
@@ -274,6 +300,25 @@ function renameReservedConceptsInDir(dir, report) {
274
300
  }
275
301
  }
276
302
  }
303
+ function legacyTypeForDirectory(root, dir) {
304
+ const [top] = path.relative(root, dir).split(path.sep);
305
+ if (!top || top === "..")
306
+ return undefined;
307
+ const exact = Object.entries(TYPE_DIRS).find(([, typeDir]) => typeDir === top)?.[0];
308
+ if (exact)
309
+ return exact;
310
+ const actualRoot = path.join(root, top);
311
+ try {
312
+ const actualIdentity = fs.realpathSync(actualRoot);
313
+ return Object.entries(TYPE_DIRS).find(([, typeDir]) => {
314
+ const canonicalRoot = path.join(root, typeDir);
315
+ return fs.existsSync(canonicalRoot) && fs.realpathSync(canonicalRoot) === actualIdentity;
316
+ })?.[0];
317
+ }
318
+ catch {
319
+ return undefined;
320
+ }
321
+ }
277
322
  /**
278
323
  * True when a reserved-name file actually holds akm ASSET frontmatter (a
279
324
  * concept mis-placed under a reserved name), keyed on the D-R6 example markers
@@ -36,35 +36,43 @@ export function legacyStashFilePath(dirPath) {
36
36
  * or `null` when absent/empty/corrupt. Was `loadStashFile` in `metadata.ts`;
37
37
  * relocated verbatim to the migrator home (scope-B ruling) — behavior unchanged.
38
38
  */
39
- export function readLegacyStashOverrides(dirPath, options) {
39
+ export function inspectLegacyStashOverrides(dirPath, options) {
40
40
  const filePath = legacyStashFilePath(dirPath);
41
41
  if (!fs.existsSync(filePath))
42
- return null;
42
+ return { status: "missing" };
43
43
  try {
44
44
  const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
45
45
  if (!raw || !Array.isArray(raw.entries))
46
- return null;
46
+ return { status: "invalid" };
47
47
  const entries = [];
48
+ let complete = true;
48
49
  for (const e of raw.entries) {
49
50
  const validated = validateStashEntry(e);
50
51
  if (validated) {
51
- if (options?.requireFilename && !validated.filename)
52
+ if (options?.requireFilename && !validated.filename) {
53
+ complete = false;
52
54
  continue;
55
+ }
53
56
  entries.push(validated);
54
57
  }
55
58
  else {
59
+ complete = false;
56
60
  const name = typeof e === "object" && e !== null && typeof e.name === "string"
57
61
  ? e.name
58
62
  : "(unknown)";
59
63
  warn(`Warning: Skipping invalid entry "${name}" in ${filePath}`);
60
64
  }
61
65
  }
62
- return entries.length > 0 ? { entries } : null;
66
+ return { status: "valid", stash: { entries }, complete };
63
67
  }
64
68
  catch {
65
- return null;
69
+ return { status: "invalid" };
66
70
  }
67
71
  }
72
+ export function readLegacyStashOverrides(dirPath, options) {
73
+ const result = inspectLegacyStashOverrides(dirPath, options);
74
+ return result.status === "valid" && result.stash.entries.length > 0 ? result.stash : null;
75
+ }
68
76
  /** Write a legacy metadata sidecar (test/migrator fixtures). Was `writeStashFile`. */
69
77
  export function writeLegacyStashFile(dirPath, stash) {
70
78
  const filePath = legacyStashFilePath(dirPath);
@@ -97,11 +97,14 @@ function resolveOrigin(origin, bundles, filePath) {
97
97
  throw migrationError(filePath, `legacy workflow origin "${origin}" did not resolve.`);
98
98
  return candidate;
99
99
  }
100
- function assertWorkflowExists(bundle, name, rawRef, filePath) {
100
+ function assertWorkflowPathSafe(bundle, name, rawRef, filePath) {
101
101
  const candidate = resolveAssetPathFromName("workflow", path.join(bundle.root, "workflows"), name);
102
- if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
103
- throw migrationError(filePath, `legacy target "${rawRef}" was not found in bundle "${bundle.id}" at ${bundle.root}.`);
104
- }
102
+ // A stale task is valid persisted state. Rewrite its deterministic ref and let
103
+ // task sync/run continue to report the missing workflow per task.
104
+ if (!fs.existsSync(candidate))
105
+ return;
106
+ if (!fs.statSync(candidate).isFile())
107
+ throw migrationError(filePath, `legacy target "${rawRef}" is not a file in bundle "${bundle.id}".`);
105
108
  const realRoot = fs.realpathSync(bundle.root);
106
109
  const realCandidate = fs.realpathSync(candidate);
107
110
  const relative = path.relative(realRoot, realCandidate);
@@ -120,7 +123,10 @@ function renderScalarLike(sourceToken, replacement, filePath) {
120
123
  }
121
124
  if (sourceToken.startsWith('"') && sourceToken.endsWith('"'))
122
125
  return JSON.stringify(replacement);
123
- if (/^[^\s#[\]{},&*!|>'"%@`]+$/.test(sourceToken))
126
+ // parseDocument already proved this source range is one valid string scalar.
127
+ // Canonical replacements contain only slug/path characters, so any one-line
128
+ // plain legacy scalar can be replaced plainly even when its origin had @/#.
129
+ if (!sourceToken.includes("\n") && !sourceToken.includes("\r"))
124
130
  return replacement;
125
131
  throw migrationError(filePath, "the legacy workflow target uses an unsupported YAML scalar style.");
126
132
  }
@@ -165,7 +171,7 @@ function planTaskFile(filePath, containing, bundles, durabilityPaths) {
165
171
  }
166
172
  const name = canonicalizeWorkflowName(parsed.name);
167
173
  const targetBundle = parsed.origin ? resolveOrigin(parsed.origin, bundles, filePath) : containing;
168
- assertWorkflowExists(targetBundle, name, from, filePath);
174
+ assertWorkflowPathSafe(targetBundle, name, from, filePath);
169
175
  const conceptId = legacyConceptId("workflow", name);
170
176
  const to = parsed.origin ? `${targetBundle.id}//${conceptId}` : conceptId;
171
177
  const range = node.range;
@@ -137,7 +137,9 @@ function addIndexEntryMappings(map, row, stashRoots) {
137
137
  */
138
138
  function walkLegacyLayoutInto(map, root) {
139
139
  let bundle;
140
- if (root.registryId && root.registryId.length > 0)
140
+ if (root.bundleId && root.bundleId.length > 0)
141
+ bundle = root.bundleId;
142
+ else if (root.registryId && root.registryId.length > 0)
141
143
  bundle = root.registryId;
142
144
  else if (root.primary)
143
145
  bundle = basenameSlug(root.path);
@@ -157,8 +159,6 @@ function walkLegacyLayoutInto(map, root) {
157
159
  if (name === undefined)
158
160
  continue;
159
161
  const bareTail = `${type}:${name}`;
160
- if (map.has(bareTail))
161
- continue; // source (a) already covers it
162
162
  const conceptId = `${dirName}/${name}`;
163
163
  const itemRef = `${bundle}//${conceptId}`;
164
164
  setMapping(map, bareTail, itemRef);
@@ -613,6 +613,11 @@ export function runThreeDbCutover(opts) {
613
613
  db.exec(`ATTACH DATABASE '${sqliteQuote(opts.workflowPath)}' AS wf`);
614
614
  if (oldIndexExists)
615
615
  db.exec(`ATTACH DATABASE '${sqliteQuote(opts.oldIndexPath)}' AS oldidx`);
616
+ if (process.env.AKM_TEST_MIGRATION_CRASH_GAP === "cutover-attach") {
617
+ if (workflowExists)
618
+ db.prepare("SELECT COUNT(*) FROM wf.sqlite_master").get();
619
+ process.kill(process.pid, "SIGKILL");
620
+ }
616
621
  let rekey = emptyReport();
617
622
  try {
618
623
  db.exec("BEGIN IMMEDIATE");
@@ -807,23 +812,22 @@ export function quarantineIndexDb(runId, indexPath) {
807
812
  /**
808
813
  * Journaled, idempotent unlink of workflow.db + its `-wal`/`-shm` sidecars, keyed
809
814
  * on the committed cutover marker (the caller passes it only once the merge has
810
- * committed). Best-effort: a failure is logged, never thrown.
815
+ * committed). A failure throws so the operation-bound journal remains available
816
+ * and the same cutover operation retries this boundary step.
811
817
  */
812
818
  export function deleteWorkflowDb(workflowPath) {
819
+ if (process.env.AKM_TEST_MIGRATION_FAIL_WORKFLOW_DELETE === "1") {
820
+ throw new Error("injected workflow.db unlink failure");
821
+ }
813
822
  let deleted = false;
814
- try {
815
- for (const suffix of ["-shm", "-wal", ""]) {
816
- const target = `${workflowPath}${suffix}`;
817
- if (fs.existsSync(target)) {
818
- fs.rmSync(target, { force: true });
819
- if (suffix === "")
820
- deleted = true;
821
- }
823
+ for (const suffix of ["-shm", "-wal", ""]) {
824
+ const target = `${workflowPath}${suffix}`;
825
+ if (fs.existsSync(target)) {
826
+ fs.rmSync(target, { force: true });
827
+ if (suffix === "")
828
+ deleted = true;
822
829
  }
823
830
  }
824
- catch (error) {
825
- warn(`[akm] three-DB cutover: workflow.db unlink failed (${error instanceof Error ? error.message : String(error)}); it is retried on the next migrate apply.`);
826
- }
827
831
  return { deleted };
828
832
  }
829
833
  // ═══════════════════════════════════════════════════════════════════════
@@ -56,8 +56,7 @@ export function refToString(ref) {
56
56
  return makeAssetRef(ref.type, ref.name, ref.origin);
57
57
  }
58
58
  // ── Parsing ──────────────────────────────────────────────────────────────────
59
- /** Parse a legacy ref string in the format `[origin//]type:name`. */
60
- export function parseAssetRef(ref) {
59
+ function parseLegacyAssetRef(ref, allowRetiredTypes) {
61
60
  const trimmed = ref.trim();
62
61
  if (!trimmed)
63
62
  throw new UsageError("Empty ref.", "MISSING_REQUIRED_ARGUMENT");
@@ -78,20 +77,24 @@ export function parseAssetRef(ref) {
78
77
  const rawName = body.slice(colon + 1);
79
78
  // The `vault` asset type was removed in 0.9.0. Point callers at its
80
79
  // replacements rather than failing with a generic unknown-type error.
81
- if (rawType === "vault") {
80
+ if (!allowRetiredTypes && rawType === "vault") {
82
81
  throw new UsageError("The `vault` asset type was removed in 0.9.0 — use `env:` (whole .env config) or `secret:` (a single value).", "MISSING_REQUIRED_ARGUMENT");
83
82
  }
84
83
  // Type aliases: `environment:` is an accepted spelling of the canonical `env:` type.
85
84
  const resolvedType = TYPE_ALIASES[rawType] ?? rawType;
86
85
  // Open type token (chunk 1.5, D1.5-6): any other non-empty type is valid ref
87
86
  // data (foreign/adapter types included) EXCEPT the deliberately-removed set.
88
- if (DEPRECATED_REJECTED_TYPES.has(resolvedType)) {
87
+ if (!allowRetiredTypes && DEPRECATED_REJECTED_TYPES.has(resolvedType)) {
89
88
  throw new UsageError(`Invalid asset type: "${rawType}".`, "MISSING_REQUIRED_ARGUMENT");
90
89
  }
91
90
  validateName(rawName);
92
91
  const name = normalizeName(rawName);
93
92
  return { type: resolvedType, name, origin: origin || undefined };
94
93
  }
94
+ /** Parse a legacy ref string in the format `[origin//]type:name`. */
95
+ export function parseAssetRef(ref) {
96
+ return parseLegacyAssetRef(ref, false);
97
+ }
95
98
  // ── Validation (private copies — kept self-contained) ────────────────────────
96
99
  function validateName(name) {
97
100
  if (!name)
@@ -188,14 +191,16 @@ export function legacyRefToBundleRef(raw) {
188
191
  * this is the safe superset of `parseRefInput` (input boundaries are new-only).
189
192
  *
190
193
  * Mapping mirrors the pre-flip input bridge exactly:
191
- * - legacy input → `parseAssetRef` (byte-identical).
194
+ * - legacy input → historical syntax parser (including retired types).
192
195
  * - new `conceptId` → `type`/`name` via the D-R2 reverse table.
193
196
  * - new `bundle` → `origin`.
194
197
  * - `#fragment` → rejected (no stored ref carries one).
195
198
  */
196
199
  export function parseStoredRef(raw) {
197
200
  if (classifyRefGrammar(raw) === "legacy") {
198
- return parseAssetRef(raw);
201
+ // Durable state may predate the removal of a type. Retired but structurally
202
+ // valid refs are expected orphans; current user input remains strict.
203
+ return parseLegacyAssetRef(raw, true);
199
204
  }
200
205
  const ref = parseBundleRef(raw);
201
206
  if (ref.fragment !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-rc.7",
3
+ "version": "0.9.0-rc.9",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [