quilltap 4.7.0-dev → 4.7.0-dev.117
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/README.md +38 -4
- package/bin/quilltap.js +63 -5
- package/lib/__tests__/qtap-uri.test.js +111 -0
- package/lib/completion/bash.template +1 -1
- package/lib/completion/fish.template +2 -1
- package/lib/completion/zsh.template +2 -1
- package/lib/docs-commands.js +423 -19
- package/lib/lock-helpers.js +242 -0
- package/lib/maintenance-commands.js +394 -0
- package/lib/native-modules.js +91 -32
- package/lib/qtap-uri.js +171 -0
- package/lib/theme-validation.js +37 -0
- package/package.json +3 -3
package/lib/lock-helpers.js
CHANGED
|
@@ -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
|
+
};
|