quilltap 4.7.0-dev → 4.7.0-dev.101

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.
@@ -110,8 +110,250 @@ function getLockStatus(dataDir) {
110
110
  };
111
111
  }
112
112
 
113
+ // ============================================================================
114
+ // Write-lock acquire / release
115
+ //
116
+ // When the CLI opens the database read-write (`quilltap db --write`), it must
117
+ // claim the very same `<dataDir>/quilltap.lock` the server uses, in the very
118
+ // same JSON shape, so that a server starting mid-operation sees it and refuses
119
+ // to run. This mirrors `lib/database/backends/sqlite/instance-lock.ts`
120
+ // (buildLockContent / addHistoryEntry / writeLockFile / acquireInstanceLock /
121
+ // releaseInstanceLock). We reuse getLockStatus() above for the live/stale
122
+ // decision rather than re-deriving liveness.
123
+ //
124
+ // Hard rule: NO overrides. A live lock (state 'active' or 'suspect') is always
125
+ // refused. Only an absent or stale (dead-PID / no-heartbeat) lock is claimed —
126
+ // claiming a dead lock is exactly what the server does and is not an override.
127
+ // ============================================================================
128
+
129
+ const MAX_HISTORY_ENTRIES = 50;
130
+
131
+ // Module-level record of the lock path we currently own, so release is safe to
132
+ // call repeatedly (exit handler + explicit finally) and from signal handlers.
133
+ let ownedLockPath = null;
134
+ let heartbeatTimer = null;
135
+ let exitHandlersRegistered = false;
136
+
137
+ /**
138
+ * Detect the runtime environment for lock metadata. JS port of the server's
139
+ * detectEnvironmentType() so a CLI run inside Docker/Lima/WSL2 writes the right
140
+ * environment and the cross-host heartbeat semantics keep working.
141
+ */
142
+ function detectEnvironmentType() {
143
+ if (process.versions && process.versions.electron) return 'electron';
144
+ if (process.env.ELECTRON_DEV) return 'electron';
145
+ if (process.env.LIMA_CONTAINER === 'true') return 'lima'; // before Docker — Lima rootfs has Docker markers
146
+ if (process.env.WSL_DISTRO_NAME) return 'wsl2';
147
+ if (process.env.DOCKER_CONTAINER === 'true') return 'docker';
148
+ try {
149
+ if (fs.existsSync('/.dockerenv')) return 'docker';
150
+ } catch { /* not Docker */ }
151
+ return 'local';
152
+ }
153
+
154
+ /** Build a fresh lock content object for the current process. */
155
+ function buildLockContent() {
156
+ const now = new Date().toISOString();
157
+ return {
158
+ pid: process.pid,
159
+ hostname: os.hostname(),
160
+ startedAt: now,
161
+ lastHeartbeat: now,
162
+ environment: detectEnvironmentType(),
163
+ processTitle: process.title,
164
+ processArgv0: process.argv[0] || '',
165
+ history: [],
166
+ };
167
+ }
168
+
169
+ /** Append a history entry, trimming to MAX_HISTORY_ENTRIES. Returns a new object. */
170
+ function addHistoryEntry(content, event, detail) {
171
+ const entry = {
172
+ event,
173
+ pid: process.pid,
174
+ hostname: os.hostname(),
175
+ timestamp: new Date().toISOString(),
176
+ ...(detail ? { detail } : {}),
177
+ };
178
+ const history = [...(content.history || []), entry];
179
+ if (history.length > MAX_HISTORY_ENTRIES) {
180
+ history.splice(0, history.length - MAX_HISTORY_ENTRIES);
181
+ }
182
+ return { ...content, history };
183
+ }
184
+
185
+ /** Write lock content atomically via tmp + rename (matches the server). */
186
+ function writeLockFileAtomic(lockPath, content) {
187
+ const tmpPath = lockPath + '.tmp';
188
+ try {
189
+ fs.writeFileSync(tmpPath, JSON.stringify(content, null, 2) + '\n', 'utf8');
190
+ fs.renameSync(tmpPath, lockPath);
191
+ } catch (err) {
192
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore cleanup failure */ }
193
+ throw err;
194
+ }
195
+ }
196
+
197
+ function startHeartbeat(lockPath) {
198
+ stopHeartbeat();
199
+ // 60s like the server. unref() so a one-shot command (which exits in
200
+ // milliseconds) is never held open by the timer; only long `--repl --write`
201
+ // sessions ever actually beat.
202
+ heartbeatTimer = setInterval(() => {
203
+ try {
204
+ const raw = fs.readFileSync(lockPath, 'utf8');
205
+ const lock = JSON.parse(raw);
206
+ if (lock.pid !== process.pid || lock.hostname !== os.hostname()) {
207
+ // We no longer own the lock — stop beating, don't fight over it.
208
+ stopHeartbeat();
209
+ return;
210
+ }
211
+ lock.lastHeartbeat = new Date().toISOString();
212
+ writeLockFileAtomic(lockPath, lock);
213
+ } catch { /* best effort */ }
214
+ }, 60_000);
215
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
216
+ }
217
+
218
+ function stopHeartbeat() {
219
+ if (heartbeatTimer) {
220
+ clearInterval(heartbeatTimer);
221
+ heartbeatTimer = null;
222
+ }
223
+ }
224
+
225
+ function registerExitHandlers(dataDir) {
226
+ if (exitHandlersRegistered) return;
227
+ exitHandlersRegistered = true;
228
+ // 'exit' can only run sync work — releaseWriteLock's unlink is sync, so this works.
229
+ process.once('exit', () => { releaseWriteLock(dataDir); });
230
+ process.once('SIGINT', () => { releaseWriteLock(dataDir); process.exit(130); });
231
+ process.once('SIGTERM', () => { releaseWriteLock(dataDir); process.exit(143); });
232
+ process.once('uncaughtException', (err) => {
233
+ releaseWriteLock(dataDir);
234
+ console.error(err && err.stack ? err.stack : String(err));
235
+ process.exit(1);
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Acquire the instance lock for a read-write CLI session. Throws (with
241
+ * `.locked = true` when a live instance holds it) if the lock is not free.
242
+ * On success the lock is held until releaseWriteLock() / process exit.
243
+ */
244
+ function acquireWriteLock(dataDir) {
245
+ const lockPath = path.join(dataDir, 'quilltap.lock');
246
+ const status = getLockStatus(dataDir);
247
+
248
+ if (status.state === 'active' || status.state === 'suspect') {
249
+ const lines = [`Database is currently in use — ${status.reason}.`];
250
+ if (status.state === 'active') {
251
+ lines.push('Stop the running Quilltap instance before opening the database read-write.');
252
+ } else {
253
+ lines.push('This may be a stale lock from a reused PID. Inspect it with');
254
+ lines.push('`quilltap db --lock-status` and clean it with `quilltap db --lock-clean` if safe.');
255
+ }
256
+ lines.push('(See `quilltap db --lock-status` for details.)');
257
+ const err = new Error(lines.join('\n'));
258
+ err.locked = true;
259
+ throw err;
260
+ }
261
+ if (status.state === 'corrupt') {
262
+ throw new Error(
263
+ `Lock file at ${status.lockPath} is corrupt. Inspect it manually or clean it with ` +
264
+ '`quilltap db --lock-clean`, then retry.',
265
+ );
266
+ }
267
+
268
+ if (status.state === 'stale') {
269
+ // Dead PID / no recent heartbeat — claim it, preserving history.
270
+ claimStaleLock(lockPath, status.lock, status.reason);
271
+ finishAcquire(dataDir, lockPath);
272
+ return;
273
+ }
274
+
275
+ // Absent — atomic create so we lose cleanly to any racing process.
276
+ const content = addHistoryEntry(buildLockContent(), 'acquired', 'Read-write CLI session (quilltap db --write)');
277
+ const jsonData = JSON.stringify(content, null, 2) + '\n';
278
+ try {
279
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
280
+ try {
281
+ fs.writeSync(fd, jsonData, 0, 'utf8');
282
+ } finally {
283
+ fs.closeSync(fd);
284
+ }
285
+ finishAcquire(dataDir, lockPath);
286
+ return;
287
+ } catch (err) {
288
+ if (err.code !== 'EEXIST') throw err;
289
+ // Someone created the lock between our check and our create. Re-decide.
290
+ const recheck = getLockStatus(dataDir);
291
+ if (recheck.state === 'active' || recheck.state === 'suspect' || recheck.state === 'corrupt') {
292
+ const e = new Error(
293
+ `Database is currently in use — ${recheck.reason || 'lock just claimed by another process'}.\n` +
294
+ 'Stop the running Quilltap instance before opening the database read-write.',
295
+ );
296
+ e.locked = true;
297
+ throw e;
298
+ }
299
+ // Now stale (or absent again) — claim it.
300
+ claimStaleLock(lockPath, recheck.lock || buildLockContent(), recheck.reason || 'reclaimed after race');
301
+ finishAcquire(dataDir, lockPath);
302
+ }
303
+ }
304
+
305
+ /** Overwrite a stale lock with our process info, preserving prior history. */
306
+ function claimStaleLock(lockPath, existing, reason) {
307
+ let content = { ...buildLockContent(), history: (existing && existing.history) || [] };
308
+ content = addHistoryEntry(content, 'stale-detected', reason);
309
+ content = addHistoryEntry(content, 'stale-claimed', `Claimed by PID ${process.pid} (quilltap db --write)`);
310
+ writeLockFileAtomic(lockPath, content);
311
+ }
312
+
313
+ function finishAcquire(dataDir, lockPath) {
314
+ ownedLockPath = lockPath;
315
+ startHeartbeat(lockPath);
316
+ registerExitHandlers(dataDir);
317
+ }
318
+
319
+ /**
320
+ * Release the lock if (and only if) we own it. Idempotent; never throws.
321
+ */
322
+ function releaseWriteLock(dataDir) {
323
+ stopHeartbeat();
324
+ const lockPath = path.join(dataDir, 'quilltap.lock');
325
+ if (ownedLockPath !== lockPath && ownedLockPath !== null) {
326
+ // Owned a different path (shouldn't happen in one CLI run) — be conservative.
327
+ }
328
+ try {
329
+ if (!fs.existsSync(lockPath)) { ownedLockPath = null; return; }
330
+ let lock;
331
+ try {
332
+ lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
333
+ } catch {
334
+ // Corrupt — don't touch a lock we can't prove is ours.
335
+ ownedLockPath = null;
336
+ return;
337
+ }
338
+ if (lock.pid !== process.pid || lock.hostname !== os.hostname()) {
339
+ ownedLockPath = null;
340
+ return;
341
+ }
342
+ const updated = addHistoryEntry(lock, 'released', `Released by PID ${process.pid} (quilltap db --write)`);
343
+ try { writeLockFileAtomic(lockPath, updated); } catch { /* best effort */ }
344
+ try { fs.unlinkSync(lockPath); } catch { /* best effort */ }
345
+ } catch {
346
+ /* never throw from release */
347
+ } finally {
348
+ ownedLockPath = null;
349
+ }
350
+ }
351
+
113
352
  module.exports = {
114
353
  getLockStatus,
115
354
  isPidAlive,
116
355
  verifyPidIsNode,
356
+ acquireWriteLock,
357
+ releaseWriteLock,
358
+ detectEnvironmentType,
117
359
  };
@@ -0,0 +1,394 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `quilltap maintenance` — manual trigger for the retention & cleanup sweeps
5
+ * that otherwise run on the server's daily maintenance tick
6
+ * (`lib/background-jobs/scheduled-maintenance.ts`).
7
+ *
8
+ * The CLI is plain Node — it cannot import the TypeScript `runScheduledMaintenance()`
9
+ * and it speaks raw SQL to the encrypted databases. `maintenance run` is a DB
10
+ * writer and is lock-gated: it claims `<dataDir>/quilltap.lock` and REFUSES
11
+ * while a running server (or another writer) holds it, so it only ever touches
12
+ * the database when the server is down. Because it can't reach the server, it
13
+ * performs only the sweeps expressible as faithful direct SQL/fs:
14
+ * - finished background jobs (COMPLETED short window, DEAD longer window),
15
+ * - closed terminal sessions + their transcript files,
16
+ * - the orphaned-mount-index-file sweep.
17
+ *
18
+ * The stale-chat ASSET COLLAPSE is intentionally NOT performed here: it needs
19
+ * the app's file-storage manager / `deleteWithGC` machinery (mount-blob →
20
+ * link → byte GC), which lives in the server process. That sweep runs only on
21
+ * the server's daily tick. `maintenance status` reports a stale-chat count so
22
+ * you can see the backlog, but it does not estimate the per-asset reap.
23
+ *
24
+ * Retention windows below MIRROR the TypeScript source of truth at
25
+ * `lib/background-jobs/maintenance/retention-constants.ts`. Keep them in sync.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const {
31
+ resolveDataDirAndPassphrase,
32
+ printDefaultInstanceHint,
33
+ openMainDb,
34
+ openMountIndexDb,
35
+ loadDbKey,
36
+ } = require('./db-helpers');
37
+ const { acquireWriteLock, releaseWriteLock } = require('./lock-helpers');
38
+
39
+ // Mirror of lib/background-jobs/maintenance/retention-constants.ts
40
+ const COMPLETED_JOB_RETENTION_DAYS = 7;
41
+ const DEAD_JOB_RETENTION_DAYS = 30;
42
+ const STALE_CHAT_RETENTION_DAYS = 30;
43
+ const CLOSED_TERMINAL_RETENTION_DAYS = 30;
44
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
+
46
+ const LAST_SWEEP_KEY = 'lastMaintenanceSweepAt';
47
+
48
+ function cutoffIso(days, now = Date.now()) {
49
+ return new Date(now - days * DAY_MS).toISOString();
50
+ }
51
+
52
+ // ---------- argument parsing ----------
53
+
54
+ function parseFlags(args) {
55
+ const flags = {
56
+ dataDir: '',
57
+ instance: '',
58
+ passphrase: '',
59
+ json: false,
60
+ help: false,
61
+ };
62
+ const positional = [];
63
+ let i = 0;
64
+ while (i < args.length) {
65
+ const a = args[i];
66
+ switch (a) {
67
+ case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
68
+ case '-i': case '--instance': flags.instance = args[++i]; break;
69
+ case '--passphrase': flags.passphrase = args[++i]; break;
70
+ case '--json': flags.json = true; break;
71
+ case '-h': case '--help': flags.help = true; break;
72
+ default:
73
+ if (!a.startsWith('-')) positional.push(a);
74
+ else console.error(`unknown flag: ${a}`);
75
+ break;
76
+ }
77
+ i++;
78
+ }
79
+ return { flags, positional };
80
+ }
81
+
82
+ function printHelp() {
83
+ console.log(`Usage: quilltap maintenance <command> [options]
84
+
85
+ Commands:
86
+ run Run the cleanup sweeps once (lock-gated; refuses
87
+ while the server holds the lock). Reaps finished
88
+ background jobs, closed terminal sessions + their
89
+ transcripts, and orphaned mount-index files.
90
+ status Read-only: show the last sweep time and a dry-run
91
+ count of what would be reaped.
92
+
93
+ Note: the stale-chat asset collapse (superseded story-backgrounds & avatars)
94
+ runs only on the server's daily tick, not from this CLI.
95
+
96
+ Options:
97
+ -d, --data-dir <path> Use a specific data directory (instance root)
98
+ -i, --instance <name> Use a named instance
99
+ --passphrase <pass> Provide passphrase (prompts if needed)
100
+ --json Output as JSON
101
+ -h, --help Show this help message
102
+
103
+ Examples:
104
+ quilltap maintenance status
105
+ quilltap maintenance status --instance Friday --json
106
+ quilltap maintenance run --instance Friday
107
+ `);
108
+ }
109
+
110
+ // ---------- shared plumbing ----------
111
+
112
+ async function resolveAndKey(flags) {
113
+ const resolved = resolveDataDirAndPassphrase(flags);
114
+ printDefaultInstanceHint(resolved);
115
+ const pepper = await loadDbKey(resolved.dataDir, resolved.passphrase);
116
+ return { resolved, pepper };
117
+ }
118
+
119
+ function readLastSweep(mainDb) {
120
+ try {
121
+ const row = mainDb
122
+ .prepare('SELECT "value" FROM "instance_settings" WHERE "key" = ?')
123
+ .get(LAST_SWEEP_KEY);
124
+ return row ? row.value : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function countOrphanedFiles(dataDir, pepper) {
131
+ try {
132
+ const mounts = openMountIndexDb(dataDir, pepper, { readonly: true });
133
+ try {
134
+ const row = mounts
135
+ .prepare(
136
+ 'SELECT COUNT(*) AS n FROM doc_mount_files ' +
137
+ 'WHERE id NOT IN (SELECT DISTINCT fileId FROM doc_mount_file_links)'
138
+ )
139
+ .get();
140
+ return row ? row.n : 0;
141
+ } finally {
142
+ mounts.close();
143
+ }
144
+ } catch {
145
+ // Mount-index DB absent on this instance — nothing to count.
146
+ return null;
147
+ }
148
+ }
149
+
150
+ // ---------- command handlers ----------
151
+
152
+ async function handleStatus(flags) {
153
+ let resolved;
154
+ let pepper;
155
+ let mainDb;
156
+ try {
157
+ ({ resolved, pepper } = await resolveAndKey(flags));
158
+ mainDb = openMainDb(resolved.dataDir, pepper, { readonly: true });
159
+ } catch (err) {
160
+ console.error(`Error opening database: ${err.message}`);
161
+ process.exit(1);
162
+ }
163
+
164
+ const now = Date.now();
165
+ const completedCutoff = cutoffIso(COMPLETED_JOB_RETENTION_DAYS, now);
166
+ const deadCutoff = cutoffIso(DEAD_JOB_RETENTION_DAYS, now);
167
+ const terminalCutoff = cutoffIso(CLOSED_TERMINAL_RETENTION_DAYS, now);
168
+ const staleCutoff = cutoffIso(STALE_CHAT_RETENTION_DAYS, now);
169
+
170
+ const reapableCompleted = mainDb
171
+ .prepare(
172
+ "SELECT COUNT(*) AS n FROM background_jobs " +
173
+ "WHERE status = 'COMPLETED' AND completedAt IS NOT NULL AND completedAt < ?"
174
+ )
175
+ .get(completedCutoff).n;
176
+ const reapableDead = mainDb
177
+ .prepare(
178
+ "SELECT COUNT(*) AS n FROM background_jobs " +
179
+ "WHERE status = 'DEAD' AND completedAt IS NOT NULL AND completedAt < ?"
180
+ )
181
+ .get(deadCutoff).n;
182
+ const closedTerminals = mainDb
183
+ .prepare(
184
+ 'SELECT COUNT(*) AS n FROM terminal_sessions ' +
185
+ 'WHERE exitedAt IS NOT NULL AND exitedAt < ?'
186
+ )
187
+ .get(terminalCutoff).n;
188
+ const staleChats = mainDb
189
+ .prepare(
190
+ 'SELECT COUNT(*) AS n FROM chats WHERE COALESCE(lastMessageAt, updatedAt) < ?'
191
+ )
192
+ .get(staleCutoff).n;
193
+
194
+ const lastSweep = readLastSweep(mainDb);
195
+ mainDb.close();
196
+
197
+ const orphanedFiles = countOrphanedFiles(resolved.dataDir, pepper);
198
+
199
+ if (flags.json) {
200
+ console.log(
201
+ JSON.stringify(
202
+ {
203
+ lastMaintenanceSweepAt: lastSweep,
204
+ reapableJobs: { completed: reapableCompleted, dead: reapableDead },
205
+ closedTerminalSessions: closedTerminals,
206
+ orphanedMountIndexFiles: orphanedFiles,
207
+ staleChats,
208
+ note:
209
+ 'Stale-chat asset collapse runs on the server tick, not the CLI. ' +
210
+ 'staleChats is informational.',
211
+ },
212
+ null,
213
+ 2
214
+ )
215
+ );
216
+ } else {
217
+ console.log(`Last maintenance sweep: ${lastSweep || '(never)'}`);
218
+ console.log(`Reapable COMPLETED jobs: ${reapableCompleted} (older than ${COMPLETED_JOB_RETENTION_DAYS}d)`);
219
+ console.log(`Reapable DEAD jobs: ${reapableDead} (older than ${DEAD_JOB_RETENTION_DAYS}d)`);
220
+ console.log(`Closed terminal sessions: ${closedTerminals} (older than ${CLOSED_TERMINAL_RETENTION_DAYS}d)`);
221
+ console.log(
222
+ `Orphaned mount-index files: ${orphanedFiles === null ? '(no mount-index db)' : orphanedFiles}`
223
+ );
224
+ console.log(`Stale chats: ${staleChats} (no activity for ${STALE_CHAT_RETENTION_DAYS}d)`);
225
+ console.log('');
226
+ console.log('Note: the stale-chat asset collapse runs on the server\'s daily tick,');
227
+ console.log('not from this CLI. `maintenance run` reaps jobs, terminals, and orphans.');
228
+ }
229
+ }
230
+
231
+ async function handleRun(flags) {
232
+ let resolved;
233
+ let pepper;
234
+ try {
235
+ ({ resolved, pepper } = await resolveAndKey(flags));
236
+ } catch (err) {
237
+ console.error(`Error opening database: ${err.message}`);
238
+ process.exit(1);
239
+ }
240
+
241
+ // Lock-gated: refuse while a running server (or another writer) holds it.
242
+ try {
243
+ acquireWriteLock(resolved.dataDir);
244
+ } catch (err) {
245
+ console.error(err.message);
246
+ process.exit(1);
247
+ }
248
+
249
+ const summary = {
250
+ completedJobs: 0,
251
+ deadJobs: 0,
252
+ terminalRows: 0,
253
+ terminalTranscripts: 0,
254
+ orphanedFiles: 0,
255
+ };
256
+
257
+ let mainDb;
258
+ let mountsDb;
259
+ try {
260
+ mainDb = openMainDb(resolved.dataDir, pepper, { readonly: false });
261
+
262
+ const now = Date.now();
263
+ const completedCutoff = cutoffIso(COMPLETED_JOB_RETENTION_DAYS, now);
264
+ const deadCutoff = cutoffIso(DEAD_JOB_RETENTION_DAYS, now);
265
+ const terminalCutoff = cutoffIso(CLOSED_TERMINAL_RETENTION_DAYS, now);
266
+
267
+ // 1. Finished background jobs.
268
+ const jobTx = mainDb.transaction(() => {
269
+ const completed = mainDb
270
+ .prepare(
271
+ "DELETE FROM background_jobs " +
272
+ "WHERE status = 'COMPLETED' AND completedAt IS NOT NULL AND completedAt < ?"
273
+ )
274
+ .run(completedCutoff).changes;
275
+ const dead = mainDb
276
+ .prepare(
277
+ "DELETE FROM background_jobs " +
278
+ "WHERE status = 'DEAD' AND completedAt IS NOT NULL AND completedAt < ?"
279
+ )
280
+ .run(deadCutoff).changes;
281
+ return { completed, dead };
282
+ });
283
+ const jobRes = jobTx();
284
+ summary.completedJobs = jobRes.completed;
285
+ summary.deadJobs = jobRes.dead;
286
+
287
+ // 2. Closed terminal sessions + transcript files. Never select a session
288
+ // still running (exitedAt IS NULL). Transcripts live under
289
+ // <instanceRoot>/logs/terminals/<id>.log (data dir's sibling).
290
+ const logsTerminalsDir = path.join(resolved.dataDir, '..', 'logs', 'terminals');
291
+ const closed = mainDb
292
+ .prepare(
293
+ 'SELECT id, transcriptPath FROM terminal_sessions ' +
294
+ 'WHERE exitedAt IS NOT NULL AND exitedAt < ?'
295
+ )
296
+ .all(terminalCutoff);
297
+ const delTerminal = mainDb.prepare('DELETE FROM terminal_sessions WHERE id = ?');
298
+ const terminalTx = mainDb.transaction((rows) => {
299
+ let n = 0;
300
+ for (const r of rows) {
301
+ delTerminal.run(r.id);
302
+ n++;
303
+ }
304
+ return n;
305
+ });
306
+ summary.terminalRows = terminalTx(closed);
307
+ for (const r of closed) {
308
+ const transcriptPath =
309
+ r.transcriptPath || path.join(logsTerminalsDir, `${r.id}.log`);
310
+ try {
311
+ fs.unlinkSync(transcriptPath);
312
+ summary.terminalTranscripts++;
313
+ } catch (e) {
314
+ if (e.code !== 'ENOENT') {
315
+ console.error(` warning: could not unlink transcript ${transcriptPath}: ${e.message}`);
316
+ }
317
+ }
318
+ }
319
+
320
+ // 3. Orphaned mount-index files (belt-and-suspenders). Mount-index DB may
321
+ // be absent on older instances — skip gracefully.
322
+ try {
323
+ mountsDb = openMountIndexDb(resolved.dataDir, pepper, { readonly: false });
324
+ summary.orphanedFiles = mountsDb
325
+ .prepare(
326
+ 'DELETE FROM doc_mount_files ' +
327
+ 'WHERE id NOT IN (SELECT DISTINCT fileId FROM doc_mount_file_links)'
328
+ )
329
+ .run().changes;
330
+ } catch {
331
+ summary.orphanedFiles = 0;
332
+ }
333
+
334
+ // Record the sweep time so the server's startup short-circuit honors it.
335
+ mainDb
336
+ .prepare(
337
+ 'INSERT INTO "instance_settings" ("key", "value") VALUES (?, ?) ' +
338
+ 'ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"'
339
+ )
340
+ .run(LAST_SWEEP_KEY, new Date().toISOString());
341
+ } catch (err) {
342
+ console.error(`Error during maintenance run: ${err.message}`);
343
+ if (mountsDb) try { mountsDb.close(); } catch {}
344
+ if (mainDb) try { mainDb.close(); } catch {}
345
+ releaseWriteLock(resolved.dataDir);
346
+ process.exit(1);
347
+ }
348
+
349
+ if (mountsDb) try { mountsDb.close(); } catch {}
350
+ if (mainDb) try { mainDb.close(); } catch {}
351
+ releaseWriteLock(resolved.dataDir);
352
+
353
+ if (flags.json) {
354
+ console.log(JSON.stringify(summary, null, 2));
355
+ } else {
356
+ console.log('Maintenance run complete:');
357
+ console.log(` Reaped COMPLETED jobs: ${summary.completedJobs}`);
358
+ console.log(` Reaped DEAD jobs: ${summary.deadJobs}`);
359
+ console.log(` Reaped terminal sessions: ${summary.terminalRows}`);
360
+ console.log(` Removed transcript files: ${summary.terminalTranscripts}`);
361
+ console.log(` Swept orphaned mount files: ${summary.orphanedFiles}`);
362
+ console.log('');
363
+ console.log('(Stale-chat asset collapse runs on the server tick, not here.)');
364
+ }
365
+ }
366
+
367
+ // ---------- main entry point ----------
368
+
369
+ async function maintenanceCommand(args) {
370
+ const { flags, positional } = parseFlags(args);
371
+
372
+ if (flags.help || positional.length === 0) {
373
+ printHelp();
374
+ return;
375
+ }
376
+
377
+ const verb = positional[0];
378
+ switch (verb) {
379
+ case 'status':
380
+ await handleStatus(flags);
381
+ break;
382
+ case 'run':
383
+ await handleRun(flags);
384
+ break;
385
+ default:
386
+ console.error(`unknown maintenance command: ${verb}`);
387
+ console.error('Use "quilltap maintenance --help" for usage.');
388
+ process.exit(1);
389
+ }
390
+ }
391
+
392
+ module.exports = {
393
+ maintenanceCommand,
394
+ };