@mu-crew/mu 3.4.0 → 3.5.0

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.js CHANGED
@@ -175,6 +175,11 @@ function fullOldPayload(cols) {
175
175
  const pairs = cols.map((c) => `'${c}', OLD.${c}`).join(", ");
176
176
  return `json_object(${pairs})`;
177
177
  }
178
+ function noteTombstonePayload(keyExpr, cols) {
179
+ return `CASE WHEN EXISTS (
180
+ SELECT 1 FROM ops WHERE entity = 'note' AND key = ${keyExpr} AND op = 'put'
181
+ ) THEN '{}' ELSE ${fullOldPayload(cols)} END`;
182
+ }
178
183
  function anyChanged(cols) {
179
184
  return cols.map((c) => `NEW.${c} IS NOT OLD.${c}`).join("\n OR ");
180
185
  }
@@ -258,7 +263,7 @@ END;
258
263
  CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_notes_del
259
264
  BEFORE DELETE ON task_notes WHEN ${NOT_APPLYING}
260
265
  BEGIN
261
- ${emitOp("note", "del", `${taskKey("OLD.task_id")} || '#' || OLD.id`, fullOldPayload(noteCols))}
266
+ ${emitOp("note", "del", `${taskKey("OLD.task_id")} || '#' || OLD.id`, noteTombstonePayload(`${taskKey("OLD.task_id")} || '#' || OLD.id`, noteCols))}
262
267
  END;
263
268
 
264
269
  -- \u2500\u2500\u2500 task_edges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -7620,7 +7625,10 @@ var init_adopt = __esm({
7620
7625
  // src/agents/delegate.ts
7621
7626
  function delegateOutcome(agent, timedOut) {
7622
7627
  if (agent.dead) return "died";
7623
- if (agent.fired) return agent.lastText ? "done" : "empty";
7628
+ if (agent.fired) {
7629
+ if (agent.lastError !== void 0) return "error";
7630
+ return agent.lastText ? "done" : "empty";
7631
+ }
7624
7632
  return timedOut ? "timeout" : "pending";
7625
7633
  }
7626
7634
  var init_delegate = __esm({
@@ -7833,6 +7841,7 @@ async function waitForAgents(db, input, opts) {
7833
7841
  st.wasBusy = true;
7834
7842
  st.fired = true;
7835
7843
  if (snap.lastText !== void 0) st.lastText = snap.lastText;
7844
+ if (snap.lastError !== void 0) st.lastError = snap.lastError;
7836
7845
  }
7837
7846
  wake();
7838
7847
  },
@@ -10400,13 +10409,13 @@ import {
10400
10409
  appendFileSync,
10401
10410
  existsSync as existsSync14,
10402
10411
  mkdirSync as mkdirSync5,
10403
- readdirSync as readdirSync3,
10412
+ readdirSync as readdirSync4,
10404
10413
  readFileSync as readFileSync3,
10405
- statSync as statSync2,
10414
+ statSync as statSync3,
10406
10415
  writeFileSync as writeFileSync2
10407
10416
  } from "fs";
10408
10417
  import { tmpdir as tmpdir2 } from "os";
10409
- import { join as join12, resolve as resolve6, sep as sep2 } from "path";
10418
+ import { join as join13, resolve as resolve6, sep as sep2 } from "path";
10410
10419
  import { crc32 } from "zlib";
10411
10420
  function syncDir() {
10412
10421
  const dir = process.env.MU_SYNC_DIR;
@@ -10430,7 +10439,7 @@ function localMachineId(db) {
10430
10439
  return row2.machine_id;
10431
10440
  }
10432
10441
  function segmentPath(dir, machineId2) {
10433
- return join12(dir, `${machineId2}${SEGMENT_EXT}`);
10442
+ return join13(dir, `${machineId2}${SEGMENT_EXT}`);
10434
10443
  }
10435
10444
  function manifestPath(segment) {
10436
10445
  return segment.replace(new RegExp(`${SEGMENT_EXT}$`), MANIFEST_EXT);
@@ -10574,7 +10583,7 @@ async function flushSegment(db, dir = syncDir()) {
10574
10583
  const path2 = segmentPath(dir, machineId2);
10575
10584
  const lockName = createHash2("sha256").update(`${dir}${machineId2}`).digest("hex").slice(0, 16);
10576
10585
  return withFileLock(
10577
- join12(locksDir(), `segment-${lockName}.lock`),
10586
+ join13(locksDir(), `segment-${lockName}.lock`),
10578
10587
  `segment:${machineId2}`,
10579
10588
  () => Promise.resolve(flushLocked(db, path2, machineId2))
10580
10589
  );
@@ -10696,16 +10705,16 @@ function verifyAgainstManifest(segment) {
10696
10705
  function discoverPeers(dir, selfMachineId) {
10697
10706
  if (!existsSync14(dir)) return [];
10698
10707
  const peers = [];
10699
- for (const name of readdirSync3(dir)) {
10708
+ for (const name of readdirSync4(dir)) {
10700
10709
  if (!name.endsWith(SEGMENT_EXT)) continue;
10701
10710
  const stem = name.slice(0, -SEGMENT_EXT.length);
10702
10711
  const conflictAt = stem.indexOf(".sync-conflict-");
10703
10712
  const conflictCopy = conflictAt > 0;
10704
10713
  const machineId2 = conflictCopy ? stem.slice(0, conflictAt) : stem;
10705
10714
  if (machineId2 === selfMachineId && !conflictCopy) continue;
10706
- const path2 = join12(dir, name);
10715
+ const path2 = join13(dir, name);
10707
10716
  try {
10708
- if (!statSync2(path2).isFile()) continue;
10717
+ if (!statSync3(path2).isFile()) continue;
10709
10718
  } catch {
10710
10719
  continue;
10711
10720
  }
@@ -10910,7 +10919,7 @@ var init_segments = __esm({
10910
10919
  });
10911
10920
 
10912
10921
  // src/sync.ts
10913
- import { existsSync as existsSync15, statSync as statSync3 } from "fs";
10922
+ import { existsSync as existsSync15, statSync as statSync4 } from "fs";
10914
10923
  import Database2 from "better-sqlite3";
10915
10924
  function peerStatuses(db, dir) {
10916
10925
  const self = localMachineId(db);
@@ -10918,7 +10927,7 @@ function peerStatuses(db, dir) {
10918
10927
  const rows = discoverPeers(dir, self).map((peer) => {
10919
10928
  let lastSeenMs = null;
10920
10929
  try {
10921
- lastSeenMs = statSync3(peer.path).mtimeMs;
10930
+ lastSeenMs = statSync4(peer.path).mtimeMs;
10922
10931
  } catch {
10923
10932
  lastSeenMs = null;
10924
10933
  }
@@ -20677,7 +20686,8 @@ async function cmdAgentWait(db, rawNames, opts) {
20677
20686
  const settled = ctlRequest(sock, { op: "wait", afterRuns: probe.status.runs }, { signal }).then(
20678
20687
  (reply) => reply.ok && reply.state !== void 0 ? {
20679
20688
  status: ctlRuntimeState(reply.state),
20680
- ...reply.lastText !== void 0 ? { lastText: reply.lastText } : {}
20689
+ ...reply.lastText !== void 0 ? { lastText: reply.lastText } : {},
20690
+ ...reply.lastError !== void 0 ? { lastError: reply.lastError } : {}
20681
20691
  } : { status: "unknown", unknownReason: reply.ok ? "ctl wait: no state" : reply.error },
20682
20692
  // The connection dropped. A socket that still answers (pi's /new
20683
20693
  // restarts the session) hands back to polling; one that does not
@@ -20886,8 +20896,308 @@ function wireSelfCommands(program) {
20886
20896
  }
20887
20897
 
20888
20898
  // src/cli/db.ts
20889
- import { existsSync as existsSync16, mkdirSync as mkdirSync6 } from "fs";
20899
+ import { existsSync as existsSync16, mkdirSync as mkdirSync6, statSync as statSync5 } from "fs";
20890
20900
  import { dirname as dirname13, resolve as resolve7 } from "path";
20901
+
20902
+ // src/compact.ts
20903
+ init_legacy_ops();
20904
+ var REDUNDANT_TOMBSTONES_SQL = `
20905
+ SELECT d.seq AS seq, length(d.payload) AS bytes
20906
+ FROM ops d
20907
+ WHERE d.entity = 'note' AND d.op = 'del' AND d.payload <> '{}'
20908
+ AND EXISTS (SELECT 1 FROM ops p
20909
+ WHERE p.entity = 'note' AND p.key = d.key AND p.op = 'put')`;
20910
+ function planCompact(db) {
20911
+ const r = db.prepare(
20912
+ `SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM (${REDUNDANT_TOMBSTONES_SQL})`
20913
+ ).get();
20914
+ return { tombstones: r.n, bytes: r.b };
20915
+ }
20916
+ function compact(db) {
20917
+ const plan = planCompact(db);
20918
+ db.transaction(() => {
20919
+ db.prepare(
20920
+ `UPDATE ops SET payload = '{}' WHERE seq IN (SELECT seq FROM (${REDUNDANT_TOMBSTONES_SQL}))`
20921
+ ).run();
20922
+ })();
20923
+ return plan;
20924
+ }
20925
+ var WORKSTREAM_OPS_WHERE = `(key = @ws OR substr(key, 1, length(@ws) + 1) = @ws || '/')`;
20926
+ function listForgetCandidates(db) {
20927
+ const spellings = intentSpellings("workstream.teardown");
20928
+ const names = db.prepare(
20929
+ `SELECT key AS name, MAX(created_at) AS at
20930
+ FROM ops
20931
+ WHERE entity = 'workstream' AND op = 'del'
20932
+ AND intent IN (${spellings.map(() => "?").join(", ")})
20933
+ AND key NOT IN (SELECT name FROM workstreams)
20934
+ GROUP BY key`
20935
+ ).all(...spellings);
20936
+ const cost = db.prepare(
20937
+ `SELECT COUNT(*) AS n, COALESCE(SUM(length(payload)), 0) AS b FROM ops WHERE ${WORKSTREAM_OPS_WHERE}`
20938
+ );
20939
+ return names.map((r) => {
20940
+ const c = cost.get({ ws: r.name });
20941
+ return { name: r.name, tornDownAt: r.at, ops: c.n, bytes: c.b };
20942
+ }).sort((a, b) => b.bytes - a.bytes);
20943
+ }
20944
+ function planForget(db, names) {
20945
+ const all = new Map(listForgetCandidates(db).map((c) => [c.name, c]));
20946
+ const live = new Set(
20947
+ db.prepare("SELECT name FROM workstreams").all().map((r) => r.name)
20948
+ );
20949
+ const plan = { candidates: [], refused: [] };
20950
+ for (const name of new Set(names)) {
20951
+ const c = all.get(name);
20952
+ if (c) plan.candidates.push(c);
20953
+ else plan.refused.push({ name, why: live.has(name) ? "live" : "never-torn-down" });
20954
+ }
20955
+ return plan;
20956
+ }
20957
+ function forget(db, plan) {
20958
+ const del = db.prepare(`DELETE FROM ops WHERE ${WORKSTREAM_OPS_WHERE}`);
20959
+ let ops = 0;
20960
+ db.transaction(() => {
20961
+ for (const c of plan.candidates) ops += del.run({ ws: c.name }).changes;
20962
+ })();
20963
+ return { ops };
20964
+ }
20965
+
20966
+ // src/disk-recon.ts
20967
+ init_db();
20968
+ init_workspace();
20969
+ import { readdirSync as readdirSync3, statSync as statSync2 } from "fs";
20970
+ import { join as join12 } from "path";
20971
+ var STALE_LOCK_REPORT_MS = 60 * 60 * 1e3;
20972
+ function formatBytes(bytes) {
20973
+ if (bytes < 1024) return `${bytes}B`;
20974
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
20975
+ if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}M`;
20976
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`;
20977
+ }
20978
+ function dirNames(path2) {
20979
+ try {
20980
+ return readdirSync3(path2, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
20981
+ } catch {
20982
+ return [];
20983
+ }
20984
+ }
20985
+ function exists(path2) {
20986
+ try {
20987
+ statSync2(path2);
20988
+ return true;
20989
+ } catch {
20990
+ return false;
20991
+ }
20992
+ }
20993
+ function sizeOf(path2) {
20994
+ try {
20995
+ return statSync2(path2).size;
20996
+ } catch {
20997
+ return 0;
20998
+ }
20999
+ }
21000
+ function findMissingWorkspaceDirs(db) {
21001
+ return listWorkspaces2(db).filter((row2) => !exists(row2.path)).map((row2) => ({
21002
+ agentName: row2.agentName,
21003
+ workstreamName: row2.workstreamName,
21004
+ path: row2.path
21005
+ }));
21006
+ }
21007
+ function checkMissingWorkspaceDirs(db) {
21008
+ const missing = findMissingWorkspaceDirs(db);
21009
+ if (missing.length === 0) {
21010
+ return { name: "ws-rows", severity: "ok", detail: "every workspace row has its dir" };
21011
+ }
21012
+ return {
21013
+ name: "ws-rows",
21014
+ severity: "warn",
21015
+ detail: `${missing.length} workspace row(s) point at a path that is gone`,
21016
+ remediation: [
21017
+ "These vcs_workspaces rows name a directory that no longer exists:",
21018
+ ...missing.map((m) => ` ${m.workstreamName}/${m.agentName} ${m.path}`),
21019
+ "",
21020
+ "mu reports such a row as a healthy workspace on every read surface, and",
21021
+ "the next send or refresh against it fails inside the VCS backend rather",
21022
+ "than at the row that lied. Unregister them:",
21023
+ ...missing.map((m) => ` mu workspace free ${m.agentName} -w ${m.workstreamName}`)
21024
+ ]
21025
+ };
21026
+ }
21027
+ function checkWorkspaceOrphanDirs(db) {
21028
+ const orphans = listAllOrphanWorkspaces(db);
21029
+ if (orphans.length === 0) {
21030
+ return { name: "ws-dirs", severity: "ok", detail: "no orphan workspace dirs" };
21031
+ }
21032
+ const stranded = orphans.filter((o) => o.stranded);
21033
+ const strandedNote = stranded.length > 0 ? `, ${stranded.length} stranded` : "";
21034
+ return {
21035
+ name: "ws-dirs",
21036
+ severity: "warn",
21037
+ detail: `${orphans.length} workspace dir(s) with no DB row${strandedNote}`,
21038
+ remediation: [
21039
+ "These directories have no row in vcs_workspaces:",
21040
+ ...orphans.map((o) => ` ${o.path}${o.stranded ? " (workstream row also gone)" : ""}`),
21041
+ "",
21042
+ "Each one blocks the next `--workspace` spawn for that agent name. mu will",
21043
+ "not remove them: they may hold uncommitted work, and a stranded dir is the",
21044
+ "only remaining copy of it. Inspect, then remove by hand:",
21045
+ " mu workspace orphans --all",
21046
+ ...orphans.slice(0, 3).map((o) => ` rm -rf ${o.path}`)
21047
+ ]
21048
+ };
21049
+ }
21050
+ function checkEmptyWorkstreamDirs() {
21051
+ const root = join12(defaultStateDir(), "workspaces");
21052
+ const empty = dirNames(root).filter((name) => dirNames(join12(root, name)).length === 0);
21053
+ if (empty.length === 0) {
21054
+ return { name: "ws-empty", severity: "ok", detail: "no empty workstream dirs" };
21055
+ }
21056
+ return {
21057
+ name: "ws-empty",
21058
+ severity: "ok",
21059
+ detail: `${empty.length} empty workstream dir(s): ${empty.slice(0, 4).join(", ")}`,
21060
+ remediation: [
21061
+ "These per-workstream workspace dirs hold no checkouts:",
21062
+ ...empty.map((name) => ` ${join12(root, name)}`),
21063
+ "",
21064
+ "`mu workspace free` removes an agent's dir and its row but not the parent,",
21065
+ "so this is normal residue rather than a fault. Remove if you want the",
21066
+ "state dir to read cleanly:",
21067
+ ...empty.slice(0, 3).map((name) => ` rmdir ${join12(root, name)}`)
21068
+ ]
21069
+ };
21070
+ }
21071
+ function findStrayDbFiles(stateDir = defaultStateDir()) {
21072
+ const live = /* @__PURE__ */ new Set(["mu.db", "mu.db-wal", "mu.db-shm"]);
21073
+ let entries;
21074
+ try {
21075
+ entries = readdirSync3(stateDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name);
21076
+ } catch {
21077
+ return [];
21078
+ }
21079
+ return entries.filter((name) => name.startsWith("mu.db") && !live.has(name)).map((name) => ({ name, path: join12(stateDir, name), bytes: sizeOf(join12(stateDir, name)) })).sort((a, b) => b.bytes - a.bytes);
21080
+ }
21081
+ function checkStrayDbFiles() {
21082
+ const stateDir = defaultStateDir();
21083
+ const stray = findStrayDbFiles(stateDir);
21084
+ if (stray.length === 0) {
21085
+ return { name: "db-copies", severity: "ok", detail: "no stray DB copies" };
21086
+ }
21087
+ const total = stray.reduce((n, f) => n + f.bytes, 0);
21088
+ return {
21089
+ name: "db-copies",
21090
+ severity: "warn",
21091
+ detail: `${stray.length} stray DB file(s), ${formatBytes(total)} \u2014 nothing reads these`,
21092
+ remediation: [
21093
+ `Files matching mu.db* in ${stateDir} that are not the live WAL triple:`,
21094
+ ...stray.map((f) => ` ${formatBytes(f.bytes).padStart(6)} ${f.name}`),
21095
+ "",
21096
+ `Live DB: ${defaultDbPath()}`,
21097
+ "",
21098
+ "These are hand-made copies and pre-upgrade saves. No mu code path reads",
21099
+ "them and none prunes them. Real disaster recovery is the ops log, so a",
21100
+ "copy is only ever a convenience:",
21101
+ " mu rebuild <file> # DR from the ops log, not from a copy",
21102
+ " mu db backup <file> # a fresh copy, if that is what you wanted"
21103
+ ]
21104
+ };
21105
+ }
21106
+ function checkRemovedExportsDir() {
21107
+ const root = join12(defaultStateDir(), "exports");
21108
+ const entries = dirNames(root);
21109
+ if (entries.length === 0) {
21110
+ return { name: "exports", severity: "ok", detail: "no leftover export dirs" };
21111
+ }
21112
+ return {
21113
+ name: "exports",
21114
+ severity: "warn",
21115
+ detail: `${entries.length} export dir(s) from the removed \`mu workstream export\``,
21116
+ remediation: [
21117
+ `${root} holds ${entries.length} directory/ies.`,
21118
+ "",
21119
+ "`mu workstream export` and its markdown bucket were removed in 1.0; this",
21120
+ "is output from a version that still had it. Nothing in mu reads or prunes",
21121
+ "it. Keep anything you still want, then:",
21122
+ ` rm -rf ${root}`
21123
+ ]
21124
+ };
21125
+ }
21126
+ function checkStaleLocks(now = Date.now()) {
21127
+ const root = join12(defaultStateDir(), "locks");
21128
+ const stale = [];
21129
+ for (const name of dirNames(root)) {
21130
+ try {
21131
+ const ageMs2 = now - statSync2(join12(root, name)).mtimeMs;
21132
+ if (ageMs2 > STALE_LOCK_REPORT_MS) stale.push({ name, ageMs: ageMs2 });
21133
+ } catch {
21134
+ }
21135
+ }
21136
+ if (stale.length === 0) {
21137
+ return { name: "locks", severity: "ok", detail: "no stale lock dirs" };
21138
+ }
21139
+ return {
21140
+ name: "locks",
21141
+ severity: "warn",
21142
+ detail: `${stale.length} lock dir(s) older than 1h`,
21143
+ remediation: [
21144
+ "These advisory-lock dirs outlived the process that took them:",
21145
+ ...stale.map((s) => ` ${s.name} (${Math.round(s.ageMs / 6e4)}m old)`),
21146
+ "",
21147
+ "Not a deadlock: withFileLock steals a lock older than 30s. But it only",
21148
+ "steals under contention, so these will sit here forever. They are evidence",
21149
+ "of a spawn or segment flush that died mid-critical-section.",
21150
+ ` rm -rf ${root}/<name>`
21151
+ ]
21152
+ };
21153
+ }
21154
+ function walkBytes(path2) {
21155
+ let total = 0;
21156
+ let entries;
21157
+ try {
21158
+ entries = readdirSync3(path2, { withFileTypes: true });
21159
+ } catch {
21160
+ return 0;
21161
+ }
21162
+ for (const entry of entries) {
21163
+ const child = join12(path2, entry.name);
21164
+ if (entry.isDirectory()) total += walkBytes(child);
21165
+ else if (entry.isFile()) total += sizeOf(child);
21166
+ }
21167
+ return total;
21168
+ }
21169
+ function measureWorkspaceUsage(db) {
21170
+ const root = join12(defaultStateDir(), "workspaces");
21171
+ const registered = new Set(listWorkspaces2(db).map((w) => w.path));
21172
+ const usage = [];
21173
+ for (const wsName of dirNames(root)) {
21174
+ const wsRoot = join12(root, wsName);
21175
+ for (const agentName of dirNames(wsRoot)) {
21176
+ const path2 = join12(wsRoot, agentName);
21177
+ usage.push({
21178
+ workstreamName: wsName,
21179
+ agentName,
21180
+ path: path2,
21181
+ bytes: walkBytes(path2),
21182
+ orphan: !registered.has(path2)
21183
+ });
21184
+ }
21185
+ }
21186
+ return usage.sort((a, b) => b.bytes - a.bytes);
21187
+ }
21188
+ function checkDiskRecon(db) {
21189
+ return [
21190
+ checkMissingWorkspaceDirs(db),
21191
+ checkWorkspaceOrphanDirs(db),
21192
+ checkEmptyWorkstreamDirs(),
21193
+ checkStrayDbFiles(),
21194
+ checkRemovedExportsDir(),
21195
+ checkStaleLocks()
21196
+ ];
21197
+ }
21198
+
21199
+ // src/cli/db.ts
21200
+ init_drift();
20891
21201
  init_output();
20892
21202
 
20893
21203
  // src/cli/handle.ts
@@ -21695,6 +22005,110 @@ function cmdDbBackup(db, file, opts = {}) {
21695
22005
  console.log(`Backed up to ${pc.bold(target)}`);
21696
22006
  printNextSteps(nextSteps3);
21697
22007
  }
22008
+ function backupBeside(db, verb) {
22009
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
22010
+ const target = `${db.name}.pre-${verb}-${ts}`;
22011
+ db.prepare("VACUUM INTO ?").run(target);
22012
+ return target;
22013
+ }
22014
+ function vacuumAndCheck(db, backup) {
22015
+ const before = statSync5(db.name).size;
22016
+ db.exec("VACUUM");
22017
+ const after = statSync5(db.name).size;
22018
+ const report = checkDrift(db);
22019
+ if (!report.clean) {
22020
+ console.error(
22021
+ pc.red(`drift after the rewrite (${report.totalDrift}); the backup is ${backup}`)
22022
+ );
22023
+ throw new DriftDetectedError(report.totalDrift, report.records);
22024
+ }
22025
+ return { before, after };
22026
+ }
22027
+ function cmdDbCompact(db, opts = {}) {
22028
+ const plan = planCompact(db);
22029
+ if (opts.yes !== true || plan.tombstones === 0) {
22030
+ const nextSteps3 = plan.tombstones === 0 ? [] : [{ intent: "Apply it", command: "mu db compact --yes" }];
22031
+ if (opts.json === true) {
22032
+ emitJson({ dryRun: true, ...plan, nextSteps: nextSteps3 });
22033
+ return;
22034
+ }
22035
+ console.log(
22036
+ plan.tombstones === 0 ? pc.dim("nothing to compact: no redundant note tombstones") : `${plan.tombstones} note tombstones repeat a put already in the log (${formatBytes(plan.bytes)}). Dry run; nothing changed.`
22037
+ );
22038
+ printNextSteps(nextSteps3);
22039
+ return;
22040
+ }
22041
+ const backup = backupBeside(db, "compact");
22042
+ compact(db);
22043
+ const size = vacuumAndCheck(db, backup);
22044
+ if (opts.json === true) {
22045
+ emitJson({ dryRun: false, ...plan, backup, ...size });
22046
+ return;
22047
+ }
22048
+ console.log(
22049
+ `Compacted ${plan.tombstones} note tombstones: ${formatBytes(size.before)} \u2192 ${formatBytes(size.after)}. Drift check clean.`
22050
+ );
22051
+ console.log(pc.dim(`backup: ${backup}`));
22052
+ }
22053
+ function printForgetTable(plan) {
22054
+ const t = muTable({ head: ["workstream", "torn down", "ops", "size"].map((h) => pc.bold(h)) });
22055
+ for (const c of plan.candidates)
22056
+ t.push([c.name, c.tornDownAt.slice(0, 10), String(c.ops), formatBytes(c.bytes)]);
22057
+ console.log(t.toString());
22058
+ }
22059
+ function cmdDbForget(db, names, opts = {}) {
22060
+ const plan = planForget(db, names);
22061
+ if (plan.refused.length > 0) {
22062
+ const why = plan.refused.map((r2) => `${r2.name} (${r2.why === "live" ? "is live" : "was never torn down"})`).join(", ");
22063
+ throw new UsageError(`cannot forget: ${why}. Only torn-down workstreams can be forgotten.`);
22064
+ }
22065
+ const ops = plan.candidates.reduce((n, c) => n + c.ops, 0);
22066
+ const bytes = plan.candidates.reduce((n, c) => n + c.bytes, 0);
22067
+ if (opts.yes !== true) {
22068
+ const nextSteps3 = [
22069
+ { intent: "Forget them (NOT undoable)", command: `mu db forget ${names.join(" ")} --yes` }
22070
+ ];
22071
+ if (opts.json === true) {
22072
+ emitJson({ dryRun: true, candidates: plan.candidates, ops, bytes, nextSteps: nextSteps3 });
22073
+ return;
22074
+ }
22075
+ printForgetTable(plan);
22076
+ console.log(
22077
+ `${ops} ops (${formatBytes(bytes)} of payload). Forgetting deletes their history: mu undo can no longer restore these teardowns. Dry run; nothing changed.`
22078
+ );
22079
+ printNextSteps(nextSteps3);
22080
+ return;
22081
+ }
22082
+ const backup = backupBeside(db, "forget");
22083
+ const r = forget(db, plan);
22084
+ const size = vacuumAndCheck(db, backup);
22085
+ if (opts.json === true) {
22086
+ emitJson({
22087
+ dryRun: false,
22088
+ forgotten: plan.candidates.map((c) => c.name),
22089
+ ops: r.ops,
22090
+ backup,
22091
+ ...size
22092
+ });
22093
+ return;
22094
+ }
22095
+ console.log(
22096
+ `Forgot ${plan.candidates.length} workstream(s), ${r.ops} ops: ${formatBytes(size.before)} \u2192 ${formatBytes(size.after)}. Drift check clean.`
22097
+ );
22098
+ console.log(pc.dim(`backup (the only way back): ${backup}`));
22099
+ }
22100
+ function forgetHint(db) {
22101
+ const all = listForgetCandidates(db);
22102
+ const bytes = all.reduce((n, c) => n + c.bytes, 0);
22103
+ if (bytes < FORGET_HINT_MIN_BYTES) return void 0;
22104
+ return {
22105
+ total: all.length,
22106
+ ops: all.reduce((n, c) => n + c.ops, 0),
22107
+ bytes,
22108
+ top: all.slice(0, 3).map((c) => c.name)
22109
+ };
22110
+ }
22111
+ var FORGET_HINT_MIN_BYTES = 5 * 1024 * 1024;
21698
22112
  function wireDbCommands(program) {
21699
22113
  const dbCmd = program.command("db").description("Whole-DB file commands");
21700
22114
  dbCmd.command("backup <file>").description(
@@ -21703,247 +22117,24 @@ function wireDbCommands(program) {
21703
22117
  const opts = this.opts();
21704
22118
  return handle(async (db) => cmdDbBackup(db, file, opts), this)();
21705
22119
  });
22120
+ dbCmd.command("compact").description(
22121
+ "Shrink mu.db: blank note tombstones that repeat a put already in the log, then VACUUM. Dry run without --yes; --yes backs up beside the DB first and runs the drift check after. Local only; sync segments are untouched."
22122
+ ).option("--yes", "apply (default: dry run)").option(...JSON_OPT).action(function() {
22123
+ const opts = this.opts();
22124
+ return handle(async (db) => cmdDbCompact(db, opts), this)();
22125
+ });
22126
+ dbCmd.command("forget <workstreams...>").description(
22127
+ "Delete every op of torn-down workstreams to shrink mu.db. NOT undoable: mu undo can no longer restore them. Dry run without --yes; --yes backs up beside the DB first, VACUUMs, and runs the drift check. List candidates: mu workstream list --torn-down."
22128
+ ).option("--yes", "apply (default: dry run)").option(...JSON_OPT).action(function(names) {
22129
+ const opts = this.opts();
22130
+ return handle(async (db) => cmdDbForget(db, names, opts), this)();
22131
+ });
21706
22132
  }
21707
22133
 
21708
22134
  // src/cli/doctor.ts
21709
22135
  init_agents();
21710
22136
  import { dirname as dirname14 } from "path";
21711
22137
  init_db();
21712
-
21713
- // src/disk-recon.ts
21714
- init_db();
21715
- init_workspace();
21716
- import { readdirSync as readdirSync4, statSync as statSync4 } from "fs";
21717
- import { join as join13 } from "path";
21718
- var STALE_LOCK_REPORT_MS = 60 * 60 * 1e3;
21719
- function formatBytes(bytes) {
21720
- if (bytes < 1024) return `${bytes}B`;
21721
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
21722
- if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}M`;
21723
- return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`;
21724
- }
21725
- function dirNames(path2) {
21726
- try {
21727
- return readdirSync4(path2, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
21728
- } catch {
21729
- return [];
21730
- }
21731
- }
21732
- function exists(path2) {
21733
- try {
21734
- statSync4(path2);
21735
- return true;
21736
- } catch {
21737
- return false;
21738
- }
21739
- }
21740
- function sizeOf(path2) {
21741
- try {
21742
- return statSync4(path2).size;
21743
- } catch {
21744
- return 0;
21745
- }
21746
- }
21747
- function findMissingWorkspaceDirs(db) {
21748
- return listWorkspaces2(db).filter((row2) => !exists(row2.path)).map((row2) => ({
21749
- agentName: row2.agentName,
21750
- workstreamName: row2.workstreamName,
21751
- path: row2.path
21752
- }));
21753
- }
21754
- function checkMissingWorkspaceDirs(db) {
21755
- const missing = findMissingWorkspaceDirs(db);
21756
- if (missing.length === 0) {
21757
- return { name: "ws-rows", severity: "ok", detail: "every workspace row has its dir" };
21758
- }
21759
- return {
21760
- name: "ws-rows",
21761
- severity: "warn",
21762
- detail: `${missing.length} workspace row(s) point at a path that is gone`,
21763
- remediation: [
21764
- "These vcs_workspaces rows name a directory that no longer exists:",
21765
- ...missing.map((m) => ` ${m.workstreamName}/${m.agentName} ${m.path}`),
21766
- "",
21767
- "mu reports such a row as a healthy workspace on every read surface, and",
21768
- "the next send or refresh against it fails inside the VCS backend rather",
21769
- "than at the row that lied. Unregister them:",
21770
- ...missing.map((m) => ` mu workspace free ${m.agentName} -w ${m.workstreamName}`)
21771
- ]
21772
- };
21773
- }
21774
- function checkWorkspaceOrphanDirs(db) {
21775
- const orphans = listAllOrphanWorkspaces(db);
21776
- if (orphans.length === 0) {
21777
- return { name: "ws-dirs", severity: "ok", detail: "no orphan workspace dirs" };
21778
- }
21779
- const stranded = orphans.filter((o) => o.stranded);
21780
- const strandedNote = stranded.length > 0 ? `, ${stranded.length} stranded` : "";
21781
- return {
21782
- name: "ws-dirs",
21783
- severity: "warn",
21784
- detail: `${orphans.length} workspace dir(s) with no DB row${strandedNote}`,
21785
- remediation: [
21786
- "These directories have no row in vcs_workspaces:",
21787
- ...orphans.map((o) => ` ${o.path}${o.stranded ? " (workstream row also gone)" : ""}`),
21788
- "",
21789
- "Each one blocks the next `--workspace` spawn for that agent name. mu will",
21790
- "not remove them: they may hold uncommitted work, and a stranded dir is the",
21791
- "only remaining copy of it. Inspect, then remove by hand:",
21792
- " mu workspace orphans --all",
21793
- ...orphans.slice(0, 3).map((o) => ` rm -rf ${o.path}`)
21794
- ]
21795
- };
21796
- }
21797
- function checkEmptyWorkstreamDirs() {
21798
- const root = join13(defaultStateDir(), "workspaces");
21799
- const empty = dirNames(root).filter((name) => dirNames(join13(root, name)).length === 0);
21800
- if (empty.length === 0) {
21801
- return { name: "ws-empty", severity: "ok", detail: "no empty workstream dirs" };
21802
- }
21803
- return {
21804
- name: "ws-empty",
21805
- severity: "ok",
21806
- detail: `${empty.length} empty workstream dir(s): ${empty.slice(0, 4).join(", ")}`,
21807
- remediation: [
21808
- "These per-workstream workspace dirs hold no checkouts:",
21809
- ...empty.map((name) => ` ${join13(root, name)}`),
21810
- "",
21811
- "`mu workspace free` removes an agent's dir and its row but not the parent,",
21812
- "so this is normal residue rather than a fault. Remove if you want the",
21813
- "state dir to read cleanly:",
21814
- ...empty.slice(0, 3).map((name) => ` rmdir ${join13(root, name)}`)
21815
- ]
21816
- };
21817
- }
21818
- function findStrayDbFiles(stateDir = defaultStateDir()) {
21819
- const live = /* @__PURE__ */ new Set(["mu.db", "mu.db-wal", "mu.db-shm"]);
21820
- let entries;
21821
- try {
21822
- entries = readdirSync4(stateDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name);
21823
- } catch {
21824
- return [];
21825
- }
21826
- return entries.filter((name) => name.startsWith("mu.db") && !live.has(name)).map((name) => ({ name, path: join13(stateDir, name), bytes: sizeOf(join13(stateDir, name)) })).sort((a, b) => b.bytes - a.bytes);
21827
- }
21828
- function checkStrayDbFiles() {
21829
- const stateDir = defaultStateDir();
21830
- const stray = findStrayDbFiles(stateDir);
21831
- if (stray.length === 0) {
21832
- return { name: "db-copies", severity: "ok", detail: "no stray DB copies" };
21833
- }
21834
- const total = stray.reduce((n, f) => n + f.bytes, 0);
21835
- return {
21836
- name: "db-copies",
21837
- severity: "warn",
21838
- detail: `${stray.length} stray DB file(s), ${formatBytes(total)} \u2014 nothing reads these`,
21839
- remediation: [
21840
- `Files matching mu.db* in ${stateDir} that are not the live WAL triple:`,
21841
- ...stray.map((f) => ` ${formatBytes(f.bytes).padStart(6)} ${f.name}`),
21842
- "",
21843
- `Live DB: ${defaultDbPath()}`,
21844
- "",
21845
- "These are hand-made copies and pre-upgrade saves. No mu code path reads",
21846
- "them and none prunes them. Real disaster recovery is the ops log, so a",
21847
- "copy is only ever a convenience:",
21848
- " mu rebuild <file> # DR from the ops log, not from a copy",
21849
- " mu db backup <file> # a fresh copy, if that is what you wanted"
21850
- ]
21851
- };
21852
- }
21853
- function checkRemovedExportsDir() {
21854
- const root = join13(defaultStateDir(), "exports");
21855
- const entries = dirNames(root);
21856
- if (entries.length === 0) {
21857
- return { name: "exports", severity: "ok", detail: "no leftover export dirs" };
21858
- }
21859
- return {
21860
- name: "exports",
21861
- severity: "warn",
21862
- detail: `${entries.length} export dir(s) from the removed \`mu workstream export\``,
21863
- remediation: [
21864
- `${root} holds ${entries.length} directory/ies.`,
21865
- "",
21866
- "`mu workstream export` and its markdown bucket were removed in 1.0; this",
21867
- "is output from a version that still had it. Nothing in mu reads or prunes",
21868
- "it. Keep anything you still want, then:",
21869
- ` rm -rf ${root}`
21870
- ]
21871
- };
21872
- }
21873
- function checkStaleLocks(now = Date.now()) {
21874
- const root = join13(defaultStateDir(), "locks");
21875
- const stale = [];
21876
- for (const name of dirNames(root)) {
21877
- try {
21878
- const ageMs2 = now - statSync4(join13(root, name)).mtimeMs;
21879
- if (ageMs2 > STALE_LOCK_REPORT_MS) stale.push({ name, ageMs: ageMs2 });
21880
- } catch {
21881
- }
21882
- }
21883
- if (stale.length === 0) {
21884
- return { name: "locks", severity: "ok", detail: "no stale lock dirs" };
21885
- }
21886
- return {
21887
- name: "locks",
21888
- severity: "warn",
21889
- detail: `${stale.length} lock dir(s) older than 1h`,
21890
- remediation: [
21891
- "These advisory-lock dirs outlived the process that took them:",
21892
- ...stale.map((s) => ` ${s.name} (${Math.round(s.ageMs / 6e4)}m old)`),
21893
- "",
21894
- "Not a deadlock: withFileLock steals a lock older than 30s. But it only",
21895
- "steals under contention, so these will sit here forever. They are evidence",
21896
- "of a spawn or segment flush that died mid-critical-section.",
21897
- ` rm -rf ${root}/<name>`
21898
- ]
21899
- };
21900
- }
21901
- function walkBytes(path2) {
21902
- let total = 0;
21903
- let entries;
21904
- try {
21905
- entries = readdirSync4(path2, { withFileTypes: true });
21906
- } catch {
21907
- return 0;
21908
- }
21909
- for (const entry of entries) {
21910
- const child = join13(path2, entry.name);
21911
- if (entry.isDirectory()) total += walkBytes(child);
21912
- else if (entry.isFile()) total += sizeOf(child);
21913
- }
21914
- return total;
21915
- }
21916
- function measureWorkspaceUsage(db) {
21917
- const root = join13(defaultStateDir(), "workspaces");
21918
- const registered = new Set(listWorkspaces2(db).map((w) => w.path));
21919
- const usage = [];
21920
- for (const wsName of dirNames(root)) {
21921
- const wsRoot = join13(root, wsName);
21922
- for (const agentName of dirNames(wsRoot)) {
21923
- const path2 = join13(wsRoot, agentName);
21924
- usage.push({
21925
- workstreamName: wsName,
21926
- agentName,
21927
- path: path2,
21928
- bytes: walkBytes(path2),
21929
- orphan: !registered.has(path2)
21930
- });
21931
- }
21932
- }
21933
- return usage.sort((a, b) => b.bytes - a.bytes);
21934
- }
21935
- function checkDiskRecon(db) {
21936
- return [
21937
- checkMissingWorkspaceDirs(db),
21938
- checkWorkspaceOrphanDirs(db),
21939
- checkEmptyWorkstreamDirs(),
21940
- checkStrayDbFiles(),
21941
- checkRemovedExportsDir(),
21942
- checkStaleLocks()
21943
- ];
21944
- }
21945
-
21946
- // src/cli/doctor.ts
21947
22138
  init_doctor_summary();
21948
22139
 
21949
22140
  // src/dormant.ts
@@ -22272,12 +22463,37 @@ state (workstream=${ws})`));
22272
22463
  throw new DriftDetectedError(cheap.totalUnexplained, []);
22273
22464
  }
22274
22465
  }
22466
+ const size = opsSizeHint(db);
22467
+ if (size) {
22468
+ console.log(` size : ${pc.yellow("hint")} ${size.line}`);
22469
+ for (const c of size.commands) console.log(pc.dim(` ${c}`));
22470
+ }
22275
22471
  if (sawHazard) {
22276
22472
  console.log(
22277
22473
  pc.dim("\nSee the fleet / disk sections above: at least one finding needs attention.")
22278
22474
  );
22279
22475
  }
22280
22476
  }
22477
+ function opsSizeHint(db) {
22478
+ const forgettable = forgetHint(db);
22479
+ const tomb = planCompact(db);
22480
+ const parts = [];
22481
+ const commands = [];
22482
+ if (forgettable) {
22483
+ parts.push(
22484
+ `${forgettable.total} torn-down workstreams hold ${forgettable.ops} ops (${formatBytes(forgettable.bytes)}); largest: ${forgettable.top.join(", ")}`
22485
+ );
22486
+ commands.push(
22487
+ "see them: mu workstream list --torn-down",
22488
+ `forget some: mu db forget ${forgettable.top.join(" ")}`
22489
+ );
22490
+ }
22491
+ if (tomb.bytes >= 1024 * 1024) {
22492
+ parts.push(`${tomb.tombstones} redundant note tombstones (${formatBytes(tomb.bytes)})`);
22493
+ commands.push("compact: mu db compact");
22494
+ }
22495
+ return parts.length === 0 ? void 0 : { line: parts.join("; "), commands };
22496
+ }
22281
22497
  async function cmdDoctorJson(db, opts = {}) {
22282
22498
  const health = await muxHealth();
22283
22499
  const muExt = ctlExtensionDoctorCheck();
@@ -22419,6 +22635,7 @@ async function cmdDoctorJson(db, opts = {}) {
22419
22635
  disk,
22420
22636
  workspaceUsage,
22421
22637
  drift,
22638
+ size: opsSizeHint(db) ?? null,
22422
22639
  remediation: drift.ok === true ? [] : driftRemediation()
22423
22640
  });
22424
22641
  if (driftFailure !== null) throw driftFailure;