@jhizzard/termdeck 0.6.7 → 0.7.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/README.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/init-mnestra.js +14 -1
- package/packages/cli/src/init-rumen.js +23 -1
- package/packages/client/public/app.js +17 -1
- package/packages/server/src/auth.js +79 -8
- package/packages/server/src/database.js +38 -2
- package/packages/server/src/health.js +377 -0
- package/packages/server/src/index.js +19 -0
- package/packages/server/src/session.js +58 -13
- package/packages/server/src/setup/index.js +2 -1
- package/packages/server/src/setup/migrations.js +28 -11
- package/packages/server/src/setup/preconditions.js +370 -0
- package/packages/server/src/theme-resolver.js +77 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Runtime health snapshot — the v0.7.0 sibling of v0.6.9's
|
|
2
|
+
// auditPreconditions/verifyOutcomes (packages/server/src/setup/preconditions.js).
|
|
3
|
+
//
|
|
4
|
+
// Why this module exists
|
|
5
|
+
// ──────────────────────
|
|
6
|
+
// v0.6.9 closed the install-time precondition-drift class with a front-loaded
|
|
7
|
+
// audit and a post-write verify inside the wizards. That defends the moment
|
|
8
|
+
// the user runs `termdeck init --mnestra` / `--rumen`. It does not defend
|
|
9
|
+
// later — when an extension gets disabled in the Supabase dashboard, when a
|
|
10
|
+
// migration loader picks up a stale set on a subsequent reinstall, or when
|
|
11
|
+
// the cron job is paused without anyone noticing. `/api/health/full` answers
|
|
12
|
+
// "is this install actually healthy *right now*?" by re-running the same
|
|
13
|
+
// SELECTs at runtime instead of install-time.
|
|
14
|
+
//
|
|
15
|
+
// Required vs warn checks
|
|
16
|
+
// ───────────────────────
|
|
17
|
+
// Required checks (sqlite, mnestra-pg, memory-items-col, pg-cron-ext,
|
|
18
|
+
// pg-net-ext, vault-secret, cron-job-active) drive the overall `ok` flag —
|
|
19
|
+
// any non-pass marks the report unhealthy and the route returns 503. Warn
|
|
20
|
+
// checks (mnestra-webhook, rumen-pool) are best-effort: a failure surfaces
|
|
21
|
+
// as `warn` with detail, but does not flip `ok`.
|
|
22
|
+
//
|
|
23
|
+
// Caching
|
|
24
|
+
// ───────
|
|
25
|
+
// Reports cached in module scope for 30s. `getFullHealth(config, { refresh: true })`
|
|
26
|
+
// or the `?refresh=1` query param bypasses the cache. The TTL is reflected
|
|
27
|
+
// in the response's `ttlSeconds` field so polling clients can self-pace.
|
|
28
|
+
//
|
|
29
|
+
// Error handling
|
|
30
|
+
// ──────────────
|
|
31
|
+
// Every check is wrapped: any unexpected error downgrades that single check
|
|
32
|
+
// to `fail` (or `warn` for warn-checks) with the error message in `detail`.
|
|
33
|
+
// `getFullHealth()` always resolves with a structured report — never throws.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const http = require('http');
|
|
38
|
+
const https = require('https');
|
|
39
|
+
|
|
40
|
+
const TTL_SECONDS = 30;
|
|
41
|
+
const TTL_MS = TTL_SECONDS * 1000;
|
|
42
|
+
|
|
43
|
+
const REQUIRED_CHECKS = new Set([
|
|
44
|
+
'sqlite',
|
|
45
|
+
'mnestra-pg',
|
|
46
|
+
'memory-items-col',
|
|
47
|
+
'pg-cron-ext',
|
|
48
|
+
'pg-net-ext',
|
|
49
|
+
'vault-secret',
|
|
50
|
+
'cron-job-active'
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
let _cache = null;
|
|
54
|
+
let _cachedAt = 0;
|
|
55
|
+
|
|
56
|
+
// ── SQLite check ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function checkSqlite(db) {
|
|
59
|
+
if (!db) {
|
|
60
|
+
return { name: 'sqlite', status: 'fail', detail: 'better-sqlite3 not initialized' };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const row = db.prepare('SELECT 1 AS ok').get();
|
|
64
|
+
if (row && row.ok === 1) return { name: 'sqlite', status: 'pass' };
|
|
65
|
+
return { name: 'sqlite', status: 'fail', detail: 'SELECT 1 returned unexpected result' };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { name: 'sqlite', status: 'fail', detail: err && err.message ? err.message : String(err) };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Postgres-side checks ────────────────────────────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// These mirror auditRumenPreconditions + verifyMnestraOutcomes + verifyRumenOutcomes
|
|
74
|
+
// from setup/preconditions.js. We deliberately copy the SQL rather than
|
|
75
|
+
// import the helper — preconditions.js is owned by other lanes and concurrent
|
|
76
|
+
// edits to share code would step on T1/T4. The queries are small and stable.
|
|
77
|
+
|
|
78
|
+
async function safeQueryRow(client, sql) {
|
|
79
|
+
try {
|
|
80
|
+
const r = await client.query(sql);
|
|
81
|
+
if (r.rows && r.rows.length > 0 && r.rows[0].ok) return { ok: true };
|
|
82
|
+
return { ok: false };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function safeQueryRows(client, sql) {
|
|
89
|
+
try {
|
|
90
|
+
const r = await client.query(sql);
|
|
91
|
+
return { rows: r.rows || [] };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function openPgClient(databaseUrl) {
|
|
98
|
+
if (!databaseUrl) return null;
|
|
99
|
+
let pgRunner;
|
|
100
|
+
try { pgRunner = require('./setup/pg-runner'); } catch (_e) { return null; }
|
|
101
|
+
try { return await pgRunner.connect(databaseUrl); } catch (_e) { return null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runPgChecks({ databaseUrl, _pgClient }) {
|
|
105
|
+
const checks = [];
|
|
106
|
+
const client = _pgClient || (await openPgClient(databaseUrl));
|
|
107
|
+
const owned = !_pgClient;
|
|
108
|
+
|
|
109
|
+
if (!client) {
|
|
110
|
+
checks.push({
|
|
111
|
+
name: 'mnestra-pg',
|
|
112
|
+
status: 'fail',
|
|
113
|
+
detail: databaseUrl
|
|
114
|
+
? 'could not connect to Postgres using DATABASE_URL'
|
|
115
|
+
: 'DATABASE_URL not configured (set in ~/.termdeck/secrets.env)'
|
|
116
|
+
});
|
|
117
|
+
// Dependent checks can't run without a connection — surface them as
|
|
118
|
+
// fail rather than silently skipping so the report is complete.
|
|
119
|
+
for (const name of ['memory-items-col', 'pg-cron-ext', 'pg-net-ext', 'vault-secret', 'cron-job-active']) {
|
|
120
|
+
checks.push({ name, status: 'fail', detail: 'pg unavailable' });
|
|
121
|
+
}
|
|
122
|
+
return checks;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const ping = await safeQueryRow(client, 'SELECT 1 AS ok');
|
|
127
|
+
if (ping.error) {
|
|
128
|
+
checks.push({ name: 'mnestra-pg', status: 'fail', detail: ping.error });
|
|
129
|
+
} else if (!ping.ok) {
|
|
130
|
+
checks.push({ name: 'mnestra-pg', status: 'fail', detail: 'SELECT 1 returned no row' });
|
|
131
|
+
} else {
|
|
132
|
+
checks.push({ name: 'mnestra-pg', status: 'pass' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// memory_items.source_session_id — the v0.6.5 column from Brad's saga.
|
|
136
|
+
const col = await safeQueryRow(client,
|
|
137
|
+
"SELECT 1 AS ok FROM information_schema.columns " +
|
|
138
|
+
"WHERE table_schema = 'public' AND table_name = 'memory_items' AND column_name = 'source_session_id'");
|
|
139
|
+
if (col.error) {
|
|
140
|
+
checks.push({ name: 'memory-items-col', status: 'fail', detail: col.error });
|
|
141
|
+
} else if (!col.ok) {
|
|
142
|
+
checks.push({
|
|
143
|
+
name: 'memory-items-col',
|
|
144
|
+
status: 'fail',
|
|
145
|
+
detail:
|
|
146
|
+
'memory_items.source_session_id missing — re-run termdeck init --mnestra --yes ' +
|
|
147
|
+
'(if loader picked up a stale set, first: npm cache clean --force && npm i -g @jhizzard/termdeck@latest)'
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
checks.push({ name: 'memory-items-col', status: 'pass' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cron = await safeQueryRow(client,
|
|
154
|
+
"SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_cron'");
|
|
155
|
+
if (cron.error) {
|
|
156
|
+
checks.push({ name: 'pg-cron-ext', status: 'fail', detail: cron.error });
|
|
157
|
+
} else if (!cron.ok) {
|
|
158
|
+
checks.push({
|
|
159
|
+
name: 'pg-cron-ext',
|
|
160
|
+
status: 'fail',
|
|
161
|
+
detail: 'extension not enabled — Supabase dashboard → Database → Extensions → pg_cron'
|
|
162
|
+
});
|
|
163
|
+
} else {
|
|
164
|
+
checks.push({ name: 'pg-cron-ext', status: 'pass' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const net = await safeQueryRow(client,
|
|
168
|
+
"SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_net'");
|
|
169
|
+
if (net.error) {
|
|
170
|
+
checks.push({ name: 'pg-net-ext', status: 'fail', detail: net.error });
|
|
171
|
+
} else if (!net.ok) {
|
|
172
|
+
checks.push({
|
|
173
|
+
name: 'pg-net-ext',
|
|
174
|
+
status: 'fail',
|
|
175
|
+
detail: 'extension not enabled — Supabase dashboard → Database → Extensions → pg_net'
|
|
176
|
+
});
|
|
177
|
+
} else {
|
|
178
|
+
checks.push({ name: 'pg-net-ext', status: 'pass' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const vault = await safeQueryRow(client,
|
|
182
|
+
"SELECT 1 AS ok FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key'");
|
|
183
|
+
if (vault.error) {
|
|
184
|
+
checks.push({
|
|
185
|
+
name: 'vault-secret',
|
|
186
|
+
status: 'fail',
|
|
187
|
+
detail: `vault.decrypted_secrets unreadable — ${vault.error}`
|
|
188
|
+
});
|
|
189
|
+
} else if (!vault.ok) {
|
|
190
|
+
checks.push({
|
|
191
|
+
name: 'vault-secret',
|
|
192
|
+
status: 'fail',
|
|
193
|
+
detail: 'rumen_service_role_key missing — Supabase dashboard → Project Settings → Vault → New secret'
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
checks.push({ name: 'vault-secret', status: 'pass' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const job = await safeQueryRows(client,
|
|
200
|
+
"SELECT active FROM cron.job WHERE jobname = 'rumen-tick'");
|
|
201
|
+
if (job.error) {
|
|
202
|
+
checks.push({ name: 'cron-job-active', status: 'fail', detail: `cron.job unreadable — ${job.error}` });
|
|
203
|
+
} else if (!job.rows || job.rows.length === 0) {
|
|
204
|
+
checks.push({
|
|
205
|
+
name: 'cron-job-active',
|
|
206
|
+
status: 'fail',
|
|
207
|
+
detail: 'rumen-tick row not found — re-run `termdeck init --rumen`'
|
|
208
|
+
});
|
|
209
|
+
} else if (!job.rows[0].active) {
|
|
210
|
+
checks.push({
|
|
211
|
+
name: 'cron-job-active',
|
|
212
|
+
status: 'fail',
|
|
213
|
+
detail:
|
|
214
|
+
"rumen-tick paused — SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'rumen-tick'), active := true);"
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
checks.push({ name: 'cron-job-active', status: 'pass' });
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
if (owned) {
|
|
221
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return checks;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Warn checks ─────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function httpReachable(url, timeoutMs = 2000) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const mod = url.startsWith('https:') ? https : http;
|
|
233
|
+
let req;
|
|
234
|
+
try {
|
|
235
|
+
req = mod.get(url, { timeout: timeoutMs }, (res) => {
|
|
236
|
+
const ok = res.statusCode != null && res.statusCode < 500;
|
|
237
|
+
res.resume();
|
|
238
|
+
resolve({ ok, status: res.statusCode });
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
resolve({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
req.on('error', (err) => resolve({ ok: false, error: err && err.message ? err.message : String(err) }));
|
|
245
|
+
req.on('timeout', () => { try { req.destroy(); } catch (_e) { /* gone */ } resolve({ ok: false, error: 'timeout' }); });
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function checkMnestraWebhook(config, options) {
|
|
250
|
+
if (options && typeof options._mnestraWebhookProbe === 'function') {
|
|
251
|
+
try {
|
|
252
|
+
const r = await options._mnestraWebhookProbe();
|
|
253
|
+
if (r && r.ok) return { name: 'mnestra-webhook', status: 'pass' };
|
|
254
|
+
return { name: 'mnestra-webhook', status: 'warn', detail: (r && r.detail) || 'unreachable' };
|
|
255
|
+
} catch (err) {
|
|
256
|
+
return { name: 'mnestra-webhook', status: 'warn', detail: err && err.message ? err.message : String(err) };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const rag = (config && config.rag) || {};
|
|
260
|
+
if (!rag.mnestraWebhookUrl) {
|
|
261
|
+
return { name: 'mnestra-webhook', status: 'warn', detail: 'webhook URL not configured' };
|
|
262
|
+
}
|
|
263
|
+
const healthUrl = String(rag.mnestraWebhookUrl).replace(/\/mnestra\/?$/, '/healthz');
|
|
264
|
+
const r = await httpReachable(healthUrl, 2000);
|
|
265
|
+
if (r.ok) return { name: 'mnestra-webhook', status: 'pass' };
|
|
266
|
+
return {
|
|
267
|
+
name: 'mnestra-webhook',
|
|
268
|
+
status: 'warn',
|
|
269
|
+
detail: r.error ? `unreachable — ${r.error}` : `HTTP ${r.status || '???'}`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function checkRumenPool(config, options) {
|
|
274
|
+
if (options && typeof options._rumenPoolProbe === 'function') {
|
|
275
|
+
try {
|
|
276
|
+
const r = await options._rumenPoolProbe();
|
|
277
|
+
if (r && r.ok) return { name: 'rumen-pool', status: 'pass' };
|
|
278
|
+
return { name: 'rumen-pool', status: 'warn', detail: (r && r.detail) || 'unreachable (best-effort)' };
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
let pg;
|
|
284
|
+
try { pg = require('pg'); } catch (_e) { pg = null; }
|
|
285
|
+
if (!pg) return { name: 'rumen-pool', status: 'warn', detail: 'pg module not installed' };
|
|
286
|
+
|
|
287
|
+
const dbUrl = (config && config.rag && config.rag.databaseUrl) || process.env.DATABASE_URL;
|
|
288
|
+
if (!dbUrl) return { name: 'rumen-pool', status: 'warn', detail: 'DATABASE_URL not set' };
|
|
289
|
+
|
|
290
|
+
const pool = new pg.Pool({ connectionString: dbUrl, max: 1, connectionTimeoutMillis: 3000 });
|
|
291
|
+
try {
|
|
292
|
+
const res = await pool.query('SELECT 1 AS ok');
|
|
293
|
+
if (res.rows[0] && res.rows[0].ok === 1) return { name: 'rumen-pool', status: 'pass' };
|
|
294
|
+
return { name: 'rumen-pool', status: 'warn', detail: 'SELECT 1 returned unexpected result' };
|
|
295
|
+
} catch (err) {
|
|
296
|
+
return { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) };
|
|
297
|
+
} finally {
|
|
298
|
+
try { await pool.end(); } catch (_e) { /* ignore */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Aggregator ──────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
async function getFullHealth(config = {}, options = {}) {
|
|
305
|
+
const refresh = !!options.refresh;
|
|
306
|
+
if (!refresh && _cache && (Date.now() - _cachedAt) < TTL_MS) {
|
|
307
|
+
return _cache;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const db = options.db || (config && config._db) || null;
|
|
311
|
+
const databaseUrl =
|
|
312
|
+
options.databaseUrl ||
|
|
313
|
+
(config && config.rag && config.rag.databaseUrl) ||
|
|
314
|
+
process.env.DATABASE_URL ||
|
|
315
|
+
null;
|
|
316
|
+
|
|
317
|
+
const checks = [];
|
|
318
|
+
|
|
319
|
+
// 1. SQLite (sync — small DB, no risk of blocking)
|
|
320
|
+
try { checks.push(checkSqlite(db)); }
|
|
321
|
+
catch (err) { checks.push({ name: 'sqlite', status: 'fail', detail: err && err.message ? err.message : String(err) }); }
|
|
322
|
+
|
|
323
|
+
// 2-7. Postgres-side suite
|
|
324
|
+
let pgChecks;
|
|
325
|
+
try { pgChecks = await runPgChecks({ databaseUrl, _pgClient: options._pgClient }); }
|
|
326
|
+
catch (err) {
|
|
327
|
+
pgChecks = [{
|
|
328
|
+
name: 'mnestra-pg',
|
|
329
|
+
status: 'fail',
|
|
330
|
+
detail: err && err.message ? err.message : String(err)
|
|
331
|
+
}];
|
|
332
|
+
for (const name of ['memory-items-col', 'pg-cron-ext', 'pg-net-ext', 'vault-secret', 'cron-job-active']) {
|
|
333
|
+
pgChecks.push({ name, status: 'fail', detail: 'pg suite aborted' });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const c of pgChecks) checks.push(c);
|
|
337
|
+
|
|
338
|
+
// 8. Mnestra webhook (warn)
|
|
339
|
+
let webhook;
|
|
340
|
+
try { webhook = await checkMnestraWebhook(config, options); }
|
|
341
|
+
catch (err) { webhook = { name: 'mnestra-webhook', status: 'warn', detail: err && err.message ? err.message : String(err) }; }
|
|
342
|
+
checks.push(webhook);
|
|
343
|
+
|
|
344
|
+
// 9. Rumen pool (warn)
|
|
345
|
+
let pool;
|
|
346
|
+
try { pool = await checkRumenPool(config, options); }
|
|
347
|
+
catch (err) { pool = { name: 'rumen-pool', status: 'warn', detail: err && err.message ? err.message : String(err) }; }
|
|
348
|
+
checks.push(pool);
|
|
349
|
+
|
|
350
|
+
const ok = checks
|
|
351
|
+
.filter((c) => REQUIRED_CHECKS.has(c.name))
|
|
352
|
+
.every((c) => c.status === 'pass');
|
|
353
|
+
|
|
354
|
+
const report = {
|
|
355
|
+
ok,
|
|
356
|
+
timestamp: new Date().toISOString(),
|
|
357
|
+
ttlSeconds: TTL_SECONDS,
|
|
358
|
+
checks
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
_cache = report;
|
|
362
|
+
_cachedAt = Date.now();
|
|
363
|
+
|
|
364
|
+
return report;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Test seam — drop the cache between cases so call-count assertions hold.
|
|
368
|
+
function _resetCache() {
|
|
369
|
+
_cache = null;
|
|
370
|
+
_cachedAt = 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = {
|
|
374
|
+
getFullHealth,
|
|
375
|
+
REQUIRED_CHECKS,
|
|
376
|
+
_resetCache
|
|
377
|
+
};
|
|
@@ -59,6 +59,7 @@ const { createBridge } = require('./mnestra-bridge');
|
|
|
59
59
|
const { writeSessionLog } = require('./session-logger');
|
|
60
60
|
const { TranscriptWriter } = require('./transcripts');
|
|
61
61
|
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
62
|
+
const { getFullHealth } = require('./health');
|
|
62
63
|
const { themes, statusColors } = require('./themes');
|
|
63
64
|
const { loadConfig, addProject } = require('./config');
|
|
64
65
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
@@ -146,6 +147,24 @@ function createServer(config) {
|
|
|
146
147
|
// or scope the response to a minimal {status, version} payload.
|
|
147
148
|
app.get('/api/health', createHealthHandler(config));
|
|
148
149
|
|
|
150
|
+
// GET /api/health/full - v0.7.0 runtime health snapshot (Sprint 32 T3)
|
|
151
|
+
// Mirrors the install-time auditPreconditions/verifyOutcomes pattern from
|
|
152
|
+
// v0.6.9 at runtime: re-runs the same SELECTs against pg_extension,
|
|
153
|
+
// vault.decrypted_secrets, cron.job, and information_schema.columns so a
|
|
154
|
+
// post-install drift (extension toggled off, schedule paused, stale loader
|
|
155
|
+
// shadow) is observable without a re-install. Cached 30s; pass ?refresh=1
|
|
156
|
+
// to bypass. Required checks drive the response status (200 ok / 503 fail);
|
|
157
|
+
// warn checks (mnestra-webhook, rumen-pool) never flip ok.
|
|
158
|
+
app.get('/api/health/full', async (req, res) => {
|
|
159
|
+
const refresh = req.query.refresh === '1' || req.query.refresh === 'true';
|
|
160
|
+
try {
|
|
161
|
+
const report = await getFullHealth(config, { refresh, db });
|
|
162
|
+
res.status(report.ok ? 200 : 503).json(report);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
res.status(500).json({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
149
168
|
// GET /api/setup - setup wizard tier status (Sprint 19 T1)
|
|
150
169
|
// Reuses preflight checks (mnestra_reachable, rumen_recent) and pairs them
|
|
151
170
|
// with filesystem + config signals to classify which of the 4 TermDeck tiers
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
// Session manager - PTY lifecycle, metadata tracking, output analysis
|
|
2
|
-
// Each session wraps a node-pty instance with rich metadata
|
|
2
|
+
// Each session wraps a node-pty instance with rich metadata.
|
|
3
|
+
//
|
|
4
|
+
// v0.7.0 theme model (see theme-resolver.js): meta.theme is a *getter* that
|
|
5
|
+
// resolves at read time from { session.theme_override → project default →
|
|
6
|
+
// global default → 'tokyo-night' }. The session no longer snapshots a theme
|
|
7
|
+
// string into meta at construction. This is the getter form (vs. the
|
|
8
|
+
// alternative of recomputing-and-writing on every metadata broadcast) because
|
|
9
|
+
// it makes the resolution path explicit at every read site and keeps the
|
|
10
|
+
// metadata broadcast in index.js untouched — `s.meta.theme` already returns
|
|
11
|
+
// the right thing whenever index.js dereferences it.
|
|
3
12
|
|
|
4
13
|
const { v4: uuidv4 } = require('uuid');
|
|
5
14
|
const os = require('os');
|
|
6
15
|
const path = require('path');
|
|
16
|
+
const { resolveTheme } = require('./theme-resolver');
|
|
7
17
|
|
|
8
18
|
// Strip ANSI escape codes for pattern matching
|
|
9
19
|
function stripAnsi(str) {
|
|
@@ -69,6 +79,24 @@ class Session {
|
|
|
69
79
|
this.pty = null;
|
|
70
80
|
this.ws = null;
|
|
71
81
|
|
|
82
|
+
// v0.7.0: theme_override is the user's explicit dropdown choice (NULL = no
|
|
83
|
+
// override → resolveTheme falls through to project / global default at
|
|
84
|
+
// read time). The legacy `options.theme` argument is intentionally NOT
|
|
85
|
+
// stored as an override here — it arrives from index.js already
|
|
86
|
+
// pre-defaulted (`theme || project.defaultTheme || config.defaultTheme`),
|
|
87
|
+
// which means we can no longer distinguish a real user choice from the
|
|
88
|
+
// server-filled default at the create-call boundary. Real overrides come
|
|
89
|
+
// through PATCH /api/sessions/:id (see updateMeta below).
|
|
90
|
+
this.theme_override = options.themeOverride != null ? options.themeOverride : null;
|
|
91
|
+
|
|
92
|
+
// Project (mirrored from meta for theme-resolver convenience — resolveTheme
|
|
93
|
+
// reads `session.project`, not `session.meta.project`).
|
|
94
|
+
Object.defineProperty(this, 'project', {
|
|
95
|
+
get: () => this.meta.project,
|
|
96
|
+
enumerable: false,
|
|
97
|
+
configurable: true
|
|
98
|
+
});
|
|
99
|
+
|
|
72
100
|
// Metadata
|
|
73
101
|
this.meta = {
|
|
74
102
|
type: options.type || 'shell', // shell, claude-code, gemini, python-server, one-shot
|
|
@@ -89,14 +117,23 @@ class Session {
|
|
|
89
117
|
exitCode: null,
|
|
90
118
|
childProcesses: [],
|
|
91
119
|
|
|
92
|
-
// Theme
|
|
93
|
-
theme: options.theme || 'tokyo-night',
|
|
94
|
-
|
|
95
120
|
// RAG
|
|
96
121
|
ragEnabled: options.ragEnabled !== false,
|
|
97
122
|
ragEvents: [] // buffer before flush
|
|
98
123
|
};
|
|
99
124
|
|
|
125
|
+
// theme is render-time resolved (see header comment + theme-resolver.js).
|
|
126
|
+
// Reads call resolveTheme(this, undefined) which falls back to the cached
|
|
127
|
+
// ~/.termdeck/config.yaml. Writes route to theme_override so PATCH/UPDATE
|
|
128
|
+
// through `session.meta.theme = 'dracula'` persists correctly. Setting
|
|
129
|
+
// null clears the override and reverts to the config-derived default.
|
|
130
|
+
Object.defineProperty(this.meta, 'theme', {
|
|
131
|
+
get: () => resolveTheme(this),
|
|
132
|
+
set: (val) => { this.theme_override = val == null ? null : val; },
|
|
133
|
+
enumerable: true,
|
|
134
|
+
configurable: true
|
|
135
|
+
});
|
|
136
|
+
|
|
100
137
|
// Transcript chunk counter — monotonic per session for deterministic replay
|
|
101
138
|
this.transcriptChunkIndex = 0;
|
|
102
139
|
|
|
@@ -383,11 +420,16 @@ class SessionManager {
|
|
|
383
420
|
const session = new Session(options);
|
|
384
421
|
this.sessions.set(session.id, session);
|
|
385
422
|
|
|
386
|
-
// Persist to SQLite
|
|
423
|
+
// Persist to SQLite. Both columns get written:
|
|
424
|
+
// theme — legacy; the resolved value at create time, kept for
|
|
425
|
+
// backward-compat with any consumer that still reads it.
|
|
426
|
+
// Not authoritative post-v0.7.0.
|
|
427
|
+
// theme_override — v0.7.0 authoritative column. NULL on create — only
|
|
428
|
+
// a PATCH from the dropdown sets it (see updateMeta).
|
|
387
429
|
if (this.db) {
|
|
388
430
|
this.db.prepare(`
|
|
389
|
-
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme)
|
|
390
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
431
|
+
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
391
433
|
`).run(
|
|
392
434
|
session.id,
|
|
393
435
|
session.meta.type,
|
|
@@ -397,7 +439,8 @@ class SessionManager {
|
|
|
397
439
|
session.meta.cwd,
|
|
398
440
|
session.meta.createdAt,
|
|
399
441
|
session.meta.reason,
|
|
400
|
-
session.meta.theme
|
|
442
|
+
session.meta.theme, // resolved snapshot, legacy column
|
|
443
|
+
session.theme_override // NULL by default
|
|
401
444
|
);
|
|
402
445
|
}
|
|
403
446
|
|
|
@@ -435,14 +478,16 @@ class SessionManager {
|
|
|
435
478
|
const applied = {};
|
|
436
479
|
for (const [key, val] of Object.entries(updates)) {
|
|
437
480
|
if (!SessionManager.PATCHABLE_META_FIELDS.has(key)) continue;
|
|
438
|
-
session.meta[key] = val;
|
|
481
|
+
session.meta[key] = val; // theme assignment routes through the setter → theme_override
|
|
439
482
|
applied[key] = val;
|
|
440
483
|
}
|
|
441
484
|
|
|
442
|
-
// Persist theme changes to SQLite
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
485
|
+
// Persist theme changes to SQLite. v0.7.0: writes go to theme_override
|
|
486
|
+
// (the authoritative column); a `theme: null` PATCH clears the override
|
|
487
|
+
// and reverts the session to the config-derived default at next read.
|
|
488
|
+
if ('theme' in applied && this.db) {
|
|
489
|
+
this.db.prepare('UPDATE sessions SET theme_override = ? WHERE id = ?')
|
|
490
|
+
.run(applied.theme == null ? null : applied.theme, id);
|
|
446
491
|
}
|
|
447
492
|
|
|
448
493
|
this._emit('session:updated', session);
|
|
@@ -11,5 +11,6 @@ module.exports = {
|
|
|
11
11
|
supabaseUrl: require('./supabase-url'),
|
|
12
12
|
migrations: require('./migrations'),
|
|
13
13
|
pgRunner: require('./pg-runner'),
|
|
14
|
-
migrationRunner: require('./migration-runner')
|
|
14
|
+
migrationRunner: require('./migration-runner'),
|
|
15
|
+
preconditions: require('./preconditions')
|
|
15
16
|
};
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
// Discover the SQL migration files that ship bundled inside the TermDeck
|
|
2
|
-
// package. Both init wizards call this — init-mnestra for the
|
|
2
|
+
// package. Both init wizards call this — init-mnestra for the seven Mnestra
|
|
3
3
|
// migrations, init-rumen for the two Rumen migrations.
|
|
4
4
|
//
|
|
5
5
|
// The wizards intentionally do NOT fall back to a sibling `../../mnestra`
|
|
6
|
-
// working copy. Resolution order:
|
|
6
|
+
// working copy. Resolution order (BUNDLED FIRST as of v0.6.8):
|
|
7
7
|
//
|
|
8
8
|
// 1. Files bundled at `packages/server/src/setup/mnestra-migrations/*.sql`
|
|
9
9
|
// (this directory is covered by the root package.json `files` glob).
|
|
10
|
+
// ALWAYS preferred when it has any .sql files.
|
|
10
11
|
// 2. Files at `node_modules/@jhizzard/mnestra/migrations/*.sql` if that
|
|
11
|
-
// package is installed alongside TermDeck
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// package is installed alongside TermDeck. Used ONLY as a fallback when
|
|
13
|
+
// the bundled directory is missing (e.g. someone deleted it manually).
|
|
14
|
+
//
|
|
15
|
+
// Why bundled-first: the meta-installer (`@jhizzard/termdeck-stack`) installs
|
|
16
|
+
// `@jhizzard/mnestra` globally as a peer. When TermDeck releases a new
|
|
17
|
+
// migration ahead of a Mnestra release, or when a user upgrades TermDeck
|
|
18
|
+
// without also upgrading the global Mnestra package, the previous loader
|
|
19
|
+
// silently picked the older Mnestra migration set. This bit Brad on
|
|
20
|
+
// 2026-04-26 with v0.6.5: he upgraded TermDeck, ran `init --mnestra --yes`,
|
|
21
|
+
// the wizard reported "6 migrations applied cleanly" (because his global
|
|
22
|
+
// mnestra@0.2.1 had only 6), and the bundled 007 — the one we shipped to
|
|
23
|
+
// fix his Rumen schema-drift issue — was never seen. Bundled is the source
|
|
24
|
+
// of truth TermDeck developed and tested against. Fall-back to node_modules
|
|
25
|
+
// is preserved as a safety valve, not a preference.
|
|
14
26
|
|
|
15
27
|
const fs = require('fs');
|
|
16
28
|
const path = require('path');
|
|
@@ -45,15 +57,20 @@ function tryNodeModules(packageName, migrationSubdir = 'migrations') {
|
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
function listMnestraMigrations() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
// Bundled FIRST (v0.6.8+). See the file header for why — this prevents
|
|
61
|
+
// a stale `@jhizzard/mnestra` install in global node_modules from
|
|
62
|
+
// silently shadowing migrations TermDeck ships with the latest version.
|
|
63
|
+
const bundled = listBundled('mnestra-migrations');
|
|
64
|
+
if (bundled.length > 0) return bundled;
|
|
65
|
+
return tryNodeModules('@jhizzard/mnestra');
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
function listRumenMigrations() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
// Bundled FIRST (v0.6.8+). Same rationale as listMnestraMigrations —
|
|
70
|
+
// a stale global `@jhizzard/rumen` cannot shadow newer bundled migrations.
|
|
71
|
+
const bundled = listBundled(path.join('rumen', 'migrations'));
|
|
72
|
+
if (bundled.length > 0) return bundled;
|
|
73
|
+
return tryNodeModules('@jhizzard/rumen');
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
function rumenFunctionDir() {
|