akm-cli 0.9.0-rc.8 → 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;
@@ -763,6 +811,7 @@ function readApplyJournalMetadata() {
763
811
  if (!fs.existsSync(journalPath))
764
812
  return {};
765
813
  let journal;
814
+ let wasFormat2 = false;
766
815
  try {
767
816
  const value = JSON.parse(readTextFileWithLimit(journalPath, MAX_LOCAL_METADATA_BYTES, "Migration apply journal"));
768
817
  const phases = [
@@ -780,27 +829,29 @@ function readApplyJournalMetadata() {
780
829
  "rollback-prepared",
781
830
  "committed",
782
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
+ ];
783
847
  if (typeof value !== "object" ||
784
848
  value === null ||
785
849
  Array.isArray(value) ||
786
- Object.keys(value).sort().join(",") !==
787
- [
788
- "backupPath",
789
- "backupRunId",
790
- "formatVersion",
791
- "generation",
792
- "installationId",
793
- "operationId",
794
- "phase",
795
- "targetConfig",
796
- "version",
797
- ]
798
- .sort()
799
- .join(",")) {
850
+ Object.keys(value).sort().join(",") !== requiredKeys.sort().join(",")) {
800
851
  return { error: `Invalid migration apply journal at ${journalPath}.` };
801
852
  }
802
853
  const candidate = value;
803
- if (candidate.formatVersion !== 2 ||
854
+ if ((candidate.formatVersion !== 2 && candidate.formatVersion !== 3) ||
804
855
  candidate.version !== MIGRATION_BACKUP_VERSION ||
805
856
  typeof candidate.operationId !== "string" ||
806
857
  !/^[A-Za-z0-9._-]+$/.test(candidate.operationId) ||
@@ -812,10 +863,16 @@ function readApplyJournalMetadata() {
812
863
  !candidate.targetConfig ||
813
864
  typeof candidate.targetConfig !== "object" ||
814
865
  Array.isArray(candidate.targetConfig) ||
815
- !phases.includes(candidate.phase)) {
866
+ !phases.includes(candidate.phase) ||
867
+ (candidate.formatVersion === 3 && !isMigrationLockEntries(candidate.migrationLockEntries))) {
816
868
  return { error: `Invalid or foreign migration apply journal at ${journalPath}.` };
817
869
  }
818
- journal = candidate;
870
+ wasFormat2 = candidate.formatVersion === 2;
871
+ journal = {
872
+ ...candidate,
873
+ formatVersion: 3,
874
+ migrationLockEntries: candidate.formatVersion === 3 ? (candidate.migrationLockEntries ?? []) : [],
875
+ };
819
876
  }
820
877
  catch (error) {
821
878
  return {
@@ -834,6 +891,8 @@ function readApplyJournalMetadata() {
834
891
  if (manifest.runId !== journal.backupRunId || manifest.installationId !== journal.installationId) {
835
892
  throw new ConfigError(`Migration apply journal backup provenance does not match its manifest.`, "INVALID_CONFIG_FILE");
836
893
  }
894
+ if (wasFormat2)
895
+ journal.migrationLockEntries = recoverV2MigrationLockEntries(journal);
837
896
  return { journal, config, manifest };
838
897
  }
839
898
  catch (error) {
@@ -897,6 +956,10 @@ function isAuthenticatedCutoverAdjacent(journal, current, stateSnapshotPath) {
897
956
  function workflowArtifactIsDeletionSubset(expected, current) {
898
957
  return ["main", "wal", "shm"].every((component) => {
899
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;
900
963
  return actual === null || JSON.stringify(actual) === JSON.stringify(expected.workflow[component]);
901
964
  });
902
965
  }
@@ -927,7 +990,7 @@ function readApplyJournal() {
927
990
  if (isPostCutoverPhase(journal.phase)) {
928
991
  if (isTaskOnlyRepair(manifest)) {
929
992
  const artifacts = validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
930
- if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
993
+ if (!sameApplyJournalGeneration(rawGeneration, journal)) {
931
994
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
932
995
  }
933
996
  return { journal, config, artifacts };
@@ -938,7 +1001,7 @@ function readApplyJournal() {
938
1001
  artifacts: postCutoverArtifacts(journal, manifest, rawGeneration, capture.paths, inspectedArtifacts),
939
1002
  };
940
1003
  }
941
- if (!sameMigrationGeneration(rawGeneration, journal.generation) &&
1004
+ if (!sameApplyJournalGeneration(rawGeneration, journal) &&
942
1005
  isAuthenticatedCutoverAdjacent(journal, rawGeneration, capture.paths.stateDbPath)) {
943
1006
  const artifacts = postCutoverArtifacts({ ...journal, phase: "cutover-applied" }, manifest, rawGeneration, capture.paths, inspectedArtifacts);
944
1007
  return {
@@ -955,7 +1018,7 @@ function readApplyJournal() {
955
1018
  if (isPreConversionCompatiblePhase(journal.phase)) {
956
1019
  const stateMarker = readSingleFileBoundStateMarker(journal, capture.paths.stateDbPath);
957
1020
  if (stateMarker?.phase === "state-applied") {
958
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1021
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
959
1022
  return { journal, config, artifacts: inspectedArtifacts };
960
1023
  }
961
1024
  if (isAuthenticatedWorkflowAdjacent(journal, rawGeneration, capture.paths.workflowDbPath)) {
@@ -974,7 +1037,7 @@ function readApplyJournal() {
974
1037
  }
975
1038
  }
976
1039
  if (isPreConversionCompatiblePhase(journal.phase)) {
977
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1040
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
978
1041
  return {
979
1042
  journal,
980
1043
  config,
@@ -998,7 +1061,7 @@ function readApplyJournal() {
998
1061
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
999
1062
  }
1000
1063
  if (journal.phase === "state-converting") {
1001
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1064
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
1002
1065
  return {
1003
1066
  journal,
1004
1067
  config,
@@ -1023,7 +1086,7 @@ function readApplyJournal() {
1023
1086
  throw new ConfigError("Migration apply journal phase state-converting does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
1024
1087
  }
1025
1088
  if (journal.phase === "state-collapsing") {
1026
- if (sameMigrationGeneration(rawGeneration, journal.generation)) {
1089
+ if (sameApplyJournalGeneration(rawGeneration, journal)) {
1027
1090
  return {
1028
1091
  journal,
1029
1092
  config,
@@ -1048,7 +1111,7 @@ function readApplyJournal() {
1048
1111
  throw new ConfigError("Migration apply journal phase state-collapsing does not match its exact marker-bound generation.", "INVALID_CONFIG_FILE");
1049
1112
  }
1050
1113
  validateApplyPhase(journal, manifest, capture.paths, inspectedArtifacts);
1051
- if (!sameMigrationGeneration(rawGeneration, journal.generation)) {
1114
+ if (!sameApplyJournalGeneration(rawGeneration, journal)) {
1052
1115
  const adjacent = detectAdjacentGeneration(journal, manifest, inspectedArtifacts, rawGeneration, capture.paths.workflowDbPath);
1053
1116
  if (adjacent.adjacent || adjacent.rollbackCompleted) {
1054
1117
  return { journal, config, artifacts: inspectedArtifacts, ...adjacent };
@@ -1087,14 +1150,14 @@ function authenticatePreConversionJournalForApply() {
1087
1150
  if (isAuthenticatedCutoverAdjacent(journal, current))
1088
1151
  return;
1089
1152
  if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied") {
1090
- if (sameMigrationGeneration(current, journal.generation) ||
1153
+ if (sameApplyJournalGeneration(current, journal) ||
1091
1154
  isAuthenticatedWorkflowAdjacent(journal, current) ||
1092
1155
  isAuthenticatedCutoverAdjacent(journal, current)) {
1093
1156
  return;
1094
1157
  }
1095
1158
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
1096
1159
  }
1097
- if (!sameMigrationGeneration(current, journal.generation) &&
1160
+ if (!sameApplyJournalGeneration(current, journal) &&
1098
1161
  !isAuthenticatedWorkflowAdjacent(journal, current) &&
1099
1162
  !isAuthenticatedCutoverAdjacent(journal, current)) {
1100
1163
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
@@ -1112,7 +1175,7 @@ function preparePreConversionJournalForApply() {
1112
1175
  return;
1113
1176
  if (readSingleFileBoundStateMarker(journal)?.phase === "state-applied")
1114
1177
  return;
1115
- if (!sameMigrationGeneration(current, journal.generation)) {
1178
+ if (!sameApplyJournalGeneration(current, journal)) {
1116
1179
  if (!isAuthenticatedWorkflowAdjacent(journal, current)) {
1117
1180
  throw new ConfigError(`Migration apply journal phase ${journal.phase} does not match the exact live artifact generation.`, "INVALID_CONFIG_FILE");
1118
1181
  }
@@ -1221,7 +1284,7 @@ function runStateMigrationStep(journal) {
1221
1284
  crashAfterForTests("state-marker");
1222
1285
  }
1223
1286
  if (journal.phase === "state-collapsing") {
1224
- if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
1287
+ if (!sameApplyJournalGeneration(fingerprintMigrationGeneration(), journal)) {
1225
1288
  const marker = readBoundStateGenerationMarkerFromDisk(journal.operationId);
1226
1289
  if (!marker) {
1227
1290
  throw new ConfigError("state.db does not match the exact marker-bound generation recorded before conversion.", "INVALID_CONFIG_FILE");
@@ -1322,7 +1385,7 @@ function expandTilde(p) {
1322
1385
  * Source (a) (the index `item_ref` join) is authoritative, so an unresolved root
1323
1386
  * only costs a few origin aliases.
1324
1387
  */
1325
- function cutoverStashRootsFromConfig(config) {
1388
+ function cutoverStashRootsFromConfig(config, migrationLockEntries = []) {
1326
1389
  const roots = [];
1327
1390
  const bundles = config.bundles;
1328
1391
  if (bundles && typeof bundles === "object") {
@@ -1333,10 +1396,23 @@ function cutoverStashRootsFromConfig(config) {
1333
1396
  const registryId = entry.registryId ?? id;
1334
1397
  roots.push({
1335
1398
  path: path.resolve(expandTilde(bundlePath)),
1399
+ bundleId: id,
1336
1400
  registryId,
1337
1401
  primary: config.defaultBundle === id,
1338
1402
  });
1339
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
+ }
1340
1416
  }
1341
1417
  return roots;
1342
1418
  }
@@ -1388,7 +1464,7 @@ function runCutoverStep(journal, target) {
1388
1464
  const statePath = getStateDbPathInDataDir();
1389
1465
  const workflowPath = getLegacyWorkflowDbPath();
1390
1466
  const indexPath = getDbPath();
1391
- const stashRoots = cutoverStashRootsFromConfig(target);
1467
+ const stashRoots = cutoverStashRootsFromConfig(target, journal.migrationLockEntries);
1392
1468
  if (!cutoverMergeCommitted(statePath, journal.operationId)) {
1393
1469
  const refMap = buildCutoverRefMap({
1394
1470
  oldIndexDbPath: indexPath,
@@ -1405,9 +1481,8 @@ function runCutoverStep(journal, target) {
1405
1481
  else {
1406
1482
  assertPostCutoverWorkflowAuthenticated(journal, fingerprintMigrationGeneration());
1407
1483
  }
1408
- // Boundary ops run AFTER the committed state txn, OUTSIDE the fail-closed gate
1409
- // (cutover-design.md §2 step 5/6). Idempotent + best-effort they log and
1410
- // 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.
1411
1486
  quarantineIndexDb(journal.operationId, indexPath);
1412
1487
  deleteWorkflowDb(workflowPath);
1413
1488
  // WI-8.5d: the content migration (`.stash.json` fold + delete, D-R6 reserved-
@@ -1527,6 +1602,7 @@ function loadTargetConfig(preparedConfigPath, artifacts) {
1527
1602
  path: targetPath,
1528
1603
  },
1529
1604
  config: parseMigrationTargetConfig(text, targetPath),
1605
+ migrationLockEntries: migratedLockEntries(parseConfigText(text, targetPath)),
1530
1606
  };
1531
1607
  }
1532
1608
  catch (error) {
@@ -1558,6 +1634,7 @@ function buildMigrationPlan(preparedConfigPath, activeApply) {
1558
1634
  unsafeArtifact("config.json", artifacts.config),
1559
1635
  unsafeArtifact("state.db", artifacts.state),
1560
1636
  unsafeArtifact("workflow.db", artifacts.workflow),
1637
+ unsafeArtifact("index.db", artifacts.index),
1561
1638
  ].filter((blocker) => blocker !== undefined);
1562
1639
  if (target.state.status !== "current")
1563
1640
  blockers.push(target.state.detail ?? "A current target config is required.");
@@ -1617,11 +1694,13 @@ export async function runMigrationStatus(options = {}) {
1617
1694
  }
1618
1695
  function requireEligiblePlan(preparedConfigPath, active = readApplyJournal()) {
1619
1696
  const plan = buildMigrationPlan(preparedConfigPath, active);
1620
- 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);
1621
1700
  if (plan.status === "blocked" || !loaded.config) {
1622
1701
  throw new ConfigError(`Migration is blocked: ${plan.blockers.join("; ")}`, "INVALID_CONFIG_FILE");
1623
1702
  }
1624
- return { plan, target: loaded.config };
1703
+ return { plan, target: loaded.config, migrationLockEntries: loaded.migrationLockEntries ?? [] };
1625
1704
  }
1626
1705
  export async function runMigrationApply(options = {}) {
1627
1706
  if (options.dryRun) {
@@ -1656,14 +1735,14 @@ export async function runMigrationApply(options = {}) {
1656
1735
  active.journal.phase = active.adjacent.phase;
1657
1736
  writeApplyJournal(active.journal);
1658
1737
  }
1659
- const { plan, target } = requireEligiblePlan(options.preparedConfigPath, active);
1738
+ const { plan, target, migrationLockEntries } = requireEligiblePlan(options.preparedConfigPath, active);
1660
1739
  if (plan.status === "current")
1661
1740
  return { plan };
1662
1741
  const backup = active.journal
1663
1742
  ? { path: active.journal.backupPath, manifest: verifyMigrationBackup(active.journal.backupPath) }
1664
1743
  : ensureMigrationBackupWithConfigLockHeld();
1665
1744
  const journal = active.journal ?? {
1666
- formatVersion: 2,
1745
+ formatVersion: 3,
1667
1746
  version: MIGRATION_BACKUP_VERSION,
1668
1747
  operationId: `${process.pid}-${randomUUID()}`,
1669
1748
  installationId: backup.manifest.installationId,
@@ -1671,6 +1750,7 @@ export async function runMigrationApply(options = {}) {
1671
1750
  phase: "prepared",
1672
1751
  backupPath: backup.path,
1673
1752
  targetConfig: sanitizeConfigForWrite(target),
1753
+ migrationLockEntries,
1674
1754
  generation: fingerprintMigrationGeneration(),
1675
1755
  };
1676
1756
  if (!active.journal)
@@ -1721,15 +1801,7 @@ export async function runMigrationApply(options = {}) {
1721
1801
  }
1722
1802
  if (journal.phase === "cutover-applied") {
1723
1803
  // The cutover is committed, so all remaining work is forward-only.
1724
- try {
1725
- const preCutoverText = readConfigText(getConfigPath());
1726
- if (preCutoverText !== undefined) {
1727
- mergeLockEntriesSync(migratedLockEntries(parseConfigText(preCutoverText, getConfigPath())));
1728
- }
1729
- }
1730
- catch {
1731
- // Advisory lock re-key only; the committed cutover is unaffected.
1732
- }
1804
+ mergeLockEntriesSync(journal.migrationLockEntries);
1733
1805
  backupExistingConfig(getConfigPath());
1734
1806
  writeConfigAtomic(getConfigPath(), sanitizeConfigForWrite(target));
1735
1807
  resetConfigCache();
@@ -1778,7 +1850,7 @@ export async function runMigrationApply(options = {}) {
1778
1850
  journal.phase = "rollback-prepared";
1779
1851
  journal.generation = rollbackGeneration;
1780
1852
  writeApplyJournal(journal);
1781
- if (!sameMigrationGeneration(fingerprintMigrationGeneration(), journal.generation)) {
1853
+ if (!sameApplyJournalGeneration(fingerprintMigrationGeneration(), journal)) {
1782
1854
  throw new ConfigError(`Refusing migration rollback because live artifacts no longer match journal phase ${journal.phase}.`, "INVALID_CONFIG_FILE");
1783
1855
  }
1784
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
  // ═══════════════════════════════════════════════════════════════════════
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-rc.8",
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": [