akm-cli 0.9.0-rc.2 → 0.9.0-rc.4
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/CHANGELOG.md +40 -12
- package/README.md +7 -8
- package/SECURITY.md +5 -3
- package/dist/akm +44 -33
- package/dist/akm-migrate-storage +44 -33
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/assets/improve-strategies/catchup.json +3 -1
- package/dist/assets/improve-strategies/consolidate.json +3 -1
- package/dist/assets/improve-strategies/frequent.json +3 -1
- package/dist/assets/improve-strategies/graph-refresh.json +3 -1
- package/dist/assets/improve-strategies/memory-focus.json +4 -1
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
- package/dist/assets/improve-strategies/quick.json +3 -1
- package/dist/assets/improve-strategies/recombine-only.json +2 -0
- package/dist/assets/improve-strategies/reflect-distill.json +1 -0
- package/dist/assets/improve-strategies/synthesize.json +2 -0
- package/dist/assets/tasks/core/backup.yml +2 -2
- package/dist/cli/config-migrate.js +554 -26
- package/dist/cli.js +28 -11
- package/dist/commands/backup-cli.js +6 -4
- package/dist/commands/config-cli.js +10 -3
- package/dist/commands/feedback-cli.js +24 -7
- package/dist/commands/health/checks.js +94 -15
- package/dist/commands/health/html-report.js +23 -2
- package/dist/commands/health/improve-metrics.js +45 -6
- package/dist/commands/health/md-report.js +10 -1
- package/dist/commands/health/surfaces.js +2 -1
- package/dist/commands/health/windows.js +1 -1
- package/dist/commands/health.js +1 -1
- package/dist/commands/improve/anti-collapse.js +3 -3
- package/dist/commands/improve/collapse-detector.js +5 -5
- package/dist/commands/improve/consolidate.js +123 -66
- package/dist/commands/improve/distill/promote-memory.js +8 -6
- package/dist/commands/improve/distill/quality-gate.js +1 -1
- package/dist/commands/improve/distill-guards.js +1 -1
- package/dist/commands/improve/distill-promotion-policy.js +32 -26
- package/dist/commands/improve/distill.js +52 -36
- package/dist/commands/improve/eligibility.js +9 -8
- package/dist/commands/improve/extract-prompt.js +2 -2
- package/dist/commands/improve/extract.js +43 -11
- package/dist/commands/improve/improve-auto-accept.js +14 -28
- package/dist/commands/improve/improve-cli.js +4 -2
- package/dist/commands/improve/improve-strategies.js +2 -1
- package/dist/commands/improve/improve.js +49 -8
- package/dist/commands/improve/loop-stages.js +11 -6
- package/dist/commands/improve/preparation.js +51 -17
- package/dist/commands/improve/procedural.js +10 -6
- package/dist/commands/improve/recombine.js +31 -7
- package/dist/commands/improve/reflect.js +24 -14
- package/dist/commands/improve/salience.js +5 -1
- package/dist/commands/improve/source-identity.js +43 -0
- package/dist/commands/migrate-cli.js +36 -0
- package/dist/commands/mv-cli.js +647 -258
- package/dist/commands/proposal/drain.js +16 -4
- package/dist/commands/proposal/proposal-cli.js +3 -2
- package/dist/commands/proposal/proposal.js +16 -55
- package/dist/commands/proposal/repository.js +664 -58
- package/dist/commands/read/curate.js +4 -2
- package/dist/commands/read/knowledge.js +4 -1
- package/dist/commands/read/search.js +6 -2
- package/dist/commands/read/show.js +8 -7
- package/dist/commands/sources/self-update.js +156 -112
- package/dist/commands/sources/sources-cli.js +7 -2
- package/dist/commands/tasks/default-tasks.js +5 -5
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +261 -68
- package/dist/core/common.js +36 -3
- package/dist/core/config/config-io.js +2 -2
- package/dist/core/config/config-schema.js +15 -3
- package/dist/core/config/config.js +14 -13
- package/dist/core/file-lock.js +2 -2
- package/dist/core/improve-result.js +87 -7
- package/dist/core/migration-backup.js +816 -171
- package/dist/core/migration-operation.js +44 -0
- package/dist/core/state/migrations.js +4 -7
- package/dist/core/state-db.js +23 -7
- package/dist/core/write-source.js +86 -18
- package/dist/indexer/db/db.js +109 -53
- package/dist/indexer/index-writer-lock.js +38 -37
- package/dist/indexer/index-written-assets.js +73 -56
- package/dist/indexer/indexer.js +5 -1
- package/dist/indexer/search/search-source.js +2 -2
- package/dist/indexer/usage/usage-events.js +8 -2
- package/dist/integrations/agent/engine-resolution.js +14 -7
- package/dist/scripts/migrate-storage.js +887 -992
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
- package/dist/setup/detected-engines.js +7 -13
- package/dist/setup/engine-config.js +14 -4
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +1 -1
- package/dist/setup/steps/tasks.js +1 -1
- package/dist/sources/providers/git-stash.js +58 -21
- package/dist/sources/providers/git.js +1 -1
- package/dist/storage/engines/sqlite-migrations.js +138 -3
- package/dist/storage/repositories/events-repository.js +24 -0
- package/dist/storage/repositories/proposals-repository.js +14 -0
- package/dist/storage/repositories/task-history-repository.js +30 -3
- package/dist/tasks/backends/cron.js +71 -38
- package/dist/tasks/backends/exec-utils.js +55 -3
- package/dist/tasks/backends/launchd.js +241 -50
- package/dist/tasks/backends/schtasks.js +434 -59
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/embedded.js +2 -0
- package/dist/tasks/parser.js +142 -6
- package/dist/tasks/resolve-akm-bin.js +19 -4
- package/dist/tasks/runner.js +258 -93
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +86 -0
- package/dist/tasks/task-id.js +37 -0
- package/dist/workflows/db.js +24 -13
- package/dist/workflows/validator.js +24 -2
- package/docs/README.md +103 -0
- package/docs/migration/release-notes/0.9.0.md +28 -9
- package/docs/migration/v0.8-to-v0.9.md +186 -26
- package/package.json +2 -3
package/dist/commands/mv-cli.js
CHANGED
|
@@ -31,33 +31,33 @@
|
|
|
31
31
|
* cannot be moved alone, and target names ending `.derived` are rejected
|
|
32
32
|
* (reserved suffix).
|
|
33
33
|
*
|
|
34
|
-
* Ordering
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* (already-edited citers simply yield zero further rewrites on the retry);
|
|
41
|
-
* a crash after the rename but before the index re-key is healed by the next
|
|
42
|
-
* full `akm index` (at the cost of the utility history the re-key preserves).
|
|
34
|
+
* Ordering: the complete mutation holds the index-writer lease. After validation,
|
|
35
|
+
* citer replacements are staged beside durable byte-for-byte backups and a small
|
|
36
|
+
* phase journal under `.akm/mv-transactions/`. Publication uses same-filesystem
|
|
37
|
+
* renames; any synchronous failure restores every citer and asset rename. A later
|
|
38
|
+
* invocation rolls back an interrupted prepared/applying journal before planning
|
|
39
|
+
* another move. Derived index state remains fail-open and heals on a full index.
|
|
43
40
|
* Graph tables (graph_files) key extractions by file path and stay stale
|
|
44
41
|
* until the next graph pass — acceptable, the graph is a derived cache.
|
|
45
42
|
*/
|
|
43
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
46
44
|
import fs from "node:fs";
|
|
47
45
|
import path from "node:path";
|
|
48
46
|
import { defineJsonCommand, output } from "../cli/shared.js";
|
|
49
47
|
import { parseAssetRef, refToString } from "../core/asset/asset-ref.js";
|
|
50
48
|
import { deriveCanonicalAssetNameFromStashRoot, TYPE_DIRS } from "../core/asset/asset-spec.js";
|
|
51
49
|
import { isWithin, resolveStashDir, toPosix } from "../core/common.js";
|
|
50
|
+
import { loadConfig } from "../core/config/config.js";
|
|
52
51
|
import { UsageError } from "../core/errors.js";
|
|
53
|
-
import { appendEvent } from "../core/events.js";
|
|
54
52
|
import { getDbPath } from "../core/paths.js";
|
|
55
|
-
import { getStateDbPath } from "../core/state-db.js";
|
|
53
|
+
import { getStateDbPath, openStateDatabase } from "../core/state-db.js";
|
|
56
54
|
import { warnVerbose } from "../core/warn.js";
|
|
57
55
|
import { closeDatabase, openExistingDatabase, rebuildFts, rekeyEntryInPlace } from "../indexer/db/db.js";
|
|
56
|
+
import { withAssetMutationLease } from "../indexer/index-writer-lock.js";
|
|
58
57
|
import { indexWrittenAssets, WRITE_PATH_INDEX_BUSY_TIMEOUT_MS } from "../indexer/index-written-assets.js";
|
|
59
58
|
import { resolveSourceEntries } from "../indexer/search/search-source.js";
|
|
60
|
-
import {
|
|
59
|
+
import { insertEventOnce } from "../storage/repositories/events-repository.js";
|
|
60
|
+
import { shouldReadLegacyBareImproveState } from "./improve/source-identity.js";
|
|
61
61
|
import { REF_BOUNDARY_PREFIX_CLASS_SRC, REF_SLUG_CHAR_CLASS_SRC, refToRelPath, resolveRefPathInStash, } from "./lint/base-linter.js";
|
|
62
62
|
// ── Scope ─────────────────────────────────────────────────────────────────────
|
|
63
63
|
/**
|
|
@@ -259,6 +259,300 @@ function collectCiterFiles(root) {
|
|
|
259
259
|
walk(root);
|
|
260
260
|
return results;
|
|
261
261
|
}
|
|
262
|
+
let mvMutationHookForTests;
|
|
263
|
+
/** TEST-ONLY crash-window hook used by subprocess recovery tests. */
|
|
264
|
+
export function _setMvMutationHookForTests(hook) {
|
|
265
|
+
mvMutationHookForTests = hook;
|
|
266
|
+
}
|
|
267
|
+
function hashContent(content) {
|
|
268
|
+
return createHash("sha256").update(content).digest("hex");
|
|
269
|
+
}
|
|
270
|
+
function hashFile(filePath) {
|
|
271
|
+
return hashContent(fs.readFileSync(filePath));
|
|
272
|
+
}
|
|
273
|
+
function fsyncFile(filePath) {
|
|
274
|
+
const fd = fs.openSync(filePath, "r");
|
|
275
|
+
try {
|
|
276
|
+
fs.fsyncSync(fd);
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
fs.closeSync(fd);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function fsyncDirectory(dirPath) {
|
|
283
|
+
try {
|
|
284
|
+
fsyncFile(dirPath);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Some platforms do not permit opening directories; file fsync still applies.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function writeMoveJournal(journalPath, journal) {
|
|
291
|
+
const tempPath = `${journalPath}.tmp`;
|
|
292
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(journal, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
293
|
+
fsyncFile(tempPath);
|
|
294
|
+
fs.renameSync(tempPath, journalPath);
|
|
295
|
+
fsyncDirectory(path.dirname(journalPath));
|
|
296
|
+
}
|
|
297
|
+
function setMoveJournalPhase(transaction, phase) {
|
|
298
|
+
const next = { ...transaction.journal, phase };
|
|
299
|
+
writeMoveJournal(transaction.journalPath, next);
|
|
300
|
+
transaction.journal.phase = phase;
|
|
301
|
+
}
|
|
302
|
+
function rollbackMoveJournal(journal) {
|
|
303
|
+
const restoreRename = (oldPath, newPath, originalHash, publishedHash) => {
|
|
304
|
+
if (!oldPath || !newPath || !fs.existsSync(newPath))
|
|
305
|
+
return;
|
|
306
|
+
if (!publishedHash || hashFile(newPath) !== publishedHash) {
|
|
307
|
+
throw new Error(`cannot roll back ${newPath}: published file diverged after the move`);
|
|
308
|
+
}
|
|
309
|
+
if (fs.existsSync(oldPath)) {
|
|
310
|
+
if (hashFile(oldPath) !== publishedHash) {
|
|
311
|
+
throw new Error(`cannot roll back ${newPath}: source path ${oldPath} is occupied by divergent content`);
|
|
312
|
+
}
|
|
313
|
+
fs.unlinkSync(newPath);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
fs.linkSync(newPath, oldPath);
|
|
317
|
+
fs.unlinkSync(newPath);
|
|
318
|
+
if (originalHash && hashFile(oldPath) !== publishedHash) {
|
|
319
|
+
throw new Error(`cannot verify rolled-back source ${oldPath}`);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
// Undo asset publication before restoring self-citing files at their old paths.
|
|
323
|
+
restoreRename(journal.oldPath, journal.newPath, journal.sourceOriginalHash, journal.expectedNewHash);
|
|
324
|
+
restoreRename(journal.twinOldPath, journal.twinNewPath, journal.twinOriginalHash, journal.expectedTwinNewHash);
|
|
325
|
+
for (const [index, citer] of journal.citers.entries()) {
|
|
326
|
+
if (!fs.existsSync(citer.backupPath)) {
|
|
327
|
+
throw new Error(`cannot restore ${citer.absPath}: backup is missing`);
|
|
328
|
+
}
|
|
329
|
+
const currentHash = fs.existsSync(citer.absPath) ? hashFile(citer.absPath) : null;
|
|
330
|
+
if (fs.existsSync(citer.ownedPath)) {
|
|
331
|
+
if (currentHash !== null && currentHash !== citer.replacementHash && currentHash !== citer.originalHash) {
|
|
332
|
+
throw new Error(`cannot restore ${citer.absPath}: file diverged after exclusive ownership`);
|
|
333
|
+
}
|
|
334
|
+
if (currentHash === citer.replacementHash)
|
|
335
|
+
fs.unlinkSync(citer.absPath);
|
|
336
|
+
if (!fs.existsSync(citer.absPath))
|
|
337
|
+
fs.linkSync(citer.ownedPath, citer.absPath);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (currentHash === citer.originalHash)
|
|
341
|
+
continue;
|
|
342
|
+
if (currentHash !== citer.replacementHash) {
|
|
343
|
+
throw new Error(`cannot restore ${citer.absPath}: file diverged after move planning`);
|
|
344
|
+
}
|
|
345
|
+
const restorePath = path.join(path.dirname(citer.backupPath), `restore-${index}`);
|
|
346
|
+
fs.copyFileSync(citer.backupPath, restorePath);
|
|
347
|
+
fs.chmodSync(restorePath, citer.mode);
|
|
348
|
+
fs.renameSync(restorePath, citer.absPath);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function validateCommittedMove(journal) {
|
|
352
|
+
if (fs.existsSync(journal.oldPath) || !fs.existsSync(journal.newPath)) {
|
|
353
|
+
throw new Error(`Cannot finalize move: expected only committed target ${journal.newPath}.`);
|
|
354
|
+
}
|
|
355
|
+
if (hashFile(journal.newPath) !== journal.expectedNewHash) {
|
|
356
|
+
throw new Error(`Cannot finalize move: committed target ${journal.newPath} diverged.`);
|
|
357
|
+
}
|
|
358
|
+
if (journal.twinNewPath) {
|
|
359
|
+
if (journal.twinOldPath && fs.existsSync(journal.twinOldPath)) {
|
|
360
|
+
throw new Error(`Cannot finalize move: old twin ${journal.twinOldPath} still exists.`);
|
|
361
|
+
}
|
|
362
|
+
if (!fs.existsSync(journal.twinNewPath) || hashFile(journal.twinNewPath) !== journal.expectedTwinNewHash) {
|
|
363
|
+
throw new Error(`Cannot finalize move: committed twin ${journal.twinNewPath} diverged.`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const citer of journal.citers) {
|
|
367
|
+
if (citer.absPath === journal.oldPath || citer.absPath === journal.twinOldPath)
|
|
368
|
+
continue;
|
|
369
|
+
if (!fs.existsSync(citer.absPath) || hashFile(citer.absPath) !== citer.replacementHash) {
|
|
370
|
+
throw new Error(`Cannot finalize move: citer ${citer.absPath} diverged.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function cleanupMoveTransaction(transactionDir) {
|
|
375
|
+
try {
|
|
376
|
+
fs.rmSync(transactionDir, { recursive: true, force: true });
|
|
377
|
+
const root = path.dirname(transactionDir);
|
|
378
|
+
try {
|
|
379
|
+
fs.rmdirSync(root);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Other transactions may still exist.
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const warning = `move committed but journal cleanup failed at ${transactionDir}: ${error instanceof Error ? error.message : String(error)}`;
|
|
388
|
+
warnVerbose(`akm mv: ${warning}`);
|
|
389
|
+
return warning;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export async function recoverInterruptedMoveTransactions(stashDir) {
|
|
393
|
+
const root = path.join(stashDir, ".akm", "mv-transactions");
|
|
394
|
+
if (!fs.existsSync(root))
|
|
395
|
+
return;
|
|
396
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
397
|
+
if (!entry.isDirectory())
|
|
398
|
+
continue;
|
|
399
|
+
const transactionDir = path.join(root, entry.name);
|
|
400
|
+
const journalPath = path.join(transactionDir, "journal.json");
|
|
401
|
+
if (!fs.existsSync(journalPath)) {
|
|
402
|
+
cleanupMoveTransaction(transactionDir);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
let journal;
|
|
406
|
+
try {
|
|
407
|
+
journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
throw new Error(`Cannot recover interrupted move journal at ${journalPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
411
|
+
}
|
|
412
|
+
if (journal.version !== 1)
|
|
413
|
+
throw new Error(`Unsupported move journal version at ${journalPath}.`);
|
|
414
|
+
const stashPaths = [journal.oldPath, journal.newPath, journal.twinOldPath, journal.twinNewPath]
|
|
415
|
+
.concat(journal.citers.map((citer) => citer.absPath))
|
|
416
|
+
.filter((candidate) => candidate !== null);
|
|
417
|
+
const transactionPaths = journal.citers.flatMap((citer) => [citer.backupPath, citer.stagedPath, citer.ownedPath]);
|
|
418
|
+
if (![
|
|
419
|
+
"prepared",
|
|
420
|
+
"applying",
|
|
421
|
+
"filesystem-committed",
|
|
422
|
+
"index-finalized",
|
|
423
|
+
"state-finalized",
|
|
424
|
+
"event-finalized",
|
|
425
|
+
"committed",
|
|
426
|
+
].includes(journal.phase) ||
|
|
427
|
+
stashPaths.some((candidate) => !isWithin(candidate, stashDir)) ||
|
|
428
|
+
transactionPaths.some((candidate) => !isWithin(candidate, transactionDir))) {
|
|
429
|
+
throw new Error(`Refusing unsafe move recovery journal at ${journalPath}.`);
|
|
430
|
+
}
|
|
431
|
+
const transaction = { journal, journalPath, transactionDir };
|
|
432
|
+
if (journal.phase === "prepared" || journal.phase === "applying") {
|
|
433
|
+
rollbackMoveJournal(journal);
|
|
434
|
+
}
|
|
435
|
+
else if (journal.phase !== "committed") {
|
|
436
|
+
validateCommittedMove(journal);
|
|
437
|
+
await finalizeMoveTransaction(transaction);
|
|
438
|
+
}
|
|
439
|
+
cleanupMoveTransaction(transactionDir);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function applyMoveFilesystem(opts) {
|
|
443
|
+
const transactionRoot = path.join(opts.stashDir, ".akm", "mv-transactions");
|
|
444
|
+
fs.mkdirSync(transactionRoot, { recursive: true, mode: 0o700 });
|
|
445
|
+
const transactionId = randomUUID();
|
|
446
|
+
const transactionDir = path.join(transactionRoot, transactionId);
|
|
447
|
+
fs.mkdirSync(transactionDir, { mode: 0o700 });
|
|
448
|
+
const journalPath = path.join(transactionDir, "journal.json");
|
|
449
|
+
let journal;
|
|
450
|
+
try {
|
|
451
|
+
const citers = opts.plans.map((plan, index) => {
|
|
452
|
+
const mode = fs.statSync(plan.absPath).mode;
|
|
453
|
+
const backupPath = path.join(transactionDir, `backup-${index}`);
|
|
454
|
+
const stagedPath = path.join(transactionDir, `staged-${index}`);
|
|
455
|
+
const ownedPath = path.join(transactionDir, `owned-${index}`);
|
|
456
|
+
if (hashFile(plan.absPath) !== plan.originalHash) {
|
|
457
|
+
throw new Error(`refusing to stage divergent citer ${plan.absPath}`);
|
|
458
|
+
}
|
|
459
|
+
fs.copyFileSync(plan.absPath, backupPath);
|
|
460
|
+
fs.chmodSync(backupPath, mode);
|
|
461
|
+
fs.writeFileSync(stagedPath, plan.content, { encoding: "utf8", mode });
|
|
462
|
+
fsyncFile(backupPath);
|
|
463
|
+
fsyncFile(stagedPath);
|
|
464
|
+
return {
|
|
465
|
+
absPath: plan.absPath,
|
|
466
|
+
backupPath,
|
|
467
|
+
stagedPath,
|
|
468
|
+
ownedPath,
|
|
469
|
+
mode,
|
|
470
|
+
originalHash: plan.originalHash,
|
|
471
|
+
replacementHash: hashContent(plan.content),
|
|
472
|
+
};
|
|
473
|
+
});
|
|
474
|
+
if (hashFile(opts.oldPath) !== opts.sourceOriginalHash)
|
|
475
|
+
throw new Error(`source ${opts.oldPath} diverged`);
|
|
476
|
+
if (opts.twinOldPath && opts.twinOriginalHash && hashFile(opts.twinOldPath) !== opts.twinOriginalHash) {
|
|
477
|
+
throw new Error(`twin ${opts.twinOldPath} diverged`);
|
|
478
|
+
}
|
|
479
|
+
const sourceCiter = citers.find((citer) => citer.absPath === opts.oldPath);
|
|
480
|
+
const twinCiter = citers.find((citer) => citer.absPath === opts.twinOldPath);
|
|
481
|
+
journal = {
|
|
482
|
+
version: 1,
|
|
483
|
+
phase: "prepared",
|
|
484
|
+
transactionId,
|
|
485
|
+
sourceName: opts.sourceName,
|
|
486
|
+
sourceRoot: opts.sourceRoot,
|
|
487
|
+
includeLegacyBare: opts.includeLegacyBare,
|
|
488
|
+
eventTs: new Date().toISOString(),
|
|
489
|
+
eventMetadata: opts.eventMetadata,
|
|
490
|
+
oldPath: opts.oldPath,
|
|
491
|
+
newPath: opts.newPath,
|
|
492
|
+
twinOldPath: opts.twinOldPath,
|
|
493
|
+
twinNewPath: opts.twinNewPath,
|
|
494
|
+
sourceOriginalHash: opts.sourceOriginalHash,
|
|
495
|
+
expectedNewHash: sourceCiter?.replacementHash ?? opts.sourceOriginalHash,
|
|
496
|
+
twinOriginalHash: opts.twinOriginalHash,
|
|
497
|
+
expectedTwinNewHash: twinCiter?.replacementHash ?? opts.twinOriginalHash,
|
|
498
|
+
type: opts.type,
|
|
499
|
+
oldName: opts.oldName,
|
|
500
|
+
newName: opts.newName,
|
|
501
|
+
fromRef: opts.fromRef,
|
|
502
|
+
toRef: opts.toRef,
|
|
503
|
+
citers,
|
|
504
|
+
};
|
|
505
|
+
writeMoveJournal(journalPath, journal);
|
|
506
|
+
const transaction = { journal, journalPath, transactionDir };
|
|
507
|
+
setMoveJournalPhase(transaction, "applying");
|
|
508
|
+
for (const citer of citers) {
|
|
509
|
+
fs.renameSync(citer.absPath, citer.ownedPath);
|
|
510
|
+
if (hashFile(citer.ownedPath) !== citer.originalHash) {
|
|
511
|
+
if (!fs.existsSync(citer.absPath))
|
|
512
|
+
fs.linkSync(citer.ownedPath, citer.absPath);
|
|
513
|
+
throw new Error(`refusing to replace divergent citer ${citer.absPath}`);
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
fs.linkSync(citer.stagedPath, citer.absPath);
|
|
517
|
+
fs.unlinkSync(citer.stagedPath);
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (!fs.existsSync(citer.absPath))
|
|
521
|
+
fs.linkSync(citer.ownedPath, citer.absPath);
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (hashFile(opts.oldPath) !== journal.expectedNewHash)
|
|
526
|
+
throw new Error(`source ${opts.oldPath} diverged`);
|
|
527
|
+
fs.linkSync(opts.oldPath, opts.newPath);
|
|
528
|
+
fs.unlinkSync(opts.oldPath);
|
|
529
|
+
if (opts.twinOldPath && opts.twinNewPath) {
|
|
530
|
+
if (hashFile(opts.twinOldPath) !== journal.expectedTwinNewHash)
|
|
531
|
+
throw new Error(`twin ${opts.twinOldPath} diverged`);
|
|
532
|
+
fs.linkSync(opts.twinOldPath, opts.twinNewPath);
|
|
533
|
+
fs.unlinkSync(opts.twinOldPath);
|
|
534
|
+
}
|
|
535
|
+
setMoveJournalPhase(transaction, "filesystem-committed");
|
|
536
|
+
return transaction;
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (journal) {
|
|
540
|
+
try {
|
|
541
|
+
rollbackMoveJournal(journal);
|
|
542
|
+
cleanupMoveTransaction(transactionDir);
|
|
543
|
+
}
|
|
544
|
+
catch (rollbackError) {
|
|
545
|
+
throw new Error(`Move failed (${error instanceof Error ? error.message : String(error)}) and rollback failed ` +
|
|
546
|
+
`(${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}). ` +
|
|
547
|
+
`Recovery journal retained at ${journalPath}.`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
cleanupMoveTransaction(transactionDir);
|
|
552
|
+
}
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
262
556
|
// ── Source resolution ─────────────────────────────────────────────────────────
|
|
263
557
|
/**
|
|
264
558
|
* Return the ON-DISK casing of `relPath` under `root` as a posix-separated
|
|
@@ -390,7 +684,7 @@ function rekeyIndexForMove(opts) {
|
|
|
390
684
|
const dbPath = getDbPath();
|
|
391
685
|
try {
|
|
392
686
|
if (!fs.existsSync(dbPath))
|
|
393
|
-
return { preserved: true, warning: null };
|
|
687
|
+
return { complete: true, preserved: true, warning: null };
|
|
394
688
|
let preserved = true;
|
|
395
689
|
const db = openExistingDatabase(dbPath);
|
|
396
690
|
try {
|
|
@@ -401,6 +695,8 @@ function rekeyIndexForMove(opts) {
|
|
|
401
695
|
const strandedRow = (movedFrom) => db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(movedFrom) != null;
|
|
402
696
|
const oldKey = `${opts.stashDir}:${opts.type}:${opts.oldName}`;
|
|
403
697
|
const newKey = `${opts.stashDir}:${opts.type}:${opts.newName}`;
|
|
698
|
+
const alreadyRekeyed = db.prepare("SELECT id FROM entries WHERE entry_key = ? AND file_path = ? LIMIT 1").get(newKey, opts.newPath) !=
|
|
699
|
+
null;
|
|
404
700
|
const rekeyed = rekeyEntryInPlace(db, {
|
|
405
701
|
oldEntryKey: oldKey,
|
|
406
702
|
newEntryKey: newKey,
|
|
@@ -408,13 +704,19 @@ function rekeyIndexForMove(opts) {
|
|
|
408
704
|
newFilePath: opts.newPath,
|
|
409
705
|
oldRef: opts.fromRef,
|
|
410
706
|
newRef: opts.toRef,
|
|
707
|
+
sourceName: opts.sourceName,
|
|
708
|
+
sourceRoot: opts.sourceRoot,
|
|
709
|
+
includeLegacyBare: opts.includeLegacyBare,
|
|
411
710
|
});
|
|
412
|
-
if (rekeyed === null && strandedRow(opts.oldPath))
|
|
711
|
+
if (rekeyed === null && !alreadyRekeyed && strandedRow(opts.oldPath))
|
|
413
712
|
preserved = false;
|
|
414
713
|
let twinRekeyed = null;
|
|
415
714
|
if (opts.twinNewPath) {
|
|
416
715
|
// The twin coupling (db.ts getBaseBeliefStatesForDerivedTwins) is
|
|
417
716
|
// `twin entry_key === base entry_key + ".derived"` — preserved here.
|
|
717
|
+
const twinAlreadyRekeyed = db
|
|
718
|
+
.prepare("SELECT id FROM entries WHERE entry_key = ? AND file_path = ? LIMIT 1")
|
|
719
|
+
.get(`${newKey}.derived`, opts.twinNewPath) != null;
|
|
418
720
|
twinRekeyed = rekeyEntryInPlace(db, {
|
|
419
721
|
oldEntryKey: `${oldKey}.derived`,
|
|
420
722
|
newEntryKey: `${newKey}.derived`,
|
|
@@ -423,13 +725,18 @@ function rekeyIndexForMove(opts) {
|
|
|
423
725
|
oldRef: `${opts.fromRef}.derived`,
|
|
424
726
|
newRef: `${opts.toRef}.derived`,
|
|
425
727
|
newDerivedFrom: opts.toRef,
|
|
728
|
+
sourceName: opts.sourceName,
|
|
729
|
+
sourceRoot: opts.sourceRoot,
|
|
730
|
+
includeLegacyBare: opts.includeLegacyBare,
|
|
426
731
|
});
|
|
427
|
-
if (twinRekeyed === null && opts.twinOldPath && strandedRow(opts.twinOldPath))
|
|
732
|
+
if (twinRekeyed === null && !twinAlreadyRekeyed && opts.twinOldPath && strandedRow(opts.twinOldPath)) {
|
|
428
733
|
preserved = false;
|
|
734
|
+
}
|
|
429
735
|
}
|
|
430
736
|
if (rekeyed !== null || twinRekeyed !== null) {
|
|
431
737
|
rebuildFts(db, { incremental: true });
|
|
432
738
|
}
|
|
739
|
+
mvMutationHookForTests?.("index-rekeyed");
|
|
433
740
|
}
|
|
434
741
|
finally {
|
|
435
742
|
closeDatabase(db);
|
|
@@ -438,16 +745,16 @@ function rekeyIndexForMove(opts) {
|
|
|
438
745
|
const warning = "index re-key skipped: the index holds a row for the moved file under an unexpected key — its utility " +
|
|
439
746
|
"history was not re-keyed and resets on the next `akm index`.";
|
|
440
747
|
warnVerbose(`akm mv: ${warning}`);
|
|
441
|
-
return { preserved: false, warning };
|
|
748
|
+
return { complete: false, preserved: false, warning };
|
|
442
749
|
}
|
|
443
|
-
return { preserved: true, warning: null };
|
|
750
|
+
return { complete: true, preserved: true, warning: null };
|
|
444
751
|
}
|
|
445
752
|
catch (error) {
|
|
446
753
|
const message = error instanceof Error ? error.message : String(error);
|
|
447
754
|
const warning = `index re-key failed (${message}) — the rename itself succeeded and the index heals on the next ` +
|
|
448
755
|
"`akm index`, but the asset's utility history was NOT re-keyed and resets on that run.";
|
|
449
756
|
warnVerbose(`akm mv: ${warning}`);
|
|
450
|
-
return { preserved: false, warning };
|
|
757
|
+
return { complete: false, preserved: false, warning };
|
|
451
758
|
}
|
|
452
759
|
}
|
|
453
760
|
/**
|
|
@@ -465,21 +772,35 @@ function rekeyIndexForMove(opts) {
|
|
|
465
772
|
* no file exists at the target — so the LIVE asset's history wins: the
|
|
466
773
|
* orphan row is deleted and the moved asset's row re-keyed onto the ref.
|
|
467
774
|
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
* succeeded. Returns the warning for the JSON report, or null.
|
|
775
|
+
* No state.db means the improve loop never ran and is complete as a no-op. A
|
|
776
|
+
* legacy missing table is likewise complete. Other failures retain the
|
|
777
|
+
* committed move journal and block completion so a later mutation retries the
|
|
778
|
+
* non-regenerable state update rather than silently stranding it.
|
|
473
779
|
*/
|
|
474
|
-
function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
|
|
780
|
+
function rekeyStateDbForMove(fromRef, toRef, includeTwin, sourceName, sourceRoot, includeLegacyBare) {
|
|
475
781
|
const statePath = getStateDbPath();
|
|
476
782
|
try {
|
|
477
783
|
if (!fs.existsSync(statePath))
|
|
478
|
-
return null;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
784
|
+
return { complete: true, warning: null };
|
|
785
|
+
if (!sourceName || !sourceRoot)
|
|
786
|
+
return { complete: false, warning: "move source identity is unavailable" };
|
|
787
|
+
const origins = new Set([sourceName]);
|
|
788
|
+
if (sourceName === "stash" && includeLegacyBare)
|
|
789
|
+
origins.add("local");
|
|
790
|
+
const pairs = [...origins].map((origin) => [
|
|
791
|
+
`${origin}//${fromRef}`,
|
|
792
|
+
`${origin}//${toRef}`,
|
|
793
|
+
]);
|
|
794
|
+
if (includeLegacyBare)
|
|
795
|
+
pairs.push([fromRef, toRef]);
|
|
796
|
+
if (includeTwin) {
|
|
797
|
+
for (const origin of origins) {
|
|
798
|
+
pairs.push([`${origin}//${fromRef}.derived`, `${origin}//${toRef}.derived`]);
|
|
799
|
+
}
|
|
800
|
+
if (includeLegacyBare)
|
|
801
|
+
pairs.push([`${fromRef}.derived`, `${toRef}.derived`]);
|
|
802
|
+
}
|
|
803
|
+
const db = openStateDatabase();
|
|
483
804
|
const tableFailures = [];
|
|
484
805
|
try {
|
|
485
806
|
db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
|
|
@@ -494,6 +815,7 @@ function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
|
|
|
494
815
|
db.prepare(`UPDATE ${table} SET asset_ref = ? WHERE asset_ref = ?`).run(newRef, oldRef);
|
|
495
816
|
}
|
|
496
817
|
})();
|
|
818
|
+
mvMutationHookForTests?.(`state-${table}-rekeyed`);
|
|
497
819
|
}
|
|
498
820
|
catch (error) {
|
|
499
821
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -514,18 +836,97 @@ function rekeyStateDbForMove(fromRef, toRef, includeTwin) {
|
|
|
514
836
|
const warning = `state.db salience re-key failed (${tableFailures.join("; ")}) — the rename itself succeeded, but the ` +
|
|
515
837
|
"asset's salience/outcome history stays keyed to the old ref until the next improve run re-mints it.";
|
|
516
838
|
warnVerbose(`akm mv: ${warning}`);
|
|
517
|
-
return warning;
|
|
839
|
+
return { complete: false, warning };
|
|
518
840
|
}
|
|
519
|
-
return null;
|
|
841
|
+
return { complete: true, warning: null };
|
|
520
842
|
}
|
|
521
843
|
catch (error) {
|
|
522
844
|
const message = error instanceof Error ? error.message : String(error);
|
|
523
845
|
const warning = `state.db salience re-key failed (${message}) — the rename itself succeeded, but the asset's salience/` +
|
|
524
846
|
"outcome history stays keyed to the old ref until the next improve run re-mints it.";
|
|
525
847
|
warnVerbose(`akm mv: ${warning}`);
|
|
526
|
-
return warning;
|
|
848
|
+
return { complete: false, warning };
|
|
527
849
|
}
|
|
528
850
|
}
|
|
851
|
+
function persistMoveEvent(journal) {
|
|
852
|
+
const db = openStateDatabase();
|
|
853
|
+
try {
|
|
854
|
+
db.transaction(() => {
|
|
855
|
+
insertEventOnce(db, {
|
|
856
|
+
eventType: "mv",
|
|
857
|
+
ts: journal.eventTs,
|
|
858
|
+
ref: journal.toRef,
|
|
859
|
+
metadata: {
|
|
860
|
+
...journal.eventMetadata,
|
|
861
|
+
mutationTransactionId: journal.transactionId,
|
|
862
|
+
},
|
|
863
|
+
idempotencyKey: journal.transactionId,
|
|
864
|
+
idempotencyMetadataKey: "mutationTransactionId",
|
|
865
|
+
});
|
|
866
|
+
})();
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
db.close();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function finalizeMoveTransaction(transaction) {
|
|
873
|
+
const { journal } = transaction;
|
|
874
|
+
validateCommittedMove(journal);
|
|
875
|
+
const warnings = [];
|
|
876
|
+
let utilityPreserved = true;
|
|
877
|
+
if (journal.phase === "filesystem-committed") {
|
|
878
|
+
const indexResult = rekeyIndexForMove({
|
|
879
|
+
stashDir: path.dirname(path.dirname(path.dirname(transaction.transactionDir))),
|
|
880
|
+
type: journal.type,
|
|
881
|
+
oldName: journal.oldName,
|
|
882
|
+
newName: journal.newName,
|
|
883
|
+
oldPath: journal.oldPath,
|
|
884
|
+
newPath: journal.newPath,
|
|
885
|
+
fromRef: journal.fromRef,
|
|
886
|
+
toRef: journal.toRef,
|
|
887
|
+
twinOldPath: journal.twinOldPath,
|
|
888
|
+
twinNewPath: journal.twinNewPath,
|
|
889
|
+
sourceName: journal.sourceName,
|
|
890
|
+
sourceRoot: journal.sourceRoot,
|
|
891
|
+
includeLegacyBare: journal.includeLegacyBare,
|
|
892
|
+
});
|
|
893
|
+
utilityPreserved = indexResult.preserved;
|
|
894
|
+
if (indexResult.warning)
|
|
895
|
+
warnings.push(indexResult.warning);
|
|
896
|
+
if (!indexResult.complete)
|
|
897
|
+
throw new Error(indexResult.warning ?? "move index re-key did not complete");
|
|
898
|
+
const touched = new Set([journal.newPath]);
|
|
899
|
+
if (journal.twinNewPath)
|
|
900
|
+
touched.add(journal.twinNewPath);
|
|
901
|
+
for (const citer of journal.citers) {
|
|
902
|
+
if (citer.absPath === journal.oldPath || citer.absPath === journal.twinOldPath)
|
|
903
|
+
continue;
|
|
904
|
+
touched.add(citer.absPath);
|
|
905
|
+
}
|
|
906
|
+
const stashDir = path.dirname(path.dirname(path.dirname(transaction.transactionDir)));
|
|
907
|
+
if (!(await indexWrittenAssets(stashDir, [...touched], { recoverMoves: false }))) {
|
|
908
|
+
utilityPreserved = false;
|
|
909
|
+
warnings.push("write-path index refresh failed; the derived index will heal on the next full index");
|
|
910
|
+
}
|
|
911
|
+
setMoveJournalPhase(transaction, "index-finalized");
|
|
912
|
+
}
|
|
913
|
+
if (journal.phase === "index-finalized") {
|
|
914
|
+
const stateResult = rekeyStateDbForMove(journal.fromRef, journal.toRef, journal.twinNewPath !== null, journal.sourceName, journal.sourceRoot, journal.includeLegacyBare);
|
|
915
|
+
if (stateResult.warning)
|
|
916
|
+
warnings.push(stateResult.warning);
|
|
917
|
+
if (!stateResult.complete)
|
|
918
|
+
throw new Error(stateResult.warning ?? "move state finalization did not complete");
|
|
919
|
+
setMoveJournalPhase(transaction, "state-finalized");
|
|
920
|
+
}
|
|
921
|
+
if (journal.phase === "state-finalized") {
|
|
922
|
+
persistMoveEvent(journal);
|
|
923
|
+
mvMutationHookForTests?.("mv-event-persisted");
|
|
924
|
+
setMoveJournalPhase(transaction, "event-finalized");
|
|
925
|
+
}
|
|
926
|
+
if (journal.phase === "event-finalized")
|
|
927
|
+
setMoveJournalPhase(transaction, "committed");
|
|
928
|
+
return { utilityPreserved, warnings };
|
|
929
|
+
}
|
|
529
930
|
// ── Command ───────────────────────────────────────────────────────────────────
|
|
530
931
|
export const mvCommand = defineJsonCommand({
|
|
531
932
|
meta: {
|
|
@@ -566,161 +967,145 @@ export const mvCommand = defineJsonCommand({
|
|
|
566
967
|
if (!refArg || !targetArg) {
|
|
567
968
|
throw new UsageError("Usage: akm mv <ref> <new-name>.", "MISSING_REQUIRED_ARGUMENT", "Pass the asset's current ref and its new name, e.g. `akm mv memory:projectA/old-note projectA/new-note`.");
|
|
568
969
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (source.type === "wiki") {
|
|
575
|
-
throw new UsageError("akm mv does not support wiki refs — wiki pages have their own xref + lint system. " +
|
|
576
|
-
"Rename the page manually, fix citations in the same pass, and verify with `akm wiki lint <name>`.", "INVALID_FLAG_VALUE");
|
|
577
|
-
}
|
|
578
|
-
if (source.type === "workflow") {
|
|
579
|
-
throw new UsageError("akm mv does not support workflow refs in v1 — workflows may live as .yaml/.yml programs, which the " +
|
|
580
|
-
"flat-markdown rename path would misresolve or rename to .md. Rename the file manually under " +
|
|
581
|
-
"workflows/ (keeping its extension), fix inbound refs in the same pass, and verify with `akm lint`.", "INVALID_FLAG_VALUE");
|
|
582
|
-
}
|
|
583
|
-
if (!MV_SUPPORTED_TYPES.includes(source.type)) {
|
|
584
|
-
throw new UsageError(`akm mv supports flat-markdown asset types (${MV_SUPPORTED_TYPES.join(", ")}); "${source.type}:" refs cannot be moved.`, "INVALID_FLAG_VALUE");
|
|
585
|
-
}
|
|
586
|
-
// The `.derived` suffix is the distilled-twin marker: a twin's entry_key
|
|
587
|
-
// must stay exactly `<base entry_key>.derived` (db.ts
|
|
588
|
-
// getBaseBeliefStatesForDerivedTwins), so a twin can never move alone and
|
|
589
|
-
// no independent asset may squat on the suffix. The `.md`-suffixed alias
|
|
590
|
-
// spelling of a twin ref names the same file, so it is caught here too.
|
|
591
|
-
if (source.type === "memory" && /\.derived(\.md)?$/.test(source.name)) {
|
|
592
|
-
const baseRef = refToString({ type: "memory", name: source.name.replace(/\.derived(\.md)?$/, "") });
|
|
593
|
-
throw new UsageError(`"${refToString({ type: source.type, name: source.name })}" names a .derived.md distilled twin — a twin ` +
|
|
594
|
-
"cannot be moved on its own without breaking its belief-inheritance coupling to the base memory. " +
|
|
595
|
-
`Rename the base ref instead (akm mv ${baseRef} <new-name>); the twin moves with it.`, "INVALID_FLAG_VALUE");
|
|
596
|
-
}
|
|
597
|
-
// The target may be a bare name ("projectA/new-note") or a ref-shaped
|
|
598
|
-
// spelling. Parsing the bare form through the same ref grammar gives it
|
|
599
|
-
// identical name validation (traversal, null bytes, absolute paths).
|
|
600
|
-
const target = parseAssetRef(targetArg.includes(":") ? targetArg : `${source.type}:${targetArg}`);
|
|
601
|
-
if (target.origin) {
|
|
602
|
-
throw new UsageError(`The target must be a name within the ${source.type} type — origin prefixes are not supported.`, "INVALID_FLAG_VALUE");
|
|
603
|
-
}
|
|
604
|
-
if (target.type !== source.type) {
|
|
605
|
-
throw new UsageError(`Cross-type move is not supported: "${refToString({ type: source.type, name: source.name })}" is a ` +
|
|
606
|
-
`${source.type}: asset but the target names the ${target.type}: type. akm mv renames within one asset type.`, "INVALID_FLAG_VALUE");
|
|
607
|
-
}
|
|
608
|
-
// Accept the `.md`-suffixed alias spelling of the TARGET, but operate on
|
|
609
|
-
// the canonical extensionless name: every MV_SUPPORTED_TYPES layout is
|
|
610
|
-
// the markdownSpec family, whose `toAssetPath` writes `<name>.md` either
|
|
611
|
-
// way — so `bar.md` names the same file as `bar`, while a `bar.md`-keyed
|
|
612
|
-
// toRef/entry_key would rewrite citers to a non-canonical ref and strand
|
|
613
|
-
// the re-keyed history behind a row the write-path index pass (which
|
|
614
|
-
// derives the canonical name `bar` from the file) immediately duplicates.
|
|
615
|
-
const newName = target.name.endsWith(".md") ? target.name.slice(0, -".md".length) : target.name;
|
|
616
|
-
if (!newName) {
|
|
617
|
-
throw new UsageError(`Target "${targetArg}" names no asset once the .md extension is stripped — nothing moved.`, "INVALID_FLAG_VALUE");
|
|
618
|
-
}
|
|
619
|
-
// Reject empty path segments: `path.posix.normalize` (parseAssetRef's
|
|
620
|
-
// name normalization) PRESERVES a trailing slash — "bar/" (and "bar\",
|
|
621
|
-
// normalized to it) sails through the traversal checks, and the file
|
|
622
|
-
// would land at e.g. memories/bar/.md: a dot-prefixed file the index
|
|
623
|
-
// walker skips, unreachable by `akm show`, with every citer rewritten to
|
|
624
|
-
// the phantom ref "memory:bar/". Interior doubles ("a//b") are collapsed
|
|
625
|
-
// by the normalization, so a trailing empty segment is the only shape
|
|
626
|
-
// that reaches this check — but reject ANY empty segment regardless.
|
|
627
|
-
if (newName.split("/").some((segment) => segment.length === 0)) {
|
|
628
|
-
throw new UsageError(`Target "${targetArg}" contains an empty path segment (trailing "/" or "\\") — the file would be written ` +
|
|
629
|
-
"as a hidden dotfile the index cannot see. Pass a name, e.g. `akm mv <ref> projectA/new-note` — nothing moved.", "INVALID_FLAG_VALUE");
|
|
630
|
-
}
|
|
631
|
-
if (source.type === "memory" && newName.endsWith(".derived")) {
|
|
632
|
-
throw new UsageError(`The target name "${newName}" ends with the reserved .derived suffix (the distilled-twin marker) — a base ` +
|
|
633
|
-
"memory renamed onto it would masquerade as a twin of a memory that does not exist. Pick a name without " +
|
|
634
|
-
"the suffix; a real twin always moves together with its base.", "INVALID_FLAG_VALUE");
|
|
635
|
-
}
|
|
636
|
-
const toRef = refToString({ type: source.type, name: newName });
|
|
637
|
-
const stashDir = resolveStashDir();
|
|
638
|
-
const typeDir = TYPE_DIRS[source.type];
|
|
639
|
-
const typeRoot = path.join(stashDir, typeDir);
|
|
640
|
-
const oldRelPath = refToRelPath(source.type, source.name);
|
|
641
|
-
const newRelPath = refToRelPath(source.type, newName);
|
|
642
|
-
if (!oldRelPath || !newRelPath) {
|
|
643
|
-
// Unreachable for MV_SUPPORTED_TYPES; guards a future registry change.
|
|
644
|
-
throw new UsageError(`"${source.type}:" refs are not path-resolvable and cannot be moved.`, "INVALID_FLAG_VALUE");
|
|
645
|
-
}
|
|
646
|
-
const oldPath = resolveMoveSourcePath(stashDir, oldRelPath, source.type, source.name);
|
|
647
|
-
if (!oldPath) {
|
|
648
|
-
throw new UsageError(`Cannot resolve ${refToString({ type: source.type, name: source.name })} in the writable stash at ` +
|
|
649
|
-
`${stashDir} — nothing moved.`, "MISSING_REQUIRED_ARGUMENT", "akm mv renames assets in the primary writable stash only. Check the ref with `akm show <ref>` or `akm search`.");
|
|
650
|
-
}
|
|
651
|
-
// The accepted spelling may be the `.md`-suffixed alias of the same file
|
|
652
|
-
// (markdownSpec.toAssetPath maps `foo` and `foo.md` to memories/foo.md).
|
|
653
|
-
// Everything keyed off the source — the citer rewrite patterns, the index
|
|
654
|
-
// entry_key re-key, the state.db asset_ref re-key, the report — must use
|
|
655
|
-
// the CANONICAL extensionless name derived from the resolved path, or the
|
|
656
|
-
// real rows (keyed by the canonical spelling) are silently missed.
|
|
657
|
-
const sourceName = deriveCanonicalAssetNameFromStashRoot(source.type, stashDir, oldPath) ?? source.name;
|
|
658
|
-
const fromRef = refToString({ type: source.type, name: sourceName });
|
|
659
|
-
const newPath = path.join(stashDir, newRelPath);
|
|
660
|
-
// Defense-in-depth: parseAssetRef already rejects `../` traversal, but the
|
|
661
|
-
// computed target must land inside the type root regardless.
|
|
662
|
-
if (!isWithin(newPath, typeRoot)) {
|
|
663
|
-
throw new UsageError(`Target "${targetArg}" escapes the ${typeDir}/ type root — nothing moved.`, "PATH_ESCAPE_VIOLATION");
|
|
664
|
-
}
|
|
665
|
-
if (path.resolve(newPath) === path.resolve(oldPath)) {
|
|
666
|
-
throw new UsageError(`Source and target resolve to the same file (${fromRef}) — nothing to move.`);
|
|
667
|
-
}
|
|
668
|
-
if (fs.existsSync(newPath)) {
|
|
669
|
-
throw new UsageError(`Target ${toRef} already exists at ${toPosix(path.relative(stashDir, newPath))} — nothing moved.`, "RESOURCE_ALREADY_EXISTS", "Pick an unused name, or move the existing asset out of the way first.");
|
|
670
|
-
}
|
|
671
|
-
// Memory `.derived.md` twin: moves together with its base (the entry_key
|
|
672
|
-
// suffix coupling the belief-state inheritance relies on). The TARGET
|
|
673
|
-
// twin-collision check runs whenever the target could carry a twin —
|
|
674
|
-
// NOT only when the source has one: renaming a twin-less memory onto a
|
|
675
|
-
// name whose orphaned `<name>.derived.md` lingers (consolidate/dedup
|
|
676
|
-
// delete the base file without twin cleanup) would silently adopt the
|
|
677
|
-
// stranger file as the renamed memory's distillation.
|
|
678
|
-
const isBaseMemory = source.type === "memory" && !sourceName.endsWith(".derived");
|
|
679
|
-
const twinOldPath = isBaseMemory ? oldPath.replace(/\.md$/, ".derived.md") : null;
|
|
680
|
-
const hasTwin = twinOldPath !== null && fs.existsSync(twinOldPath);
|
|
681
|
-
const targetTwinPath = isBaseMemory ? newPath.replace(/\.md$/, ".derived.md") : null;
|
|
682
|
-
if (targetTwinPath && fs.existsSync(targetTwinPath)) {
|
|
683
|
-
throw new UsageError(`Target twin ${toRef}.derived already exists at ${toPosix(path.relative(stashDir, targetTwinPath))} — ` +
|
|
684
|
-
"renaming onto it would adopt that orphaned distilled twin as this memory's own. Nothing moved.", "RESOURCE_ALREADY_EXISTS", "Pick an unused name, or delete the orphaned .derived.md file first if it belongs to a removed memory.");
|
|
685
|
-
}
|
|
686
|
-
const twinNewPath = hasTwin ? targetTwinPath : null;
|
|
687
|
-
// ── Plan the inbound-ref rewrite (no writes yet) ───────────────────────
|
|
688
|
-
const rewriteCtx = buildRewriteContext({
|
|
689
|
-
type: source.type,
|
|
690
|
-
fromRef,
|
|
691
|
-
toRef,
|
|
692
|
-
isBaseMemory,
|
|
693
|
-
stashDir,
|
|
694
|
-
oldPath,
|
|
695
|
-
twinOldPath: hasTwin ? twinOldPath : null,
|
|
696
|
-
});
|
|
697
|
-
const plans = [];
|
|
698
|
-
for (const absPath of collectCiterFiles(stashDir)) {
|
|
699
|
-
let raw;
|
|
700
|
-
try {
|
|
701
|
-
raw = fs.readFileSync(absPath, "utf8");
|
|
970
|
+
await withAssetMutationLease("mv", async () => {
|
|
971
|
+
// ── Validation (everything before any write; a failure moves nothing) ──
|
|
972
|
+
const source = parseAssetRef(refArg);
|
|
973
|
+
if (source.origin && source.origin !== "local") {
|
|
974
|
+
throw new UsageError(`akm mv operates on the primary writable stash only — the origin prefix "${source.origin}//" is not supported.`, "INVALID_FLAG_VALUE");
|
|
702
975
|
}
|
|
703
|
-
|
|
704
|
-
|
|
976
|
+
if (source.type === "wiki") {
|
|
977
|
+
throw new UsageError("akm mv does not support wiki refs — wiki pages have their own xref + lint system. " +
|
|
978
|
+
"Rename the page manually, fix citations in the same pass, and verify with `akm wiki lint <name>`.", "INVALID_FLAG_VALUE");
|
|
705
979
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
980
|
+
if (source.type === "workflow") {
|
|
981
|
+
throw new UsageError("akm mv does not support workflow refs in v1 — workflows may live as .yaml/.yml programs, which the " +
|
|
982
|
+
"flat-markdown rename path would misresolve or rename to .md. Rename the file manually under " +
|
|
983
|
+
"workflows/ (keeping its extension), fix inbound refs in the same pass, and verify with `akm lint`.", "INVALID_FLAG_VALUE");
|
|
709
984
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
985
|
+
if (!MV_SUPPORTED_TYPES.includes(source.type)) {
|
|
986
|
+
throw new UsageError(`akm mv supports flat-markdown asset types (${MV_SUPPORTED_TYPES.join(", ")}); "${source.type}:" refs cannot be moved.`, "INVALID_FLAG_VALUE");
|
|
987
|
+
}
|
|
988
|
+
// The `.derived` suffix is the distilled-twin marker: a twin's entry_key
|
|
989
|
+
// must stay exactly `<base entry_key>.derived` (db.ts
|
|
990
|
+
// getBaseBeliefStatesForDerivedTwins), so a twin can never move alone and
|
|
991
|
+
// no independent asset may squat on the suffix. The `.md`-suffixed alias
|
|
992
|
+
// spelling of a twin ref names the same file, so it is caught here too.
|
|
993
|
+
if (source.type === "memory" && /\.derived(\.md)?$/.test(source.name)) {
|
|
994
|
+
const baseRef = refToString({ type: "memory", name: source.name.replace(/\.derived(\.md)?$/, "") });
|
|
995
|
+
throw new UsageError(`"${refToString({ type: source.type, name: source.name })}" names a .derived.md distilled twin — a twin ` +
|
|
996
|
+
"cannot be moved on its own without breaking its belief-inheritance coupling to the base memory. " +
|
|
997
|
+
`Rename the base ref instead (akm mv ${baseRef} <new-name>); the twin moves with it.`, "INVALID_FLAG_VALUE");
|
|
998
|
+
}
|
|
999
|
+
// The target may be a bare name ("projectA/new-note") or a ref-shaped
|
|
1000
|
+
// spelling. Parsing the bare form through the same ref grammar gives it
|
|
1001
|
+
// identical name validation (traversal, null bytes, absolute paths).
|
|
1002
|
+
const target = parseAssetRef(targetArg.includes(":") ? targetArg : `${source.type}:${targetArg}`);
|
|
1003
|
+
if (target.origin) {
|
|
1004
|
+
throw new UsageError(`The target must be a name within the ${source.type} type — origin prefixes are not supported.`, "INVALID_FLAG_VALUE");
|
|
1005
|
+
}
|
|
1006
|
+
if (target.type !== source.type) {
|
|
1007
|
+
throw new UsageError(`Cross-type move is not supported: "${refToString({ type: source.type, name: source.name })}" is a ` +
|
|
1008
|
+
`${source.type}: asset but the target names the ${target.type}: type. akm mv renames within one asset type.`, "INVALID_FLAG_VALUE");
|
|
1009
|
+
}
|
|
1010
|
+
// Accept the `.md`-suffixed alias spelling of the TARGET, but operate on
|
|
1011
|
+
// the canonical extensionless name: every MV_SUPPORTED_TYPES layout is
|
|
1012
|
+
// the markdownSpec family, whose `toAssetPath` writes `<name>.md` either
|
|
1013
|
+
// way — so `bar.md` names the same file as `bar`, while a `bar.md`-keyed
|
|
1014
|
+
// toRef/entry_key would rewrite citers to a non-canonical ref and strand
|
|
1015
|
+
// the re-keyed history behind a row the write-path index pass (which
|
|
1016
|
+
// derives the canonical name `bar` from the file) immediately duplicates.
|
|
1017
|
+
const newName = target.name.endsWith(".md") ? target.name.slice(0, -".md".length) : target.name;
|
|
1018
|
+
if (!newName) {
|
|
1019
|
+
throw new UsageError(`Target "${targetArg}" names no asset once the .md extension is stripped — nothing moved.`, "INVALID_FLAG_VALUE");
|
|
1020
|
+
}
|
|
1021
|
+
// Reject empty path segments: `path.posix.normalize` (parseAssetRef's
|
|
1022
|
+
// name normalization) PRESERVES a trailing slash — "bar/" (and "bar\",
|
|
1023
|
+
// normalized to it) sails through the traversal checks, and the file
|
|
1024
|
+
// would land at e.g. memories/bar/.md: a dot-prefixed file the index
|
|
1025
|
+
// walker skips, unreachable by `akm show`, with every citer rewritten to
|
|
1026
|
+
// the phantom ref "memory:bar/". Interior doubles ("a//b") are collapsed
|
|
1027
|
+
// by the normalization, so a trailing empty segment is the only shape
|
|
1028
|
+
// that reaches this check — but reject ANY empty segment regardless.
|
|
1029
|
+
if (newName.split("/").some((segment) => segment.length === 0)) {
|
|
1030
|
+
throw new UsageError(`Target "${targetArg}" contains an empty path segment (trailing "/" or "\\") — the file would be written ` +
|
|
1031
|
+
"as a hidden dotfile the index cannot see. Pass a name, e.g. `akm mv <ref> projectA/new-note` — nothing moved.", "INVALID_FLAG_VALUE");
|
|
1032
|
+
}
|
|
1033
|
+
if (source.type === "memory" && newName.endsWith(".derived")) {
|
|
1034
|
+
throw new UsageError(`The target name "${newName}" ends with the reserved .derived suffix (the distilled-twin marker) — a base ` +
|
|
1035
|
+
"memory renamed onto it would masquerade as a twin of a memory that does not exist. Pick a name without " +
|
|
1036
|
+
"the suffix; a real twin always moves together with its base.", "INVALID_FLAG_VALUE");
|
|
1037
|
+
}
|
|
1038
|
+
const toRef = refToString({ type: source.type, name: newName });
|
|
1039
|
+
const stashDir = resolveStashDir();
|
|
1040
|
+
const config = loadConfig();
|
|
1041
|
+
const configuredSources = resolveSourceEntries(stashDir, config);
|
|
1042
|
+
const primarySource = configuredSources.find((entry) => path.resolve(entry.path) === path.resolve(stashDir));
|
|
1043
|
+
const durableSourceName = primarySource?.registryId ?? "stash";
|
|
1044
|
+
const includeLegacyBare = shouldReadLegacyBareImproveState(durableSourceName, stashDir, config);
|
|
1045
|
+
await recoverInterruptedMoveTransactions(stashDir);
|
|
1046
|
+
const typeDir = TYPE_DIRS[source.type];
|
|
1047
|
+
const typeRoot = path.join(stashDir, typeDir);
|
|
1048
|
+
const oldRelPath = refToRelPath(source.type, source.name);
|
|
1049
|
+
const newRelPath = refToRelPath(source.type, newName);
|
|
1050
|
+
if (!oldRelPath || !newRelPath) {
|
|
1051
|
+
// Unreachable for MV_SUPPORTED_TYPES; guards a future registry change.
|
|
1052
|
+
throw new UsageError(`"${source.type}:" refs are not path-resolvable and cannot be moved.`, "INVALID_FLAG_VALUE");
|
|
1053
|
+
}
|
|
1054
|
+
const oldPath = resolveMoveSourcePath(stashDir, oldRelPath, source.type, source.name);
|
|
1055
|
+
if (!oldPath) {
|
|
1056
|
+
throw new UsageError(`Cannot resolve ${refToString({ type: source.type, name: source.name })} in the writable stash at ` +
|
|
1057
|
+
`${stashDir} — nothing moved.`, "MISSING_REQUIRED_ARGUMENT", "akm mv renames assets in the primary writable stash only. Check the ref with `akm show <ref>` or `akm search`.");
|
|
1058
|
+
}
|
|
1059
|
+
// The accepted spelling may be the `.md`-suffixed alias of the same file
|
|
1060
|
+
// (markdownSpec.toAssetPath maps `foo` and `foo.md` to memories/foo.md).
|
|
1061
|
+
// Everything keyed off the source — the citer rewrite patterns, the index
|
|
1062
|
+
// entry_key re-key, the state.db asset_ref re-key, the report — must use
|
|
1063
|
+
// the CANONICAL extensionless name derived from the resolved path, or the
|
|
1064
|
+
// real rows (keyed by the canonical spelling) are silently missed.
|
|
1065
|
+
const sourceName = deriveCanonicalAssetNameFromStashRoot(source.type, stashDir, oldPath) ?? source.name;
|
|
1066
|
+
const fromRef = refToString({ type: source.type, name: sourceName });
|
|
1067
|
+
const newPath = path.join(stashDir, newRelPath);
|
|
1068
|
+
// Defense-in-depth: parseAssetRef already rejects `../` traversal, but the
|
|
1069
|
+
// computed target must land inside the type root regardless.
|
|
1070
|
+
if (!isWithin(newPath, typeRoot)) {
|
|
1071
|
+
throw new UsageError(`Target "${targetArg}" escapes the ${typeDir}/ type root — nothing moved.`, "PATH_ESCAPE_VIOLATION");
|
|
1072
|
+
}
|
|
1073
|
+
if (path.resolve(newPath) === path.resolve(oldPath)) {
|
|
1074
|
+
throw new UsageError(`Source and target resolve to the same file (${fromRef}) — nothing to move.`);
|
|
1075
|
+
}
|
|
1076
|
+
if (fs.existsSync(newPath)) {
|
|
1077
|
+
throw new UsageError(`Target ${toRef} already exists at ${toPosix(path.relative(stashDir, newPath))} — nothing moved.`, "RESOURCE_ALREADY_EXISTS", "Pick an unused name, or move the existing asset out of the way first.");
|
|
1078
|
+
}
|
|
1079
|
+
// Memory `.derived.md` twin: moves together with its base (the entry_key
|
|
1080
|
+
// suffix coupling the belief-state inheritance relies on). The TARGET
|
|
1081
|
+
// twin-collision check runs whenever the target could carry a twin —
|
|
1082
|
+
// NOT only when the source has one: renaming a twin-less memory onto a
|
|
1083
|
+
// name whose orphaned `<name>.derived.md` lingers (consolidate/dedup
|
|
1084
|
+
// delete the base file without twin cleanup) would silently adopt the
|
|
1085
|
+
// stranger file as the renamed memory's distillation.
|
|
1086
|
+
const isBaseMemory = source.type === "memory" && !sourceName.endsWith(".derived");
|
|
1087
|
+
const twinOldPath = isBaseMemory ? oldPath.replace(/\.md$/, ".derived.md") : null;
|
|
1088
|
+
const hasTwin = twinOldPath !== null && fs.existsSync(twinOldPath);
|
|
1089
|
+
const targetTwinPath = isBaseMemory ? newPath.replace(/\.md$/, ".derived.md") : null;
|
|
1090
|
+
if (targetTwinPath && fs.existsSync(targetTwinPath)) {
|
|
1091
|
+
throw new UsageError(`Target twin ${toRef}.derived already exists at ${toPosix(path.relative(stashDir, targetTwinPath))} — ` +
|
|
1092
|
+
"renaming onto it would adopt that orphaned distilled twin as this memory's own. Nothing moved.", "RESOURCE_ALREADY_EXISTS", "Pick an unused name, or delete the orphaned .derived.md file first if it belongs to a removed memory.");
|
|
1093
|
+
}
|
|
1094
|
+
const twinNewPath = hasTwin ? targetTwinPath : null;
|
|
1095
|
+
const sourceOriginalHash = hashFile(oldPath);
|
|
1096
|
+
const twinOriginalHash = hasTwin && twinOldPath ? hashFile(twinOldPath) : null;
|
|
1097
|
+
// ── Plan the inbound-ref rewrite (no writes yet) ───────────────────────
|
|
1098
|
+
const rewriteCtx = buildRewriteContext({
|
|
1099
|
+
type: source.type,
|
|
1100
|
+
fromRef,
|
|
1101
|
+
toRef,
|
|
1102
|
+
isBaseMemory,
|
|
1103
|
+
stashDir,
|
|
1104
|
+
oldPath,
|
|
1105
|
+
twinOldPath: hasTwin ? twinOldPath : null,
|
|
1106
|
+
});
|
|
1107
|
+
const plans = [];
|
|
1108
|
+
for (const absPath of collectCiterFiles(stashDir)) {
|
|
724
1109
|
let raw;
|
|
725
1110
|
try {
|
|
726
1111
|
raw = fs.readFileSync(absPath, "utf8");
|
|
@@ -728,82 +1113,86 @@ export const mvCommand = defineJsonCommand({
|
|
|
728
1113
|
catch {
|
|
729
1114
|
continue;
|
|
730
1115
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1116
|
+
const { content, count } = rewriteRefs(raw, rewriteCtx);
|
|
1117
|
+
if (count > 0) {
|
|
1118
|
+
plans.push({
|
|
1119
|
+
absPath,
|
|
1120
|
+
relPath: toPosix(path.relative(stashDir, absPath)),
|
|
1121
|
+
count,
|
|
1122
|
+
content,
|
|
1123
|
+
originalHash: hashContent(raw),
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// Read-only sources: scanned, never written — manual follow-ups.
|
|
1128
|
+
const readOnlyCiters = [];
|
|
1129
|
+
for (const src of configuredSources) {
|
|
1130
|
+
if (path.resolve(src.path) === path.resolve(stashDir))
|
|
1131
|
+
continue;
|
|
1132
|
+
for (const absPath of collectCiterFiles(src.path)) {
|
|
1133
|
+
let raw;
|
|
1134
|
+
try {
|
|
1135
|
+
raw = fs.readFileSync(absPath, "utf8");
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
// Same detection as the writable pass (canonical + alias spellings,
|
|
1141
|
+
// alias tokens resolved against the WRITABLE stash where the moved
|
|
1142
|
+
// file lives) — count-only, never written.
|
|
1143
|
+
const { count } = rewriteRefs(raw, rewriteCtx);
|
|
1144
|
+
if (count > 0)
|
|
1145
|
+
readOnlyCiters.push({ file: absPath, count });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// ── Apply citer edits, then rename last (see module docstring) ────────
|
|
1149
|
+
// The target's parent directory is created FIRST: if it cannot be (a
|
|
1150
|
+
// segment of the target's subdirectory path exists as a FILE, or the
|
|
1151
|
+
// parent is unwritable), the command must abort before any citer has
|
|
1152
|
+
// been edited — otherwise citers would already point at a ref whose
|
|
1153
|
+
// file never arrives.
|
|
1154
|
+
fs.mkdirSync(path.dirname(newPath), { recursive: true });
|
|
1155
|
+
const transaction = applyMoveFilesystem({
|
|
1156
|
+
stashDir,
|
|
1157
|
+
oldPath,
|
|
1158
|
+
newPath,
|
|
1159
|
+
twinOldPath: hasTwin ? twinOldPath : null,
|
|
1160
|
+
twinNewPath,
|
|
1161
|
+
sourceOriginalHash,
|
|
1162
|
+
twinOriginalHash,
|
|
1163
|
+
type: source.type,
|
|
1164
|
+
oldName: sourceName,
|
|
1165
|
+
newName,
|
|
1166
|
+
fromRef,
|
|
1167
|
+
toRef,
|
|
1168
|
+
sourceName: durableSourceName,
|
|
1169
|
+
sourceRoot: stashDir,
|
|
1170
|
+
includeLegacyBare,
|
|
1171
|
+
eventMetadata: {
|
|
1172
|
+
from: fromRef,
|
|
1173
|
+
to: toRef,
|
|
1174
|
+
rewroteFiles: plans.length,
|
|
1175
|
+
readOnlyCiters: readOnlyCiters.length,
|
|
1176
|
+
twinMoved: hasTwin,
|
|
1177
|
+
},
|
|
1178
|
+
plans,
|
|
1179
|
+
});
|
|
1180
|
+
// Filesystem commit is irreversible. Any finalization error leaves the
|
|
1181
|
+
// journal for the next mutation to finish forward; it never rolls back.
|
|
1182
|
+
const finalized = await finalizeMoveTransaction(transaction);
|
|
1183
|
+
const cleanupWarning = cleanupMoveTransaction(transaction.transactionDir);
|
|
1184
|
+
const warnings = [...finalized.warnings, ...(cleanupWarning ? [cleanupWarning] : [])];
|
|
1185
|
+
output("mv", {
|
|
1186
|
+
ok: true,
|
|
790
1187
|
from: fromRef,
|
|
791
1188
|
to: toRef,
|
|
792
|
-
|
|
793
|
-
readOnlyCiters
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
from: fromRef,
|
|
800
|
-
to: toRef,
|
|
801
|
-
rewrote: plans.map((plan) => ({ file: plan.relPath, count: plan.count })),
|
|
802
|
-
readOnlyCiters,
|
|
803
|
-
utilityPreserved,
|
|
804
|
-
// Additive: present only when a re-key could not be completed, so the
|
|
805
|
-
// report (not just --verbose stderr) says WHY history may reset.
|
|
806
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
1189
|
+
rewrote: plans.map((plan) => ({ file: plan.relPath, count: plan.count })),
|
|
1190
|
+
readOnlyCiters,
|
|
1191
|
+
utilityPreserved: finalized.utilityPreserved,
|
|
1192
|
+
// Additive: present only when a re-key could not be completed, so the
|
|
1193
|
+
// report (not just --verbose stderr) says WHY history may reset.
|
|
1194
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1195
|
+
});
|
|
807
1196
|
});
|
|
808
1197
|
},
|
|
809
1198
|
});
|