@mu-crew/mu 3.4.0 → 3.6.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 +475 -257
- package/dist/cli.js.map +1 -1
- package/dist/extension/mu-pi.js +308 -107
- package/dist/extension/mu-pi.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/docs/VISION.md +3 -1
- package/docs/architecture/ops-log.md +26 -7
- package/docs/guide/cleanup.md +19 -0
- package/docs/guide/delegate.md +15 -3
- package/docs/guide/dispatch.md +2 -1
- package/docs/guide/getting-started.md +8 -3
- package/docs/reference/env.md +2 -2
- package/package.json +1 -1
- package/skills/mu/SKILL.md +2 -2
- package/skills/mu/recipes/orchestrator-loop.md +8 -2
- package/skills/mu/recipes/recovery.md +3 -0
- package/skills/mu/recipes/tasks-or-calls.md +9 -5
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`,
|
|
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
|
|
@@ -7340,7 +7345,7 @@ async function waitForTasks(db, input, opts) {
|
|
|
7340
7345
|
const wantAny = opts.any === true;
|
|
7341
7346
|
const timeoutMs = opts.timeoutMs ?? 6e5;
|
|
7342
7347
|
const pollMs = opts.pollMs ?? 1e3;
|
|
7343
|
-
const stuckAfterMs = opts.stuckAfterMs ??
|
|
7348
|
+
const stuckAfterMs = opts.stuckAfterMs ?? DEFAULT_STUCK_AFTER_MS;
|
|
7344
7349
|
const onStall = opts.onStall ?? "warn";
|
|
7345
7350
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
|
|
7346
7351
|
for (const ref of refs) {
|
|
@@ -7445,7 +7450,7 @@ async function waitForTasks(db, input, opts) {
|
|
|
7445
7450
|
if (isDone(snap)) return snap;
|
|
7446
7451
|
}
|
|
7447
7452
|
}
|
|
7448
|
-
var currentWaitSleep, pollCount, defaultStuckWarn, currentStuckWarn;
|
|
7453
|
+
var currentWaitSleep, pollCount, DEFAULT_STUCK_AFTER_MS, defaultStuckWarn, currentStuckWarn;
|
|
7449
7454
|
var init_wait = __esm({
|
|
7450
7455
|
"src/tasks/wait.ts"() {
|
|
7451
7456
|
"use strict";
|
|
@@ -7455,6 +7460,7 @@ var init_wait = __esm({
|
|
|
7455
7460
|
init_status();
|
|
7456
7461
|
currentWaitSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
|
|
7457
7462
|
pollCount = 0;
|
|
7463
|
+
DEFAULT_STUCK_AFTER_MS = 12e4;
|
|
7458
7464
|
defaultStuckWarn = (msg) => {
|
|
7459
7465
|
process.stderr.write(msg);
|
|
7460
7466
|
};
|
|
@@ -7620,7 +7626,10 @@ var init_adopt = __esm({
|
|
|
7620
7626
|
// src/agents/delegate.ts
|
|
7621
7627
|
function delegateOutcome(agent, timedOut) {
|
|
7622
7628
|
if (agent.dead) return "died";
|
|
7623
|
-
if (agent.fired)
|
|
7629
|
+
if (agent.fired) {
|
|
7630
|
+
if (agent.lastError !== void 0) return "error";
|
|
7631
|
+
return agent.lastText ? "done" : "empty";
|
|
7632
|
+
}
|
|
7624
7633
|
return timedOut ? "timeout" : "pending";
|
|
7625
7634
|
}
|
|
7626
7635
|
var init_delegate = __esm({
|
|
@@ -7833,6 +7842,7 @@ async function waitForAgents(db, input, opts) {
|
|
|
7833
7842
|
st.wasBusy = true;
|
|
7834
7843
|
st.fired = true;
|
|
7835
7844
|
if (snap.lastText !== void 0) st.lastText = snap.lastText;
|
|
7845
|
+
if (snap.lastError !== void 0) st.lastError = snap.lastError;
|
|
7836
7846
|
}
|
|
7837
7847
|
wake();
|
|
7838
7848
|
},
|
|
@@ -10400,13 +10410,13 @@ import {
|
|
|
10400
10410
|
appendFileSync,
|
|
10401
10411
|
existsSync as existsSync14,
|
|
10402
10412
|
mkdirSync as mkdirSync5,
|
|
10403
|
-
readdirSync as
|
|
10413
|
+
readdirSync as readdirSync4,
|
|
10404
10414
|
readFileSync as readFileSync3,
|
|
10405
|
-
statSync as
|
|
10415
|
+
statSync as statSync3,
|
|
10406
10416
|
writeFileSync as writeFileSync2
|
|
10407
10417
|
} from "fs";
|
|
10408
10418
|
import { tmpdir as tmpdir2 } from "os";
|
|
10409
|
-
import { join as
|
|
10419
|
+
import { join as join13, resolve as resolve6, sep as sep2 } from "path";
|
|
10410
10420
|
import { crc32 } from "zlib";
|
|
10411
10421
|
function syncDir() {
|
|
10412
10422
|
const dir = process.env.MU_SYNC_DIR;
|
|
@@ -10430,7 +10440,7 @@ function localMachineId(db) {
|
|
|
10430
10440
|
return row2.machine_id;
|
|
10431
10441
|
}
|
|
10432
10442
|
function segmentPath(dir, machineId2) {
|
|
10433
|
-
return
|
|
10443
|
+
return join13(dir, `${machineId2}${SEGMENT_EXT}`);
|
|
10434
10444
|
}
|
|
10435
10445
|
function manifestPath(segment) {
|
|
10436
10446
|
return segment.replace(new RegExp(`${SEGMENT_EXT}$`), MANIFEST_EXT);
|
|
@@ -10574,7 +10584,7 @@ async function flushSegment(db, dir = syncDir()) {
|
|
|
10574
10584
|
const path2 = segmentPath(dir, machineId2);
|
|
10575
10585
|
const lockName = createHash2("sha256").update(`${dir}${machineId2}`).digest("hex").slice(0, 16);
|
|
10576
10586
|
return withFileLock(
|
|
10577
|
-
|
|
10587
|
+
join13(locksDir(), `segment-${lockName}.lock`),
|
|
10578
10588
|
`segment:${machineId2}`,
|
|
10579
10589
|
() => Promise.resolve(flushLocked(db, path2, machineId2))
|
|
10580
10590
|
);
|
|
@@ -10696,16 +10706,16 @@ function verifyAgainstManifest(segment) {
|
|
|
10696
10706
|
function discoverPeers(dir, selfMachineId) {
|
|
10697
10707
|
if (!existsSync14(dir)) return [];
|
|
10698
10708
|
const peers = [];
|
|
10699
|
-
for (const name of
|
|
10709
|
+
for (const name of readdirSync4(dir)) {
|
|
10700
10710
|
if (!name.endsWith(SEGMENT_EXT)) continue;
|
|
10701
10711
|
const stem = name.slice(0, -SEGMENT_EXT.length);
|
|
10702
10712
|
const conflictAt = stem.indexOf(".sync-conflict-");
|
|
10703
10713
|
const conflictCopy = conflictAt > 0;
|
|
10704
10714
|
const machineId2 = conflictCopy ? stem.slice(0, conflictAt) : stem;
|
|
10705
10715
|
if (machineId2 === selfMachineId && !conflictCopy) continue;
|
|
10706
|
-
const path2 =
|
|
10716
|
+
const path2 = join13(dir, name);
|
|
10707
10717
|
try {
|
|
10708
|
-
if (!
|
|
10718
|
+
if (!statSync3(path2).isFile()) continue;
|
|
10709
10719
|
} catch {
|
|
10710
10720
|
continue;
|
|
10711
10721
|
}
|
|
@@ -10910,7 +10920,7 @@ var init_segments = __esm({
|
|
|
10910
10920
|
});
|
|
10911
10921
|
|
|
10912
10922
|
// src/sync.ts
|
|
10913
|
-
import { existsSync as existsSync15, statSync as
|
|
10923
|
+
import { existsSync as existsSync15, statSync as statSync4 } from "fs";
|
|
10914
10924
|
import Database2 from "better-sqlite3";
|
|
10915
10925
|
function peerStatuses(db, dir) {
|
|
10916
10926
|
const self = localMachineId(db);
|
|
@@ -10918,7 +10928,7 @@ function peerStatuses(db, dir) {
|
|
|
10918
10928
|
const rows = discoverPeers(dir, self).map((peer) => {
|
|
10919
10929
|
let lastSeenMs = null;
|
|
10920
10930
|
try {
|
|
10921
|
-
lastSeenMs =
|
|
10931
|
+
lastSeenMs = statSync4(peer.path).mtimeMs;
|
|
10922
10932
|
} catch {
|
|
10923
10933
|
lastSeenMs = null;
|
|
10924
10934
|
}
|
|
@@ -19393,7 +19403,7 @@ async function cmdTaskWait(db, ids, opts) {
|
|
|
19393
19403
|
if (ids.length === 0) {
|
|
19394
19404
|
throw new UsageError("mu task wait: at least one task id is required");
|
|
19395
19405
|
}
|
|
19396
|
-
const onStallRaw = opts.onStall ?? "
|
|
19406
|
+
const onStallRaw = opts.onStall ?? "exit";
|
|
19397
19407
|
if (onStallRaw !== "warn" && onStallRaw !== "exit") {
|
|
19398
19408
|
throw new UsageError(`--on-stall: expected 'warn' or 'exit', got '${onStallRaw}'`);
|
|
19399
19409
|
}
|
|
@@ -19406,7 +19416,7 @@ async function cmdTaskWait(db, ids, opts) {
|
|
|
19406
19416
|
}
|
|
19407
19417
|
const workstreamSet = new Set(refs.map((r) => r.workstreamName));
|
|
19408
19418
|
const timeoutMs = opts.timeout !== void 0 ? opts.timeout * 1e3 : 6e5;
|
|
19409
|
-
const stuckAfterMs = opts.stuckAfter !== void 0 ? opts.stuckAfter * 1e3 :
|
|
19419
|
+
const stuckAfterMs = opts.stuckAfter !== void 0 ? opts.stuckAfter * 1e3 : DEFAULT_STUCK_AFTER_MS;
|
|
19410
19420
|
const sdkOpts = { timeoutMs, stuckAfterMs };
|
|
19411
19421
|
if (statusOpt !== void 0) sdkOpts.status = statusOpt;
|
|
19412
19422
|
if (wantAny) sdkOpts.any = true;
|
|
@@ -20161,7 +20171,7 @@ function wireTaskCommands(program) {
|
|
|
20161
20171
|
return handle((db) => cmdTaskReparent(db, id, opts), this)();
|
|
20162
20172
|
});
|
|
20163
20173
|
task.command("wait <ids...>").description(
|
|
20164
|
-
"Block until the listed tasks reach --status (default CLOSED). Each <id> may be bare (resolves via -w / $MU_SESSION / tmux) or qualified `<workstream>/<name>` (cross-workstream waits don't need -w). Default: every task must reach the target (--all). --any / --first exit on the first one that does; --first additionally prints the firing ref's qualified id to stdout.
|
|
20174
|
+
"Block until the listed tasks reach --status (default CLOSED). Each <id> may be bare (resolves via -w / $MU_SESSION / tmux) or qualified `<workstream>/<name>` (cross-workstream waits don't need -w). Default: every task must reach the target (--all). --any / --first exit on the first one that does; --first additionally prints the firing ref's qualified id to stdout. A worker needing attention terminates the wait with exit 7 (--on-stall exit, the default). Exit 0 = condition met; 5 = timeout; 6 = a watched task was reaper-flipped IN_PROGRESS\u2192OPEN (target=CLOSED only); 7 = a worker needs attention (target=CLOSED, unless --on-stall warn)."
|
|
20165
20175
|
).option(
|
|
20166
20176
|
"--status <status>",
|
|
20167
20177
|
`target status (${TASK_STATUS_LIST}, case-insensitive); default CLOSED`
|
|
@@ -20170,14 +20180,14 @@ function wireTaskCommands(program) {
|
|
|
20170
20180
|
"succeed as soon as ONE listed task reaches the target (default: all must). Reports THAT one fired, not which: --json leaves `firing` null even on success, so use --first if you need to identify the ref."
|
|
20171
20181
|
).option(
|
|
20172
20182
|
"--first",
|
|
20173
|
-
"alias for --any that ALSO prints the firing ref's qualified id to stdout and populates `firing` in --json (which --any leaves null). Use to drive a single-shot dispatch loop: `closed=$(mu task wait a b --first --
|
|
20183
|
+
"alias for --any that ALSO prints the firing ref's qualified id to stdout and populates `firing` in --json (which --any leaves null). Use to drive a single-shot dispatch loop: `closed=$(mu task wait a b --first --json | jq -r .firing.qualifiedId)`."
|
|
20174
20184
|
).option("--timeout <seconds>", "max seconds to wait (0 = forever, default 600)", parseLines).option(
|
|
20175
20185
|
"--stuck-after <seconds>",
|
|
20176
|
-
"the TRIGGER: mark an IN_PROGRESS task as needing attention when its owner has been in needs_input for >= N seconds since their last status change (0 = disable, default
|
|
20186
|
+
"the TRIGGER: mark an IN_PROGRESS task as needing attention when its owner has been in needs_input for >= N seconds since their last status change (0 = disable, default 120). needs_input has several causes \u2014 the worker may have finished without closing, be waiting on an answer, or be sitting at a prompt \u2014 so the warning names the observation and points at `mu agent read <owner>`, which is the next move in every case. The default ACTION is `exit` (exit 7); see --on-stall warn to keep polling instead.",
|
|
20177
20187
|
parseLines
|
|
20178
20188
|
).option(
|
|
20179
20189
|
"--on-stall <action>",
|
|
20180
|
-
"the ACTION when --stuck-after fires: '
|
|
20190
|
+
"the ACTION when --stuck-after fires: 'exit' (default; yellow attention warning naming the owner + age, plus a corroborating agent_logs event, then exit 7 = STALL_DETECTED so an orchestrator can branch on the idle-vs-dead distinction) or 'warn' (same emit + persist; wait keeps polling). Suppressed when --status is anything other than CLOSED (mirrors exit-6's carve-out). If exit-6 (dead pane) and exit-7 (stall) would fire in the same poll, exit 6 wins (dead pane is unambiguous; stall is ambiguous)."
|
|
20181
20191
|
).option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(ids) {
|
|
20182
20192
|
const opts = this.opts();
|
|
20183
20193
|
return handle((db) => cmdTaskWait(db, ids, opts), this)();
|
|
@@ -20677,7 +20687,8 @@ async function cmdAgentWait(db, rawNames, opts) {
|
|
|
20677
20687
|
const settled = ctlRequest(sock, { op: "wait", afterRuns: probe.status.runs }, { signal }).then(
|
|
20678
20688
|
(reply) => reply.ok && reply.state !== void 0 ? {
|
|
20679
20689
|
status: ctlRuntimeState(reply.state),
|
|
20680
|
-
...reply.lastText !== void 0 ? { lastText: reply.lastText } : {}
|
|
20690
|
+
...reply.lastText !== void 0 ? { lastText: reply.lastText } : {},
|
|
20691
|
+
...reply.lastError !== void 0 ? { lastError: reply.lastError } : {}
|
|
20681
20692
|
} : { status: "unknown", unknownReason: reply.ok ? "ctl wait: no state" : reply.error },
|
|
20682
20693
|
// The connection dropped. A socket that still answers (pi's /new
|
|
20683
20694
|
// restarts the session) hands back to polling; one that does not
|
|
@@ -20886,8 +20897,308 @@ function wireSelfCommands(program) {
|
|
|
20886
20897
|
}
|
|
20887
20898
|
|
|
20888
20899
|
// src/cli/db.ts
|
|
20889
|
-
import { existsSync as existsSync16, mkdirSync as mkdirSync6 } from "fs";
|
|
20900
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync6, statSync as statSync5 } from "fs";
|
|
20890
20901
|
import { dirname as dirname13, resolve as resolve7 } from "path";
|
|
20902
|
+
|
|
20903
|
+
// src/compact.ts
|
|
20904
|
+
init_legacy_ops();
|
|
20905
|
+
var REDUNDANT_TOMBSTONES_SQL = `
|
|
20906
|
+
SELECT d.seq AS seq, length(d.payload) AS bytes
|
|
20907
|
+
FROM ops d
|
|
20908
|
+
WHERE d.entity = 'note' AND d.op = 'del' AND d.payload <> '{}'
|
|
20909
|
+
AND EXISTS (SELECT 1 FROM ops p
|
|
20910
|
+
WHERE p.entity = 'note' AND p.key = d.key AND p.op = 'put')`;
|
|
20911
|
+
function planCompact(db) {
|
|
20912
|
+
const r = db.prepare(
|
|
20913
|
+
`SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM (${REDUNDANT_TOMBSTONES_SQL})`
|
|
20914
|
+
).get();
|
|
20915
|
+
return { tombstones: r.n, bytes: r.b };
|
|
20916
|
+
}
|
|
20917
|
+
function compact(db) {
|
|
20918
|
+
const plan = planCompact(db);
|
|
20919
|
+
db.transaction(() => {
|
|
20920
|
+
db.prepare(
|
|
20921
|
+
`UPDATE ops SET payload = '{}' WHERE seq IN (SELECT seq FROM (${REDUNDANT_TOMBSTONES_SQL}))`
|
|
20922
|
+
).run();
|
|
20923
|
+
})();
|
|
20924
|
+
return plan;
|
|
20925
|
+
}
|
|
20926
|
+
var WORKSTREAM_OPS_WHERE = `(key = @ws OR substr(key, 1, length(@ws) + 1) = @ws || '/')`;
|
|
20927
|
+
function listForgetCandidates(db) {
|
|
20928
|
+
const spellings = intentSpellings("workstream.teardown");
|
|
20929
|
+
const names = db.prepare(
|
|
20930
|
+
`SELECT key AS name, MAX(created_at) AS at
|
|
20931
|
+
FROM ops
|
|
20932
|
+
WHERE entity = 'workstream' AND op = 'del'
|
|
20933
|
+
AND intent IN (${spellings.map(() => "?").join(", ")})
|
|
20934
|
+
AND key NOT IN (SELECT name FROM workstreams)
|
|
20935
|
+
GROUP BY key`
|
|
20936
|
+
).all(...spellings);
|
|
20937
|
+
const cost = db.prepare(
|
|
20938
|
+
`SELECT COUNT(*) AS n, COALESCE(SUM(length(payload)), 0) AS b FROM ops WHERE ${WORKSTREAM_OPS_WHERE}`
|
|
20939
|
+
);
|
|
20940
|
+
return names.map((r) => {
|
|
20941
|
+
const c = cost.get({ ws: r.name });
|
|
20942
|
+
return { name: r.name, tornDownAt: r.at, ops: c.n, bytes: c.b };
|
|
20943
|
+
}).sort((a, b) => b.bytes - a.bytes);
|
|
20944
|
+
}
|
|
20945
|
+
function planForget(db, names) {
|
|
20946
|
+
const all = new Map(listForgetCandidates(db).map((c) => [c.name, c]));
|
|
20947
|
+
const live = new Set(
|
|
20948
|
+
db.prepare("SELECT name FROM workstreams").all().map((r) => r.name)
|
|
20949
|
+
);
|
|
20950
|
+
const plan = { candidates: [], refused: [] };
|
|
20951
|
+
for (const name of new Set(names)) {
|
|
20952
|
+
const c = all.get(name);
|
|
20953
|
+
if (c) plan.candidates.push(c);
|
|
20954
|
+
else plan.refused.push({ name, why: live.has(name) ? "live" : "never-torn-down" });
|
|
20955
|
+
}
|
|
20956
|
+
return plan;
|
|
20957
|
+
}
|
|
20958
|
+
function forget(db, plan) {
|
|
20959
|
+
const del = db.prepare(`DELETE FROM ops WHERE ${WORKSTREAM_OPS_WHERE}`);
|
|
20960
|
+
let ops = 0;
|
|
20961
|
+
db.transaction(() => {
|
|
20962
|
+
for (const c of plan.candidates) ops += del.run({ ws: c.name }).changes;
|
|
20963
|
+
})();
|
|
20964
|
+
return { ops };
|
|
20965
|
+
}
|
|
20966
|
+
|
|
20967
|
+
// src/disk-recon.ts
|
|
20968
|
+
init_db();
|
|
20969
|
+
init_workspace();
|
|
20970
|
+
import { readdirSync as readdirSync3, statSync as statSync2 } from "fs";
|
|
20971
|
+
import { join as join12 } from "path";
|
|
20972
|
+
var STALE_LOCK_REPORT_MS = 60 * 60 * 1e3;
|
|
20973
|
+
function formatBytes(bytes) {
|
|
20974
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
20975
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
|
|
20976
|
+
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}M`;
|
|
20977
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`;
|
|
20978
|
+
}
|
|
20979
|
+
function dirNames(path2) {
|
|
20980
|
+
try {
|
|
20981
|
+
return readdirSync3(path2, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
20982
|
+
} catch {
|
|
20983
|
+
return [];
|
|
20984
|
+
}
|
|
20985
|
+
}
|
|
20986
|
+
function exists(path2) {
|
|
20987
|
+
try {
|
|
20988
|
+
statSync2(path2);
|
|
20989
|
+
return true;
|
|
20990
|
+
} catch {
|
|
20991
|
+
return false;
|
|
20992
|
+
}
|
|
20993
|
+
}
|
|
20994
|
+
function sizeOf(path2) {
|
|
20995
|
+
try {
|
|
20996
|
+
return statSync2(path2).size;
|
|
20997
|
+
} catch {
|
|
20998
|
+
return 0;
|
|
20999
|
+
}
|
|
21000
|
+
}
|
|
21001
|
+
function findMissingWorkspaceDirs(db) {
|
|
21002
|
+
return listWorkspaces2(db).filter((row2) => !exists(row2.path)).map((row2) => ({
|
|
21003
|
+
agentName: row2.agentName,
|
|
21004
|
+
workstreamName: row2.workstreamName,
|
|
21005
|
+
path: row2.path
|
|
21006
|
+
}));
|
|
21007
|
+
}
|
|
21008
|
+
function checkMissingWorkspaceDirs(db) {
|
|
21009
|
+
const missing = findMissingWorkspaceDirs(db);
|
|
21010
|
+
if (missing.length === 0) {
|
|
21011
|
+
return { name: "ws-rows", severity: "ok", detail: "every workspace row has its dir" };
|
|
21012
|
+
}
|
|
21013
|
+
return {
|
|
21014
|
+
name: "ws-rows",
|
|
21015
|
+
severity: "warn",
|
|
21016
|
+
detail: `${missing.length} workspace row(s) point at a path that is gone`,
|
|
21017
|
+
remediation: [
|
|
21018
|
+
"These vcs_workspaces rows name a directory that no longer exists:",
|
|
21019
|
+
...missing.map((m) => ` ${m.workstreamName}/${m.agentName} ${m.path}`),
|
|
21020
|
+
"",
|
|
21021
|
+
"mu reports such a row as a healthy workspace on every read surface, and",
|
|
21022
|
+
"the next send or refresh against it fails inside the VCS backend rather",
|
|
21023
|
+
"than at the row that lied. Unregister them:",
|
|
21024
|
+
...missing.map((m) => ` mu workspace free ${m.agentName} -w ${m.workstreamName}`)
|
|
21025
|
+
]
|
|
21026
|
+
};
|
|
21027
|
+
}
|
|
21028
|
+
function checkWorkspaceOrphanDirs(db) {
|
|
21029
|
+
const orphans = listAllOrphanWorkspaces(db);
|
|
21030
|
+
if (orphans.length === 0) {
|
|
21031
|
+
return { name: "ws-dirs", severity: "ok", detail: "no orphan workspace dirs" };
|
|
21032
|
+
}
|
|
21033
|
+
const stranded = orphans.filter((o) => o.stranded);
|
|
21034
|
+
const strandedNote = stranded.length > 0 ? `, ${stranded.length} stranded` : "";
|
|
21035
|
+
return {
|
|
21036
|
+
name: "ws-dirs",
|
|
21037
|
+
severity: "warn",
|
|
21038
|
+
detail: `${orphans.length} workspace dir(s) with no DB row${strandedNote}`,
|
|
21039
|
+
remediation: [
|
|
21040
|
+
"These directories have no row in vcs_workspaces:",
|
|
21041
|
+
...orphans.map((o) => ` ${o.path}${o.stranded ? " (workstream row also gone)" : ""}`),
|
|
21042
|
+
"",
|
|
21043
|
+
"Each one blocks the next `--workspace` spawn for that agent name. mu will",
|
|
21044
|
+
"not remove them: they may hold uncommitted work, and a stranded dir is the",
|
|
21045
|
+
"only remaining copy of it. Inspect, then remove by hand:",
|
|
21046
|
+
" mu workspace orphans --all",
|
|
21047
|
+
...orphans.slice(0, 3).map((o) => ` rm -rf ${o.path}`)
|
|
21048
|
+
]
|
|
21049
|
+
};
|
|
21050
|
+
}
|
|
21051
|
+
function checkEmptyWorkstreamDirs() {
|
|
21052
|
+
const root = join12(defaultStateDir(), "workspaces");
|
|
21053
|
+
const empty = dirNames(root).filter((name) => dirNames(join12(root, name)).length === 0);
|
|
21054
|
+
if (empty.length === 0) {
|
|
21055
|
+
return { name: "ws-empty", severity: "ok", detail: "no empty workstream dirs" };
|
|
21056
|
+
}
|
|
21057
|
+
return {
|
|
21058
|
+
name: "ws-empty",
|
|
21059
|
+
severity: "ok",
|
|
21060
|
+
detail: `${empty.length} empty workstream dir(s): ${empty.slice(0, 4).join(", ")}`,
|
|
21061
|
+
remediation: [
|
|
21062
|
+
"These per-workstream workspace dirs hold no checkouts:",
|
|
21063
|
+
...empty.map((name) => ` ${join12(root, name)}`),
|
|
21064
|
+
"",
|
|
21065
|
+
"`mu workspace free` removes an agent's dir and its row but not the parent,",
|
|
21066
|
+
"so this is normal residue rather than a fault. Remove if you want the",
|
|
21067
|
+
"state dir to read cleanly:",
|
|
21068
|
+
...empty.slice(0, 3).map((name) => ` rmdir ${join12(root, name)}`)
|
|
21069
|
+
]
|
|
21070
|
+
};
|
|
21071
|
+
}
|
|
21072
|
+
function findStrayDbFiles(stateDir = defaultStateDir()) {
|
|
21073
|
+
const live = /* @__PURE__ */ new Set(["mu.db", "mu.db-wal", "mu.db-shm"]);
|
|
21074
|
+
let entries;
|
|
21075
|
+
try {
|
|
21076
|
+
entries = readdirSync3(stateDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name);
|
|
21077
|
+
} catch {
|
|
21078
|
+
return [];
|
|
21079
|
+
}
|
|
21080
|
+
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);
|
|
21081
|
+
}
|
|
21082
|
+
function checkStrayDbFiles() {
|
|
21083
|
+
const stateDir = defaultStateDir();
|
|
21084
|
+
const stray = findStrayDbFiles(stateDir);
|
|
21085
|
+
if (stray.length === 0) {
|
|
21086
|
+
return { name: "db-copies", severity: "ok", detail: "no stray DB copies" };
|
|
21087
|
+
}
|
|
21088
|
+
const total = stray.reduce((n, f) => n + f.bytes, 0);
|
|
21089
|
+
return {
|
|
21090
|
+
name: "db-copies",
|
|
21091
|
+
severity: "warn",
|
|
21092
|
+
detail: `${stray.length} stray DB file(s), ${formatBytes(total)} \u2014 nothing reads these`,
|
|
21093
|
+
remediation: [
|
|
21094
|
+
`Files matching mu.db* in ${stateDir} that are not the live WAL triple:`,
|
|
21095
|
+
...stray.map((f) => ` ${formatBytes(f.bytes).padStart(6)} ${f.name}`),
|
|
21096
|
+
"",
|
|
21097
|
+
`Live DB: ${defaultDbPath()}`,
|
|
21098
|
+
"",
|
|
21099
|
+
"These are hand-made copies and pre-upgrade saves. No mu code path reads",
|
|
21100
|
+
"them and none prunes them. Real disaster recovery is the ops log, so a",
|
|
21101
|
+
"copy is only ever a convenience:",
|
|
21102
|
+
" mu rebuild <file> # DR from the ops log, not from a copy",
|
|
21103
|
+
" mu db backup <file> # a fresh copy, if that is what you wanted"
|
|
21104
|
+
]
|
|
21105
|
+
};
|
|
21106
|
+
}
|
|
21107
|
+
function checkRemovedExportsDir() {
|
|
21108
|
+
const root = join12(defaultStateDir(), "exports");
|
|
21109
|
+
const entries = dirNames(root);
|
|
21110
|
+
if (entries.length === 0) {
|
|
21111
|
+
return { name: "exports", severity: "ok", detail: "no leftover export dirs" };
|
|
21112
|
+
}
|
|
21113
|
+
return {
|
|
21114
|
+
name: "exports",
|
|
21115
|
+
severity: "warn",
|
|
21116
|
+
detail: `${entries.length} export dir(s) from the removed \`mu workstream export\``,
|
|
21117
|
+
remediation: [
|
|
21118
|
+
`${root} holds ${entries.length} directory/ies.`,
|
|
21119
|
+
"",
|
|
21120
|
+
"`mu workstream export` and its markdown bucket were removed in 1.0; this",
|
|
21121
|
+
"is output from a version that still had it. Nothing in mu reads or prunes",
|
|
21122
|
+
"it. Keep anything you still want, then:",
|
|
21123
|
+
` rm -rf ${root}`
|
|
21124
|
+
]
|
|
21125
|
+
};
|
|
21126
|
+
}
|
|
21127
|
+
function checkStaleLocks(now = Date.now()) {
|
|
21128
|
+
const root = join12(defaultStateDir(), "locks");
|
|
21129
|
+
const stale = [];
|
|
21130
|
+
for (const name of dirNames(root)) {
|
|
21131
|
+
try {
|
|
21132
|
+
const ageMs2 = now - statSync2(join12(root, name)).mtimeMs;
|
|
21133
|
+
if (ageMs2 > STALE_LOCK_REPORT_MS) stale.push({ name, ageMs: ageMs2 });
|
|
21134
|
+
} catch {
|
|
21135
|
+
}
|
|
21136
|
+
}
|
|
21137
|
+
if (stale.length === 0) {
|
|
21138
|
+
return { name: "locks", severity: "ok", detail: "no stale lock dirs" };
|
|
21139
|
+
}
|
|
21140
|
+
return {
|
|
21141
|
+
name: "locks",
|
|
21142
|
+
severity: "warn",
|
|
21143
|
+
detail: `${stale.length} lock dir(s) older than 1h`,
|
|
21144
|
+
remediation: [
|
|
21145
|
+
"These advisory-lock dirs outlived the process that took them:",
|
|
21146
|
+
...stale.map((s) => ` ${s.name} (${Math.round(s.ageMs / 6e4)}m old)`),
|
|
21147
|
+
"",
|
|
21148
|
+
"Not a deadlock: withFileLock steals a lock older than 30s. But it only",
|
|
21149
|
+
"steals under contention, so these will sit here forever. They are evidence",
|
|
21150
|
+
"of a spawn or segment flush that died mid-critical-section.",
|
|
21151
|
+
` rm -rf ${root}/<name>`
|
|
21152
|
+
]
|
|
21153
|
+
};
|
|
21154
|
+
}
|
|
21155
|
+
function walkBytes(path2) {
|
|
21156
|
+
let total = 0;
|
|
21157
|
+
let entries;
|
|
21158
|
+
try {
|
|
21159
|
+
entries = readdirSync3(path2, { withFileTypes: true });
|
|
21160
|
+
} catch {
|
|
21161
|
+
return 0;
|
|
21162
|
+
}
|
|
21163
|
+
for (const entry of entries) {
|
|
21164
|
+
const child = join12(path2, entry.name);
|
|
21165
|
+
if (entry.isDirectory()) total += walkBytes(child);
|
|
21166
|
+
else if (entry.isFile()) total += sizeOf(child);
|
|
21167
|
+
}
|
|
21168
|
+
return total;
|
|
21169
|
+
}
|
|
21170
|
+
function measureWorkspaceUsage(db) {
|
|
21171
|
+
const root = join12(defaultStateDir(), "workspaces");
|
|
21172
|
+
const registered = new Set(listWorkspaces2(db).map((w) => w.path));
|
|
21173
|
+
const usage = [];
|
|
21174
|
+
for (const wsName of dirNames(root)) {
|
|
21175
|
+
const wsRoot = join12(root, wsName);
|
|
21176
|
+
for (const agentName of dirNames(wsRoot)) {
|
|
21177
|
+
const path2 = join12(wsRoot, agentName);
|
|
21178
|
+
usage.push({
|
|
21179
|
+
workstreamName: wsName,
|
|
21180
|
+
agentName,
|
|
21181
|
+
path: path2,
|
|
21182
|
+
bytes: walkBytes(path2),
|
|
21183
|
+
orphan: !registered.has(path2)
|
|
21184
|
+
});
|
|
21185
|
+
}
|
|
21186
|
+
}
|
|
21187
|
+
return usage.sort((a, b) => b.bytes - a.bytes);
|
|
21188
|
+
}
|
|
21189
|
+
function checkDiskRecon(db) {
|
|
21190
|
+
return [
|
|
21191
|
+
checkMissingWorkspaceDirs(db),
|
|
21192
|
+
checkWorkspaceOrphanDirs(db),
|
|
21193
|
+
checkEmptyWorkstreamDirs(),
|
|
21194
|
+
checkStrayDbFiles(),
|
|
21195
|
+
checkRemovedExportsDir(),
|
|
21196
|
+
checkStaleLocks()
|
|
21197
|
+
];
|
|
21198
|
+
}
|
|
21199
|
+
|
|
21200
|
+
// src/cli/db.ts
|
|
21201
|
+
init_drift();
|
|
20891
21202
|
init_output();
|
|
20892
21203
|
|
|
20893
21204
|
// src/cli/handle.ts
|
|
@@ -21695,6 +22006,110 @@ function cmdDbBackup(db, file, opts = {}) {
|
|
|
21695
22006
|
console.log(`Backed up to ${pc.bold(target)}`);
|
|
21696
22007
|
printNextSteps(nextSteps3);
|
|
21697
22008
|
}
|
|
22009
|
+
function backupBeside(db, verb) {
|
|
22010
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
22011
|
+
const target = `${db.name}.pre-${verb}-${ts}`;
|
|
22012
|
+
db.prepare("VACUUM INTO ?").run(target);
|
|
22013
|
+
return target;
|
|
22014
|
+
}
|
|
22015
|
+
function vacuumAndCheck(db, backup) {
|
|
22016
|
+
const before = statSync5(db.name).size;
|
|
22017
|
+
db.exec("VACUUM");
|
|
22018
|
+
const after = statSync5(db.name).size;
|
|
22019
|
+
const report = checkDrift(db);
|
|
22020
|
+
if (!report.clean) {
|
|
22021
|
+
console.error(
|
|
22022
|
+
pc.red(`drift after the rewrite (${report.totalDrift}); the backup is ${backup}`)
|
|
22023
|
+
);
|
|
22024
|
+
throw new DriftDetectedError(report.totalDrift, report.records);
|
|
22025
|
+
}
|
|
22026
|
+
return { before, after };
|
|
22027
|
+
}
|
|
22028
|
+
function cmdDbCompact(db, opts = {}) {
|
|
22029
|
+
const plan = planCompact(db);
|
|
22030
|
+
if (opts.yes !== true || plan.tombstones === 0) {
|
|
22031
|
+
const nextSteps3 = plan.tombstones === 0 ? [] : [{ intent: "Apply it", command: "mu db compact --yes" }];
|
|
22032
|
+
if (opts.json === true) {
|
|
22033
|
+
emitJson({ dryRun: true, ...plan, nextSteps: nextSteps3 });
|
|
22034
|
+
return;
|
|
22035
|
+
}
|
|
22036
|
+
console.log(
|
|
22037
|
+
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.`
|
|
22038
|
+
);
|
|
22039
|
+
printNextSteps(nextSteps3);
|
|
22040
|
+
return;
|
|
22041
|
+
}
|
|
22042
|
+
const backup = backupBeside(db, "compact");
|
|
22043
|
+
compact(db);
|
|
22044
|
+
const size = vacuumAndCheck(db, backup);
|
|
22045
|
+
if (opts.json === true) {
|
|
22046
|
+
emitJson({ dryRun: false, ...plan, backup, ...size });
|
|
22047
|
+
return;
|
|
22048
|
+
}
|
|
22049
|
+
console.log(
|
|
22050
|
+
`Compacted ${plan.tombstones} note tombstones: ${formatBytes(size.before)} \u2192 ${formatBytes(size.after)}. Drift check clean.`
|
|
22051
|
+
);
|
|
22052
|
+
console.log(pc.dim(`backup: ${backup}`));
|
|
22053
|
+
}
|
|
22054
|
+
function printForgetTable(plan) {
|
|
22055
|
+
const t = muTable({ head: ["workstream", "torn down", "ops", "size"].map((h) => pc.bold(h)) });
|
|
22056
|
+
for (const c of plan.candidates)
|
|
22057
|
+
t.push([c.name, c.tornDownAt.slice(0, 10), String(c.ops), formatBytes(c.bytes)]);
|
|
22058
|
+
console.log(t.toString());
|
|
22059
|
+
}
|
|
22060
|
+
function cmdDbForget(db, names, opts = {}) {
|
|
22061
|
+
const plan = planForget(db, names);
|
|
22062
|
+
if (plan.refused.length > 0) {
|
|
22063
|
+
const why = plan.refused.map((r2) => `${r2.name} (${r2.why === "live" ? "is live" : "was never torn down"})`).join(", ");
|
|
22064
|
+
throw new UsageError(`cannot forget: ${why}. Only torn-down workstreams can be forgotten.`);
|
|
22065
|
+
}
|
|
22066
|
+
const ops = plan.candidates.reduce((n, c) => n + c.ops, 0);
|
|
22067
|
+
const bytes = plan.candidates.reduce((n, c) => n + c.bytes, 0);
|
|
22068
|
+
if (opts.yes !== true) {
|
|
22069
|
+
const nextSteps3 = [
|
|
22070
|
+
{ intent: "Forget them (NOT undoable)", command: `mu db forget ${names.join(" ")} --yes` }
|
|
22071
|
+
];
|
|
22072
|
+
if (opts.json === true) {
|
|
22073
|
+
emitJson({ dryRun: true, candidates: plan.candidates, ops, bytes, nextSteps: nextSteps3 });
|
|
22074
|
+
return;
|
|
22075
|
+
}
|
|
22076
|
+
printForgetTable(plan);
|
|
22077
|
+
console.log(
|
|
22078
|
+
`${ops} ops (${formatBytes(bytes)} of payload). Forgetting deletes their history: mu undo can no longer restore these teardowns. Dry run; nothing changed.`
|
|
22079
|
+
);
|
|
22080
|
+
printNextSteps(nextSteps3);
|
|
22081
|
+
return;
|
|
22082
|
+
}
|
|
22083
|
+
const backup = backupBeside(db, "forget");
|
|
22084
|
+
const r = forget(db, plan);
|
|
22085
|
+
const size = vacuumAndCheck(db, backup);
|
|
22086
|
+
if (opts.json === true) {
|
|
22087
|
+
emitJson({
|
|
22088
|
+
dryRun: false,
|
|
22089
|
+
forgotten: plan.candidates.map((c) => c.name),
|
|
22090
|
+
ops: r.ops,
|
|
22091
|
+
backup,
|
|
22092
|
+
...size
|
|
22093
|
+
});
|
|
22094
|
+
return;
|
|
22095
|
+
}
|
|
22096
|
+
console.log(
|
|
22097
|
+
`Forgot ${plan.candidates.length} workstream(s), ${r.ops} ops: ${formatBytes(size.before)} \u2192 ${formatBytes(size.after)}. Drift check clean.`
|
|
22098
|
+
);
|
|
22099
|
+
console.log(pc.dim(`backup (the only way back): ${backup}`));
|
|
22100
|
+
}
|
|
22101
|
+
function forgetHint(db) {
|
|
22102
|
+
const all = listForgetCandidates(db);
|
|
22103
|
+
const bytes = all.reduce((n, c) => n + c.bytes, 0);
|
|
22104
|
+
if (bytes < FORGET_HINT_MIN_BYTES) return void 0;
|
|
22105
|
+
return {
|
|
22106
|
+
total: all.length,
|
|
22107
|
+
ops: all.reduce((n, c) => n + c.ops, 0),
|
|
22108
|
+
bytes,
|
|
22109
|
+
top: all.slice(0, 3).map((c) => c.name)
|
|
22110
|
+
};
|
|
22111
|
+
}
|
|
22112
|
+
var FORGET_HINT_MIN_BYTES = 5 * 1024 * 1024;
|
|
21698
22113
|
function wireDbCommands(program) {
|
|
21699
22114
|
const dbCmd = program.command("db").description("Whole-DB file commands");
|
|
21700
22115
|
dbCmd.command("backup <file>").description(
|
|
@@ -21703,247 +22118,24 @@ function wireDbCommands(program) {
|
|
|
21703
22118
|
const opts = this.opts();
|
|
21704
22119
|
return handle(async (db) => cmdDbBackup(db, file, opts), this)();
|
|
21705
22120
|
});
|
|
22121
|
+
dbCmd.command("compact").description(
|
|
22122
|
+
"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."
|
|
22123
|
+
).option("--yes", "apply (default: dry run)").option(...JSON_OPT).action(function() {
|
|
22124
|
+
const opts = this.opts();
|
|
22125
|
+
return handle(async (db) => cmdDbCompact(db, opts), this)();
|
|
22126
|
+
});
|
|
22127
|
+
dbCmd.command("forget <workstreams...>").description(
|
|
22128
|
+
"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."
|
|
22129
|
+
).option("--yes", "apply (default: dry run)").option(...JSON_OPT).action(function(names) {
|
|
22130
|
+
const opts = this.opts();
|
|
22131
|
+
return handle(async (db) => cmdDbForget(db, names, opts), this)();
|
|
22132
|
+
});
|
|
21706
22133
|
}
|
|
21707
22134
|
|
|
21708
22135
|
// src/cli/doctor.ts
|
|
21709
22136
|
init_agents();
|
|
21710
22137
|
import { dirname as dirname14 } from "path";
|
|
21711
22138
|
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
22139
|
init_doctor_summary();
|
|
21948
22140
|
|
|
21949
22141
|
// src/dormant.ts
|
|
@@ -22272,12 +22464,37 @@ state (workstream=${ws})`));
|
|
|
22272
22464
|
throw new DriftDetectedError(cheap.totalUnexplained, []);
|
|
22273
22465
|
}
|
|
22274
22466
|
}
|
|
22467
|
+
const size = opsSizeHint(db);
|
|
22468
|
+
if (size) {
|
|
22469
|
+
console.log(` size : ${pc.yellow("hint")} ${size.line}`);
|
|
22470
|
+
for (const c of size.commands) console.log(pc.dim(` ${c}`));
|
|
22471
|
+
}
|
|
22275
22472
|
if (sawHazard) {
|
|
22276
22473
|
console.log(
|
|
22277
22474
|
pc.dim("\nSee the fleet / disk sections above: at least one finding needs attention.")
|
|
22278
22475
|
);
|
|
22279
22476
|
}
|
|
22280
22477
|
}
|
|
22478
|
+
function opsSizeHint(db) {
|
|
22479
|
+
const forgettable = forgetHint(db);
|
|
22480
|
+
const tomb = planCompact(db);
|
|
22481
|
+
const parts = [];
|
|
22482
|
+
const commands = [];
|
|
22483
|
+
if (forgettable) {
|
|
22484
|
+
parts.push(
|
|
22485
|
+
`${forgettable.total} torn-down workstreams hold ${forgettable.ops} ops (${formatBytes(forgettable.bytes)}); largest: ${forgettable.top.join(", ")}`
|
|
22486
|
+
);
|
|
22487
|
+
commands.push(
|
|
22488
|
+
"see them: mu workstream list --torn-down",
|
|
22489
|
+
`forget some: mu db forget ${forgettable.top.join(" ")}`
|
|
22490
|
+
);
|
|
22491
|
+
}
|
|
22492
|
+
if (tomb.bytes >= 1024 * 1024) {
|
|
22493
|
+
parts.push(`${tomb.tombstones} redundant note tombstones (${formatBytes(tomb.bytes)})`);
|
|
22494
|
+
commands.push("compact: mu db compact");
|
|
22495
|
+
}
|
|
22496
|
+
return parts.length === 0 ? void 0 : { line: parts.join("; "), commands };
|
|
22497
|
+
}
|
|
22281
22498
|
async function cmdDoctorJson(db, opts = {}) {
|
|
22282
22499
|
const health = await muxHealth();
|
|
22283
22500
|
const muExt = ctlExtensionDoctorCheck();
|
|
@@ -22419,6 +22636,7 @@ async function cmdDoctorJson(db, opts = {}) {
|
|
|
22419
22636
|
disk,
|
|
22420
22637
|
workspaceUsage,
|
|
22421
22638
|
drift,
|
|
22639
|
+
size: opsSizeHint(db) ?? null,
|
|
22422
22640
|
remediation: drift.ok === true ? [] : driftRemediation()
|
|
22423
22641
|
});
|
|
22424
22642
|
if (driftFailure !== null) throw driftFailure;
|