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.
- package/dist/cli/config-migrate.js +122 -50
- package/dist/commands/improve/eligibility.js +10 -2
- package/dist/core/asset/frontmatter.js +12 -0
- package/dist/core/migration-backup.js +13 -16
- package/dist/integrations/lockfile.js +16 -2
- package/dist/migrate/legacy/config-source-migration.js +36 -2
- package/dist/migrate/legacy/content-migration.js +59 -14
- package/dist/migrate/legacy/legacy-stash-json.js +14 -6
- package/dist/migrate/legacy/task-target-ref-migration.js +12 -6
- package/dist/migrate/legacy/three-db-cutover.js +19 -15
- package/package.json +1 -1
|
@@ -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,
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
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 (
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
|
1409
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
199
|
+
// Empty-stash setup paths can open index.db before its schema exists.
|
|
192
200
|
rethrowIfTestIsolationError(error);
|
|
193
|
-
if (error instanceof
|
|
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 =
|
|
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 = { ...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
39
|
+
export function inspectLegacyStashOverrides(dirPath, options) {
|
|
40
40
|
const filePath = legacyStashFilePath(dirPath);
|
|
41
41
|
if (!fs.existsSync(filePath))
|
|
42
|
-
return
|
|
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
|
|
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
|
|
66
|
+
return { status: "valid", stash: { entries }, complete };
|
|
63
67
|
}
|
|
64
68
|
catch {
|
|
65
|
-
return
|
|
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
|
|
100
|
+
function assertWorkflowPathSafe(bundle, name, rawRef, filePath) {
|
|
101
101
|
const candidate = resolveAssetPathFromName("workflow", path.join(bundle.root, "workflows"), name);
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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.
|
|
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": [
|