@drewpayment/mink 0.11.0-beta.2 → 0.11.0-beta.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.
Files changed (43) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.js +114 -20
  39. package/package.json +1 -1
  40. package/src/commands/sync-migrate.ts +175 -25
  41. package/src/core/sync.ts +7 -0
  42. /package/dashboard/out/_next/static/{WyN-sdaVY7cZaACRaK7vq → fci7mSuW5y3ri6IlmLojm}/_buildManifest.js +0 -0
  43. /package/dashboard/out/_next/static/{WyN-sdaVY7cZaACRaK7vq → fci7mSuW5y3ri6IlmLojm}/_ssgManifest.js +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  writeFileSync,
7
7
  readFileSync,
8
8
  renameSync,
9
+ rmSync,
9
10
  unlinkSync,
10
11
  } from "fs";
11
12
  import { join } from "path";
@@ -236,7 +237,24 @@ function listProjectsNeedingMigration(): string[] {
236
237
  // Idempotent: re-running after a clean pass walks every project, finds every
237
238
  // id matches its directory name, and does nothing.
238
239
 
239
- export type IdentityPlanAction = "rename" | "skip-converged" | "skip-no-cwd" | "skip-unchanged";
240
+ // Plan actions:
241
+ // rename old dir present, new dir absent → rename old → new, record alias.
242
+ // skip-converged old dir + new dir both present, alias NOT yet recorded → record
243
+ // alias on new meta and evict old dir to .identity-rollback/. Named
244
+ // "skip-converged" because the rename itself is unnecessary; the
245
+ // convergence work (alias + eviction) is the action.
246
+ // skip-evict old dir + new dir both present, alias already recorded → only
247
+ // evict the old dir. Reached when a previous migration recorded
248
+ // the alias but didn't (or couldn't) finish evicting. Without
249
+ // this, dry-run would keep proposing skip-converged forever.
250
+ // skip-no-cwd project's working-copy path is on a different device — leave alone.
251
+ // skip-unchanged newId === oldId — no work needed at all.
252
+ export type IdentityPlanAction =
253
+ | "rename"
254
+ | "skip-converged"
255
+ | "skip-evict"
256
+ | "skip-no-cwd"
257
+ | "skip-unchanged";
240
258
 
241
259
  export interface IdentityPlanEntry {
242
260
  oldId: string;
@@ -309,12 +327,23 @@ export function planIdentityMigration(flagOverride?: string): IdentityPlanEntry[
309
327
 
310
328
  const newProjDir = join(projectsRoot, newId);
311
329
  if (existsSync(newProjDir)) {
330
+ // If the canonical (new) project already records this oldId in its
331
+ // aliases list, the convergence bookkeeping is already done — only the
332
+ // leftover old directory remains to be cleaned up. Surfacing this as a
333
+ // distinct action (skip-evict) makes dry-run idempotent: once the alias
334
+ // is recorded AND the old directory is gone, the planner stops seeing
335
+ // the project entirely. Pre-fix it would keep proposing the same
336
+ // skip-converged action forever.
337
+ const newMeta = getProjectMeta(newProjDir);
338
+ const aliasAlreadyRecorded = newMeta?.aliases?.includes(oldId) ?? false;
312
339
  plan.push({
313
340
  oldId,
314
341
  newId,
315
342
  cwd: meta.cwd,
316
- action: "skip-converged",
317
- reason: "destination already exists (from sync); alias-only update",
343
+ action: aliasAlreadyRecorded ? "skip-evict" : "skip-converged",
344
+ reason: aliasAlreadyRecorded
345
+ ? "alias already recorded; will evict leftover old directory"
346
+ : "destination already exists (from sync); will record alias and evict old directory",
318
347
  });
319
348
  continue;
320
349
  }
@@ -373,29 +402,55 @@ function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>):
373
402
  // that drives this very decision). Falls back to a fresh read for callers that
374
403
  // don't operate inside a stash window (e.g. session-start triggers and the
375
404
  // --dry-run path).
376
- function migrateProjectIdentities(
377
- deviceId: string,
378
- flag: string = resolveConfigValue("projects.identity").value
379
- ): {
405
+ export interface IdentityMigrationOutcome {
380
406
  renamed: number;
407
+ converged: number;
408
+ evicted: number;
381
409
  visited: number;
382
410
  backupDir: string | null;
383
- } {
411
+ renames: Array<{ from: string; to: string }>;
412
+ evictions: string[];
413
+ }
414
+
415
+ function migrateProjectIdentities(
416
+ deviceId: string,
417
+ flag: string = resolveConfigValue("projects.identity").value
418
+ ): IdentityMigrationOutcome {
384
419
  if (flag !== "git-remote") {
385
- return { renamed: 0, visited: 0, backupDir: null };
420
+ return {
421
+ renamed: 0,
422
+ converged: 0,
423
+ evicted: 0,
424
+ visited: 0,
425
+ backupDir: null,
426
+ renames: [],
427
+ evictions: [],
428
+ };
386
429
  }
387
430
 
388
431
  const plan = planIdentityMigration(flag);
389
- const willRename = plan.filter((p) => p.action === "rename");
432
+ // Any action that moves an old directory aside needs a backup destination.
433
+ // rename, skip-converged (record alias + evict), and skip-evict (alias
434
+ // already recorded; only evict) all qualify.
435
+ const willTouchOldDir = plan.filter(
436
+ (p) =>
437
+ p.action === "rename" ||
438
+ p.action === "skip-converged" ||
439
+ p.action === "skip-evict"
440
+ );
390
441
 
391
442
  // Compute the backup root up-front so all snapshots for this migration pass
392
443
  // land in one timestamped directory the user can find and reason about.
393
444
  let backupRoot: string | null = null;
394
- if (willRename.length > 0) {
445
+ if (willTouchOldDir.length > 0) {
395
446
  backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
396
447
  }
397
448
 
398
449
  let renamed = 0;
450
+ let converged = 0;
451
+ let evicted = 0;
452
+ const renames: Array<{ from: string; to: string }> = [];
453
+ const evictions: string[] = [];
399
454
  let visited = plan.length;
400
455
  const projectsRoot = join(minkRoot(), "projects");
401
456
 
@@ -412,16 +467,70 @@ function migrateProjectIdentities(
412
467
  }
413
468
  }
414
469
 
415
- if (entry.action === "skip-converged" && entry.newId) {
470
+ if (
471
+ (entry.action === "skip-converged" || entry.action === "skip-evict") &&
472
+ entry.newId
473
+ ) {
416
474
  const newProjDir = join(projectsRoot, entry.newId);
475
+ // Record the alias and lift the device path before evicting. If the new
476
+ // dir has no project-meta.json (e.g. the daemon wrote state under the
477
+ // git-derived id before any init or migrate ran), addProjectAlias would
478
+ // silently no-op and we'd leave the old dir stranded forever. Repair
479
+ // that case by writing the old meta forward — the daemon-authored
480
+ // payload state under the new dir is preserved; only the missing meta
481
+ // gets reconstructed, with the alias already in place.
482
+ let aliasOnRecord = false;
417
483
  try {
418
- addProjectAlias(newProjDir, entry.oldId);
484
+ if (entry.action === "skip-evict") {
485
+ aliasOnRecord = true;
486
+ } else {
487
+ addProjectAlias(newProjDir, entry.oldId);
488
+ let newMeta = getProjectMeta(newProjDir);
489
+ if (!newMeta) {
490
+ const oldMeta = getProjectMeta(oldProjDir);
491
+ if (oldMeta) {
492
+ atomicWriteJson(join(newProjDir, "project-meta.json"), {
493
+ cwd: oldMeta.cwd,
494
+ name: oldMeta.name,
495
+ initTimestamp: oldMeta.initTimestamp,
496
+ version: oldMeta.version,
497
+ aliases: [...(oldMeta.aliases ?? []), entry.oldId],
498
+ pathsByDevice: oldMeta.pathsByDevice,
499
+ });
500
+ newMeta = getProjectMeta(newProjDir);
501
+ }
502
+ }
503
+ aliasOnRecord = newMeta?.aliases?.includes(entry.oldId) ?? false;
504
+ }
419
505
  if (entry.cwd) {
420
506
  setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
421
507
  }
422
508
  } catch {
423
509
  // best-effort
424
510
  }
511
+
512
+ if (aliasOnRecord && backupRoot) {
513
+ // Snapshot the old dir before eviction so any local-only state
514
+ // (writes that landed here before sync converged) is recoverable.
515
+ const ok = backupProjectForRollback(
516
+ oldProjDir,
517
+ join(backupRoot, entry.oldId)
518
+ );
519
+ if (ok) {
520
+ let removed = false;
521
+ try {
522
+ rmSync(oldProjDir, { recursive: true, force: true });
523
+ removed = true;
524
+ } catch {
525
+ // best-effort; leave the directory rather than partially deleted
526
+ }
527
+ if (removed) {
528
+ evictions.push(entry.oldId);
529
+ evicted++;
530
+ if (entry.action === "skip-converged") converged++;
531
+ }
532
+ }
533
+ }
425
534
  continue;
426
535
  }
427
536
 
@@ -456,9 +565,18 @@ function migrateProjectIdentities(
456
565
  // best-effort
457
566
  }
458
567
  renamed++;
568
+ renames.push({ from: entry.oldId, to: entry.newId });
459
569
  }
460
570
 
461
- return { renamed, visited, backupDir: backupRoot };
571
+ return {
572
+ renamed,
573
+ converged,
574
+ evicted,
575
+ visited,
576
+ backupDir: backupRoot,
577
+ renames,
578
+ evictions,
579
+ };
462
580
  }
463
581
 
464
582
  // ── v3 identity rollback ──────────────────────────────────────────────────
@@ -545,6 +663,7 @@ export interface MigrateResult {
545
663
  fromVersion: number;
546
664
  toVersion: number;
547
665
  message?: string;
666
+ identity?: IdentityMigrationOutcome;
548
667
  }
549
668
 
550
669
  // Idempotent. Safe to invoke from `mink sync migrate` directly or from a
@@ -636,7 +755,15 @@ export function migrateSyncLayout(): MigrateResult {
636
755
  // flag is off or every project's identifier already matches its directory.
637
756
  // Pass the pre-stash snapshot of identityMode so we don't re-read the
638
757
  // config from a stash-hidden working tree.
639
- let identity = { renamed: 0, visited: 0 };
758
+ let identity: IdentityMigrationOutcome = {
759
+ renamed: 0,
760
+ converged: 0,
761
+ evicted: 0,
762
+ visited: 0,
763
+ backupDir: null,
764
+ renames: [],
765
+ evictions: [],
766
+ };
640
767
  try {
641
768
  identity = migrateProjectIdentities(deviceId, identityMode);
642
769
  } catch {
@@ -650,16 +777,19 @@ export function migrateSyncLayout(): MigrateResult {
650
777
  writeSyncVersion(MINK_SYNC_VERSION);
651
778
  }
652
779
 
653
- if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
780
+ if (
781
+ isSyncInitialized() &&
782
+ (processed > 0 || identity.renamed > 0 || identity.evicted > 0)
783
+ ) {
654
784
  // Skip the lock file — it's part of migration coordination, not state.
655
785
  gitSafe("add -A");
656
786
  gitSafe(`reset HEAD ".sync-migrate.lock"`);
657
- const summary =
658
- identity.renamed > 0
659
- ? `${processed} projects, ${identity.renamed} renamed for identity v3`
660
- : `${processed} projects`;
787
+ const identityNote =
788
+ identity.renamed > 0 || identity.evicted > 0
789
+ ? `, ${identity.renamed} renamed + ${identity.evicted} evicted for identity v3`
790
+ : "";
661
791
  gitSafe(
662
- `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${summary})"`
792
+ `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${processed} projects${identityNote})"`
663
793
  );
664
794
  }
665
795
 
@@ -671,6 +801,7 @@ export function migrateSyncLayout(): MigrateResult {
671
801
  ranMigration: true,
672
802
  fromVersion,
673
803
  toVersion: MINK_SYNC_VERSION,
804
+ identity,
674
805
  };
675
806
  } finally {
676
807
  releaseLock();
@@ -696,20 +827,24 @@ export function syncMigrateCommand(args: string[] = []): void {
696
827
  }
697
828
  const renames = plan.filter((p) => p.action === "rename");
698
829
  const converged = plan.filter((p) => p.action === "skip-converged");
830
+ const evictOnly = plan.filter((p) => p.action === "skip-evict");
699
831
  const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
700
832
  const unchanged = plan.filter((p) => p.action === "skip-unchanged");
701
833
 
702
834
  console.log(
703
- `[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} alias-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
835
+ `[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} converge (alias + evict), ${evictOnly.length} evict-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
704
836
  );
705
837
  for (const p of renames) {
706
- console.log(` rename: ${p.oldId} → ${p.newId}`);
838
+ console.log(` rename: ${p.oldId} → ${p.newId}`);
707
839
  }
708
840
  for (const p of converged) {
709
- console.log(` alias: ${p.oldId} → ${p.newId} (already on disk)`);
841
+ console.log(` converge: ${p.oldId} → ${p.newId} (record alias on ${p.newId}, evict ${p.oldId} to .identity-rollback/)`);
842
+ }
843
+ for (const p of evictOnly) {
844
+ console.log(` evict: ${p.oldId} → .identity-rollback/ (alias already on ${p.newId})`);
710
845
  }
711
846
  for (const p of skippedNoCwd) {
712
- console.log(` skip: ${p.oldId} — ${p.reason}`);
847
+ console.log(` skip: ${p.oldId} — ${p.reason}`);
713
848
  }
714
849
  return;
715
850
  }
@@ -749,4 +884,19 @@ export function syncMigrateCommand(args: string[] = []): void {
749
884
  console.log(
750
885
  `[mink] sync migrate: v${result.fromVersion} → v${result.toVersion} complete`
751
886
  );
887
+ const identity = result.identity;
888
+ if (identity && (identity.renamed > 0 || identity.evicted > 0)) {
889
+ console.log(
890
+ ` identity: ${identity.renamed} renamed, ${identity.converged} converged, ${identity.evicted} evicted`
891
+ );
892
+ for (const r of identity.renames) {
893
+ console.log(` renamed: ${r.from} → ${r.to}`);
894
+ }
895
+ for (const id of identity.evictions) {
896
+ console.log(` evicted: ${id} → .identity-rollback/`);
897
+ }
898
+ if (identity.backupDir) {
899
+ console.log(` rollback snapshot: ${identity.backupDir}`);
900
+ }
901
+ }
752
902
  }
package/src/core/sync.ts CHANGED
@@ -51,6 +51,13 @@ config.local
51
51
  # Migration coordination — never sync this
52
52
  .sync-migrate.lock
53
53
 
54
+ # Per-device identity migration recovery snapshots — local recovery state
55
+ # for the migrating device, not authoritative project state. A device that
56
+ # needs to recover from its migration must do so from its own snapshot;
57
+ # syncing would make rollback dirs appear on devices that never produced
58
+ # the corresponding migration.
59
+ .identity-rollback/
60
+
54
61
  # Local backups and per-device caches — machine-specific snapshots
55
62
  projects/*/backups/
56
63
  projects/*/session.json