@jhizzard/termdeck 1.1.0 → 1.2.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/package.json +1 -1
- package/packages/cli/src/stack.js +20 -3
- package/packages/client/public/app.js +57 -0
- package/packages/server/src/agent-adapters/gemini.js +14 -8
- package/packages/server/src/health.js +354 -110
- package/packages/server/src/index.js +138 -20
- package/packages/server/src/preflight.js +7 -1
- package/packages/server/src/setup/migrations.js +27 -1
- package/packages/server/src/setup/mnestra-migrations/021_project_tag_canonicalize_claimguard.sql +175 -0
- package/packages/server/src/setup/mnestra-migrations/022_source_agent_backfill.sql +182 -0
|
@@ -16,10 +16,28 @@ const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resili
|
|
|
16
16
|
// Conditional imports (graceful fallback if not installed yet)
|
|
17
17
|
let pty, Database, pg;
|
|
18
18
|
try { pty = require('@homebridge/node-pty-prebuilt-multiarch'); } catch { pty = null; }
|
|
19
|
-
try {
|
|
19
|
+
try {
|
|
20
|
+
Database = require('better-sqlite3');
|
|
21
|
+
} catch (err) {
|
|
22
|
+
// Brad Heath 2026-05-11: distinguish a native-ABI mismatch (Node upgraded
|
|
23
|
+
// after install) from "package not installed yet." ABI mismatch leaves
|
|
24
|
+
// Database=null and cascades into a null-handle storm downstream that
|
|
25
|
+
// masquerades as "Mnestra unreachable / DB timeout" in health probes.
|
|
26
|
+
// Fail fast with the actionable rebuild hint instead.
|
|
27
|
+
const msg = err && err.message ? String(err.message) : '';
|
|
28
|
+
if (err && err.code === 'ERR_DLOPEN_FAILED' && /NODE_MODULE_VERSION/.test(msg)) {
|
|
29
|
+
console.error('[db] better-sqlite3 native ABI mismatch (Node was upgraded after install).');
|
|
30
|
+
console.error('[db] TermDeck cannot serve memory features without a working SQLite.');
|
|
31
|
+
console.error('[db] Fix:');
|
|
32
|
+
console.error(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3');
|
|
33
|
+
console.error('[db] Then restart TermDeck. Aborting.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
Database = null;
|
|
37
|
+
}
|
|
20
38
|
try { pg = require('pg'); } catch { pg = null; }
|
|
21
39
|
|
|
22
|
-
// Module-level singleton Postgres pool for rumen_insights (
|
|
40
|
+
// Module-level singleton Postgres pool for rumen_insights (the daily-driver DB).
|
|
23
41
|
// Lazy-initialized on first rumen endpoint hit so startup stays fast and
|
|
24
42
|
// servers without DATABASE_URL never pay the connection cost.
|
|
25
43
|
//
|
|
@@ -274,31 +292,42 @@ function _termdeckVersion() {
|
|
|
274
292
|
// `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
|
|
275
293
|
// but the noisy console.error trace pollutes diagnostics and obscures real
|
|
276
294
|
// errors. This helper guards against the race and downgrades the known
|
|
277
|
-
// race-class errors (EBADF, ENOTTY
|
|
278
|
-
//
|
|
279
|
-
//
|
|
295
|
+
// race-class errors (EBADF, ENOTTY) to a silent return. Set
|
|
296
|
+
// TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug for diagnostics.
|
|
297
|
+
//
|
|
298
|
+
// Sprint 63 T1 — `isPtyRaceError(err)` extracted so the WS message-handler
|
|
299
|
+
// outer catch can also downgrade race-class errors that escape the helper's
|
|
300
|
+
// own catch (e.g. if `pty.write` ever races the close, future code paths).
|
|
301
|
+
// `session.pty._destroyed` short-circuit added as belt-and-suspenders for the
|
|
302
|
+
// `term.kill()` → before-`term.onExit`-fires window: the DELETE handler now
|
|
303
|
+
// stamps `_destroyed = true` immediately after kill(), so resize attempts in
|
|
304
|
+
// that interval short-circuit without an ioctl call.
|
|
305
|
+
function isPtyRaceError(err) {
|
|
306
|
+
if (!err) return false;
|
|
307
|
+
const msg = (err.message) || '';
|
|
308
|
+
const code = err.code;
|
|
309
|
+
return code === 'EBADF' ||
|
|
310
|
+
code === 'ENOTTY' ||
|
|
311
|
+
/\b(?:EBADF|ENOTTY)\b/.test(msg);
|
|
312
|
+
}
|
|
313
|
+
|
|
280
314
|
function safelyResizePty(session, cols, rows) {
|
|
281
315
|
if (!session || !session.pty) return false;
|
|
316
|
+
if (session.pty._destroyed) return false;
|
|
282
317
|
if (session.meta && session.meta.status === 'exited') return false;
|
|
283
318
|
try {
|
|
284
319
|
session.pty.resize(cols || 120, rows || 30);
|
|
285
320
|
return true;
|
|
286
321
|
} catch (err) {
|
|
287
|
-
const msg = (err && err.message) || '';
|
|
288
|
-
const code = err && err.code;
|
|
289
322
|
// Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
|
|
290
323
|
// requires explicit EBADF or ENOTTY (in code OR message). The earlier
|
|
291
324
|
// shape — any "ioctl(N) failed" message — was too broad: it would have
|
|
292
325
|
// silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
|
|
293
326
|
// might indicate a real bug. Now: only the specific race-class signals
|
|
294
327
|
// get suppressed; everything else rethrows so it surfaces in logs.
|
|
295
|
-
|
|
296
|
-
code === 'EBADF' ||
|
|
297
|
-
code === 'ENOTTY' ||
|
|
298
|
-
/\b(?:EBADF|ENOTTY)\b/.test(msg);
|
|
299
|
-
if (isRace) {
|
|
328
|
+
if (isPtyRaceError(err)) {
|
|
300
329
|
if (process.env.TERMDECK_DEBUG_PTY_RACES) {
|
|
301
|
-
console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code ||
|
|
330
|
+
console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${err.code || err.message}`);
|
|
302
331
|
}
|
|
303
332
|
return false;
|
|
304
333
|
}
|
|
@@ -306,6 +335,35 @@ function safelyResizePty(session, cols, rows) {
|
|
|
306
335
|
}
|
|
307
336
|
}
|
|
308
337
|
|
|
338
|
+
// Sprint 63 T1 (Item 1.3) — body-parser hardening. The pre-existing
|
|
339
|
+
// `entity.verify.failed` / `entity.parse.failed` handler logged the error
|
|
340
|
+
// message but not WHICH bytes triggered the parse failure. Operators on
|
|
341
|
+
// Brad's r730 saw 9× SyntaxError flood over 13h with no fingerprint to
|
|
342
|
+
// identify the offending caller. `hexEscapePrefix` renders a 32-byte
|
|
343
|
+
// prefix of the raw body in a single-line, log-safe form: printable ASCII
|
|
344
|
+
// kept verbatim, non-printables rendered as `\xNN`, backslash escaped as
|
|
345
|
+
// `\\`. PII-conservative because we cap at 32 bytes (truncation marker `…`
|
|
346
|
+
// appended if more). The error middleware injects this into the existing
|
|
347
|
+
// `console.warn` line so the log signature is identifiable without
|
|
348
|
+
// dumping the full body.
|
|
349
|
+
function hexEscapePrefix(buf, maxBytes = 32) {
|
|
350
|
+
if (!buf || buf.length === 0) return '<no-body>';
|
|
351
|
+
const len = Math.min(buf.length, maxBytes);
|
|
352
|
+
let out = '';
|
|
353
|
+
for (let i = 0; i < len; i++) {
|
|
354
|
+
const b = buf[i];
|
|
355
|
+
if (b === 0x5c) {
|
|
356
|
+
out += '\\\\';
|
|
357
|
+
} else if (b >= 0x20 && b < 0x7f) {
|
|
358
|
+
out += String.fromCharCode(b);
|
|
359
|
+
} else {
|
|
360
|
+
out += '\\x' + b.toString(16).padStart(2, '0');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (buf.length > maxBytes) out += '…';
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
309
367
|
function createServer(config) {
|
|
310
368
|
const app = express();
|
|
311
369
|
const server = http.createServer(app);
|
|
@@ -328,6 +386,13 @@ function createServer(config) {
|
|
|
328
386
|
// logs so real errors aren't drowned in noise.
|
|
329
387
|
app.use(express.json({
|
|
330
388
|
verify: (req, res, buf) => {
|
|
389
|
+
// Sprint 63 T1 (Item 1.3) — capture a stable copy of the raw body so
|
|
390
|
+
// the error middleware below can render a 32-byte hex-escaped prefix.
|
|
391
|
+
// `Buffer.from(buf)` copies because express may pool the underlying
|
|
392
|
+
// accumulator across requests; without the copy the error handler
|
|
393
|
+
// could see bytes from a later request.
|
|
394
|
+
req.rawBody = Buffer.from(buf);
|
|
395
|
+
|
|
331
396
|
// O(N) single-pass scan. Only checks bytes inside double-quoted string
|
|
332
397
|
// regions so structural whitespace doesn't trigger false positives.
|
|
333
398
|
let inString = false;
|
|
@@ -372,7 +437,13 @@ function createServer(config) {
|
|
|
372
437
|
err.type === 'entity.verify.failed' ||
|
|
373
438
|
err instanceof SyntaxError
|
|
374
439
|
)) {
|
|
375
|
-
|
|
440
|
+
// Sprint 63 T1 (Item 1.3) — append a 32-byte hex-escaped prefix of the
|
|
441
|
+
// raw body so the operator can identify which caller is sending bad
|
|
442
|
+
// JSON without exposing the full payload. Falls through to `<no-body>`
|
|
443
|
+
// if the verify callback never ran (parse error before verify, or no
|
|
444
|
+
// body at all).
|
|
445
|
+
const prefix = hexEscapePrefix(req.rawBody);
|
|
446
|
+
console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path}) prefix="${prefix}"`);
|
|
376
447
|
return res.status(400).json({
|
|
377
448
|
error: 'Malformed JSON body',
|
|
378
449
|
detail: err.message,
|
|
@@ -1171,6 +1242,18 @@ function createServer(config) {
|
|
|
1171
1242
|
const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
|
|
1172
1243
|
fs.rmSync(sessUploadDir, { recursive: true, force: true });
|
|
1173
1244
|
} catch (_err) { /* non-blocking */ }
|
|
1245
|
+
|
|
1246
|
+
// Sprint 63 T1 (Item 1.1) — null `session.pty` so the wrapper is
|
|
1247
|
+
// eligible for GC and downstream `if (session.pty)` guards correctly
|
|
1248
|
+
// identify the exited state. Root cause of Joshua's 2026-05-08/09
|
|
1249
|
+
// overnight `kern.tty.ptmx_max=511` exhaustion (516 fds for 4 panels):
|
|
1250
|
+
// without this nulling, node-pty's wrapper stayed pinned by onData /
|
|
1251
|
+
// onExit closures even after the child exited, holding the master
|
|
1252
|
+
// fd until next GC pass. Set AFTER `onPanelClose` fires (fire-and-
|
|
1253
|
+
// forget; reads `session.meta` + `session.id`, not `session.pty`) and
|
|
1254
|
+
// AFTER the upload-dir cleanup so any sync reader above this line
|
|
1255
|
+
// sees the original wrapper.
|
|
1256
|
+
session.pty = null;
|
|
1174
1257
|
});
|
|
1175
1258
|
|
|
1176
1259
|
// Wire command logging to SQLite + RAG
|
|
@@ -1328,7 +1411,7 @@ function createServer(config) {
|
|
|
1328
1411
|
});
|
|
1329
1412
|
|
|
1330
1413
|
// Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
|
|
1331
|
-
// Reuses the
|
|
1414
|
+
// Reuses the daily-driver pg pool (same DATABASE_URL serves memory_items +
|
|
1332
1415
|
// memory_relationships alongside rumen_*). Graceful-degrades when the pool
|
|
1333
1416
|
// is absent.
|
|
1334
1417
|
createGraphRoutes({
|
|
@@ -1358,6 +1441,14 @@ function createServer(config) {
|
|
|
1358
1441
|
// Kill PTY process
|
|
1359
1442
|
if (session.pty) {
|
|
1360
1443
|
try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
|
|
1444
|
+
// Sprint 63 T1 (Item 1.2) — stamp `_destroyed = true` on the pty wrapper
|
|
1445
|
+
// so `safelyResizePty` can short-circuit any resize attempts that arrive
|
|
1446
|
+
// in the kill()→onExit window. node-pty's `kill()` only signals the
|
|
1447
|
+
// child; onExit fires asynchronously once the child reaps. Without this
|
|
1448
|
+
// marker, a WS resize message in that window would ioctl a fd whose
|
|
1449
|
+
// child has just SIGHUP'd, surfacing as EBADF/ENOTTY. node-pty doesn't
|
|
1450
|
+
// set this property itself; the convention is owned by TermDeck.
|
|
1451
|
+
session.pty._destroyed = true;
|
|
1361
1452
|
}
|
|
1362
1453
|
|
|
1363
1454
|
sessions.remove(req.params.id);
|
|
@@ -1577,15 +1668,23 @@ function createServer(config) {
|
|
|
1577
1668
|
});
|
|
1578
1669
|
|
|
1579
1670
|
// POST /api/sessions/:id/resize - resize terminal
|
|
1671
|
+
// Sprint 63 T1 (Item 1.2) — distinguish "session never existed" (404) from
|
|
1672
|
+
// "session exists but PTY has exited" (410 Gone). Pre-Sprint-63 both paths
|
|
1673
|
+
// collapsed to 404 (when session.pty was null after the PTY-leak fix) or
|
|
1674
|
+
// 409 (when safelyResizePty returned false). 410 is the semantically
|
|
1675
|
+
// correct response: the resource was here, the resource is now gone.
|
|
1580
1676
|
app.post('/api/sessions/:id/resize', (req, res) => {
|
|
1581
1677
|
const session = sessions.get(req.params.id);
|
|
1582
|
-
if (!session
|
|
1678
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1679
|
+
if (!session.pty || (session.meta && session.meta.status === 'exited')) {
|
|
1680
|
+
return res.status(410).json({ error: 'PTY is gone (session exited)' });
|
|
1681
|
+
}
|
|
1583
1682
|
|
|
1584
1683
|
const { cols, rows } = req.body;
|
|
1585
1684
|
try {
|
|
1586
1685
|
const resized = safelyResizePty(session, cols, rows);
|
|
1587
1686
|
if (!resized) {
|
|
1588
|
-
return res.status(
|
|
1687
|
+
return res.status(410).json({ error: 'PTY is gone (session exited)' });
|
|
1589
1688
|
}
|
|
1590
1689
|
res.json({ ok: true, cols, rows });
|
|
1591
1690
|
} catch (err) {
|
|
@@ -2009,7 +2108,7 @@ function createServer(config) {
|
|
|
2009
2108
|
});
|
|
2010
2109
|
|
|
2011
2110
|
// ==================== Rumen insights (Sprint 4 T2) ====================
|
|
2012
|
-
// Read-only access to rumen_insights + rumen_jobs in the
|
|
2111
|
+
// Read-only access to rumen_insights + rumen_jobs in the daily-driver Postgres
|
|
2013
2112
|
// instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
|
|
2014
2113
|
|
|
2015
2114
|
function rumenUnreachable(res) {
|
|
@@ -2250,7 +2349,7 @@ function createServer(config) {
|
|
|
2250
2349
|
|
|
2251
2350
|
switch (parsed.type) {
|
|
2252
2351
|
case 'input':
|
|
2253
|
-
if (session.pty) {
|
|
2352
|
+
if (session.pty && !session.pty._destroyed) {
|
|
2254
2353
|
session.pty.write(parsed.data);
|
|
2255
2354
|
session.trackInput(parsed.data);
|
|
2256
2355
|
}
|
|
@@ -2271,7 +2370,21 @@ function createServer(config) {
|
|
|
2271
2370
|
}));
|
|
2272
2371
|
break;
|
|
2273
2372
|
}
|
|
2274
|
-
} catch (err) {
|
|
2373
|
+
} catch (err) {
|
|
2374
|
+
// Sprint 63 T1 (Item 1.2) — belt-and-suspenders: if a race-class
|
|
2375
|
+
// ioctl error somehow escapes safelyResizePty's own catch (or comes
|
|
2376
|
+
// from a future write/ioctl path), downgrade to console.debug
|
|
2377
|
+
// instead of polluting stderr with the noisy ws-message-handler
|
|
2378
|
+
// error log. safelyResizePty itself already catches the resize
|
|
2379
|
+
// path; this catches any other race-class shape that bubbles here.
|
|
2380
|
+
if (isPtyRaceError(err)) {
|
|
2381
|
+
if (process.env.TERMDECK_DEBUG_PTY_RACES) {
|
|
2382
|
+
console.debug(`[ws] message handler race-class (suppressed): ${err.code || err.message}`);
|
|
2383
|
+
}
|
|
2384
|
+
} else {
|
|
2385
|
+
console.error('[ws] message handler error:', err);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2275
2388
|
});
|
|
2276
2389
|
|
|
2277
2390
|
ws.on('close', () => {
|
|
@@ -2581,6 +2694,11 @@ module.exports = {
|
|
|
2581
2694
|
// helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
|
|
2582
2695
|
// the prior re-implementation pattern in the test could drift silently.
|
|
2583
2696
|
safelyResizePty,
|
|
2697
|
+
// Sprint 63 T1 (Item 1.2 + 1.3) — race-class classifier + raw-body hex
|
|
2698
|
+
// prefix renderer exported so fence tests can import the production
|
|
2699
|
+
// helpers instead of re-implementing them.
|
|
2700
|
+
isPtyRaceError,
|
|
2701
|
+
hexEscapePrefix,
|
|
2584
2702
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2585
2703
|
readTermdeckSecretsForPty,
|
|
2586
2704
|
_resetTermdeckSecretsCache,
|
|
@@ -261,7 +261,13 @@ async function checkShellSanity() {
|
|
|
261
261
|
let output = '';
|
|
262
262
|
let resolved = false;
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
// Sprint 63 T3 §3.3 — drop `-l` (login mode). `-l` sources ~/.bash_profile
|
|
265
|
+
// / ~/.zshrc and friends, which on heavy profiles (nvm, conda, plugin
|
|
266
|
+
// managers — Brad's r730 has conda) routinely exceeds the 3s timeout
|
|
267
|
+
// budget below. A PTY-spawn health check answers "can $SHELL spawn a
|
|
268
|
+
// PTY and emit output?" — not "does the user's interactive profile
|
|
269
|
+
// complete fast?" Login-mode startup time is unrelated to PTY health.
|
|
270
|
+
const proc = ptyMod.spawn(shell, ['-c', 'echo TERMDECK_OK'], {
|
|
265
271
|
name: 'xterm-256color',
|
|
266
272
|
cols: 80,
|
|
267
273
|
rows: 24,
|
|
@@ -110,7 +110,33 @@ const MIGRATION_PROBES = Object.freeze({
|
|
|
110
110
|
'018_rumen_processed_at.sql':
|
|
111
111
|
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='rumen_processed_at'",
|
|
112
112
|
'019_security_hardening.sql':
|
|
113
|
-
"select 1 from pg_proc p, unnest(coalesce(p.proconfig,'{}'::text[])) c where p.proname='memory_hybrid_search' and c like 'search_path=%' and c like '%extensions%'"
|
|
113
|
+
"select 1 from pg_proc p, unnest(coalesce(p.proconfig,'{}'::text[])) c where p.proname='memory_hybrid_search' and c like 'search_path=%' and c like '%extensions%'",
|
|
114
|
+
// 021 canonicalizes legacy gorgias / gorgias-ticket-monitor project tags to
|
|
115
|
+
// claimguard. Probe is NOT-EXISTS-shaped: returns 1 row when both legacy
|
|
116
|
+
// tags carry zero rows (021's effects are in place OR the install never had
|
|
117
|
+
// legacy data). Returns 0 rows when at least one legacy tag still has rows
|
|
118
|
+
// (021 has not yet run). False-positive backfill costs nothing because the
|
|
119
|
+
// migration's UPDATE is gated on `project IN ('gorgias', 'gorgias-ticket-monitor')`
|
|
120
|
+
// so a re-apply against an already-canonicalized corpus is a 0-row no-op.
|
|
121
|
+
// Sprint 62 T2 added this; 020 is bootstrap-special-cased and intentionally
|
|
122
|
+
// absent from MIGRATION_PROBES.
|
|
123
|
+
'021_project_tag_canonicalize_claimguard.sql':
|
|
124
|
+
"select 1 where not exists (select 1 from memory_items where project in ('gorgias', 'gorgias-ticket-monitor'))",
|
|
125
|
+
// 022 backfills source_agent for the rows where the writer is inferable from
|
|
126
|
+
// row shape (Predicate A: decision/bug_fix/architecture/preference/code_context
|
|
127
|
+
// → 'claude'; Predicate B: fact rows with source_session_id → 'claude';
|
|
128
|
+
// Predicate D: document_chunk → 'orchestrator'). Predicate C (fact rows
|
|
129
|
+
// with no session and no path) is intentionally NOT backfilled — see the
|
|
130
|
+
// migration body for the provenance-preservation rationale. Probe is
|
|
131
|
+
// NOT-EXISTS-shaped over the A/B/D row-set: returns 1 when those targets
|
|
132
|
+
// all have source_agent set (022's effects in place), 0 when any A/B/D
|
|
133
|
+
// target still has NULL (022 has not yet run). Excludes Predicate C from
|
|
134
|
+
// the probe predicate so the residual NULL slice doesn't keep the probe
|
|
135
|
+
// false forever. False-positive backfill costs nothing because the
|
|
136
|
+
// migration body is gated on `source_agent IS NULL` and a re-apply against
|
|
137
|
+
// an already-tagged corpus is a 0-row no-op. Sprint 62 T3.
|
|
138
|
+
'022_source_agent_backfill.sql':
|
|
139
|
+
"select 1 where not exists (select 1 from memory_items where source_agent is null and (source_type in ('decision','bug_fix','architecture','preference','code_context') or (source_type='fact' and source_session_id is not null) or source_type='document_chunk'))"
|
|
114
140
|
});
|
|
115
141
|
|
|
116
142
|
// Sprint 61 T2 — self-transactional detection.
|
package/packages/server/src/setup/mnestra-migrations/021_project_tag_canonicalize_claimguard.sql
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
-- 021_project_tag_canonicalize_claimguard.sql
|
|
2
|
+
-- Sprint 62 T2 — finishes the gorgias / gorgias-ticket-monitor → claimguard
|
|
3
|
+
-- rename that migration 012 (Sprint 41 T2) explicitly scoped out.
|
|
4
|
+
--
|
|
5
|
+
-- Why this exists:
|
|
6
|
+
-- Same project (the ClaimGuard repo at ~/Documents/Unagi/gorgias-ticket-monitor)
|
|
7
|
+
-- was tagged three ways across history. As of 2026-05-08:
|
|
8
|
+
-- - 'claimguard' ~29 rows (newest tag, written by the
|
|
9
|
+
-- post-Sprint-41 PROJECT_MAP)
|
|
10
|
+
-- - 'gorgias-ticket-monitor' ~245 rows (mid tag, the on-disk dir name)
|
|
11
|
+
-- - 'gorgias' ~541 rows (oldest tag, pre-Sprint-41)
|
|
12
|
+
--
|
|
13
|
+
-- Migration 012's §"What this migration does NOT do" called out the merge
|
|
14
|
+
-- as a separate cleanup pass:
|
|
15
|
+
--
|
|
16
|
+
-- - Does NOT consolidate duplicate tags like 'gorgias' vs
|
|
17
|
+
-- 'gorgias-ticket-monitor', 'pvb' vs 'PVB', or 'mnestra' vs 'engram'.
|
|
18
|
+
-- Visible in `SELECT project, count(*) FROM memory_items GROUP BY
|
|
19
|
+
-- project` but a separate cleanup pass.
|
|
20
|
+
--
|
|
21
|
+
-- That separate pass is 021. Sprint 21 T2's earlier rename plan never
|
|
22
|
+
-- landed; Sprint 35's harness-hook fix addressed the upstream PROJECT_MAP
|
|
23
|
+
-- so new rows tag correctly, and Sprint 62 T2 (this migration) closes the
|
|
24
|
+
-- historical-corpus gap so memory_recall(project="claimguard") returns the
|
|
25
|
+
-- full ~815-row history rather than just the post-Sprint-41 tail.
|
|
26
|
+
--
|
|
27
|
+
-- The companion T2 invariant test at
|
|
28
|
+
-- termdeck/tests/project-tag-invariant.test.js currently skips the claimguard
|
|
29
|
+
-- invariant via `deferredToSprint35`; with 021 applied that invariant would
|
|
30
|
+
-- pass cleanly if un-deferred. Un-deferring is out of T2's lane (test edits
|
|
31
|
+
-- are owned by orchestrator close-out).
|
|
32
|
+
--
|
|
33
|
+
-- Why the *project*-column merge and not a content-keyword rebucket: rows
|
|
34
|
+
-- already-tagged 'gorgias' or 'gorgias-ticket-monitor' carry definitive
|
|
35
|
+
-- project provenance — the row is from the ClaimGuard project by virtue of
|
|
36
|
+
-- the writer's prior tag, regardless of content keywords. We are not
|
|
37
|
+
-- inferring; we are renaming an exact-match tag set that the SOURCE-BRIEF
|
|
38
|
+
-- and 012's prologue both confirm refer to the same on-disk codebase.
|
|
39
|
+
--
|
|
40
|
+
-- Idempotence:
|
|
41
|
+
-- The UPDATE is gated by `WHERE project IN ('gorgias','gorgias-ticket-monitor')`.
|
|
42
|
+
-- After the first apply those rows carry project='claimguard', so a re-run
|
|
43
|
+
-- matches zero rows — RAISE NOTICE prints 0 and the migration succeeds. The
|
|
44
|
+
-- bundled migration runner (packages/server/src/setup/migration-runner.js)
|
|
45
|
+
-- also checksums applied migrations into mnestra_migrations (table from
|
|
46
|
+
-- 020) and skips re-application by filename, so the in-runner path is
|
|
47
|
+
-- idempotent at two layers.
|
|
48
|
+
--
|
|
49
|
+
-- RLS posture:
|
|
50
|
+
-- memory_items has RLS enabled (per migration 019 security hardening), but
|
|
51
|
+
-- service_role bypasses RLS. The migration runner authenticates as
|
|
52
|
+
-- service_role via DATABASE_URL, so the UPDATE lands without policy
|
|
53
|
+
-- changes. This migration does NOT touch policies or roles.
|
|
54
|
+
--
|
|
55
|
+
-- Reversibility:
|
|
56
|
+
-- Down-migration is documented at the bottom (commented). Splitting the
|
|
57
|
+
-- merged set back into three is destructive — once project='claimguard'
|
|
58
|
+
-- replaces the prior values, the row provenance for which tag it ORIGINALLY
|
|
59
|
+
-- carried is gone (no audit column tracks pre-image). Reversal requires
|
|
60
|
+
-- restore from a pg_dump snapshot taken before the migration was applied.
|
|
61
|
+
-- Do NOT attempt heuristic reversal.
|
|
62
|
+
--
|
|
63
|
+
-- Application:
|
|
64
|
+
-- Applied via the bundled migration runner using node-postgres
|
|
65
|
+
-- client.query(). DO blocks + GET DIAGNOSTICS ROW_COUNT (no psql
|
|
66
|
+
-- metacommands — \gset / \echo / etc are not supported in client.query).
|
|
67
|
+
-- Manual fallback: `psql "$DATABASE_URL" -f 021_project_tag_canonicalize_claimguard.sql`.
|
|
68
|
+
|
|
69
|
+
BEGIN;
|
|
70
|
+
|
|
71
|
+
-- ============================================================
|
|
72
|
+
-- AUDIT BEFORE
|
|
73
|
+
-- ============================================================
|
|
74
|
+
DO $$
|
|
75
|
+
DECLARE
|
|
76
|
+
before_claimguard int;
|
|
77
|
+
before_gorgias int;
|
|
78
|
+
before_gorgias_ticket_monitor int;
|
|
79
|
+
before_total_three int;
|
|
80
|
+
BEGIN
|
|
81
|
+
SELECT count(*) INTO before_claimguard
|
|
82
|
+
FROM public.memory_items WHERE project = 'claimguard';
|
|
83
|
+
SELECT count(*) INTO before_gorgias
|
|
84
|
+
FROM public.memory_items WHERE project = 'gorgias';
|
|
85
|
+
SELECT count(*) INTO before_gorgias_ticket_monitor
|
|
86
|
+
FROM public.memory_items WHERE project = 'gorgias-ticket-monitor';
|
|
87
|
+
before_total_three := before_claimguard + before_gorgias + before_gorgias_ticket_monitor;
|
|
88
|
+
RAISE NOTICE '[021-canonicalize] BEFORE claimguard=% gorgias=% gorgias-ticket-monitor=% (sum=%)',
|
|
89
|
+
before_claimguard, before_gorgias, before_gorgias_ticket_monitor, before_total_three;
|
|
90
|
+
END $$;
|
|
91
|
+
|
|
92
|
+
-- ============================================================
|
|
93
|
+
-- CANONICALIZE — gorgias + gorgias-ticket-monitor → claimguard
|
|
94
|
+
--
|
|
95
|
+
-- Single-statement UPDATE on the project column. No content scoping required:
|
|
96
|
+
-- the source tags refer unambiguously to the ClaimGuard project per Sprint 41
|
|
97
|
+
-- T2's analysis (012's prologue) and the SOURCE-BRIEF for Sprint 62 §1.
|
|
98
|
+
-- ============================================================
|
|
99
|
+
DO $$
|
|
100
|
+
DECLARE
|
|
101
|
+
affected_count integer;
|
|
102
|
+
BEGIN
|
|
103
|
+
UPDATE public.memory_items
|
|
104
|
+
SET project = 'claimguard'
|
|
105
|
+
WHERE project IN ('gorgias', 'gorgias-ticket-monitor');
|
|
106
|
+
GET DIAGNOSTICS affected_count = ROW_COUNT;
|
|
107
|
+
RAISE NOTICE '[021-canonicalize] canonicalized % memory_items rows (gorgias + gorgias-ticket-monitor) -> claimguard',
|
|
108
|
+
affected_count;
|
|
109
|
+
END $$;
|
|
110
|
+
|
|
111
|
+
-- ============================================================
|
|
112
|
+
-- AUDIT AFTER + CONSERVATION CHECK
|
|
113
|
+
-- ============================================================
|
|
114
|
+
DO $$
|
|
115
|
+
DECLARE
|
|
116
|
+
after_claimguard int;
|
|
117
|
+
after_gorgias int;
|
|
118
|
+
after_gorgias_ticket_monitor int;
|
|
119
|
+
BEGIN
|
|
120
|
+
SELECT count(*) INTO after_claimguard
|
|
121
|
+
FROM public.memory_items WHERE project = 'claimguard';
|
|
122
|
+
SELECT count(*) INTO after_gorgias
|
|
123
|
+
FROM public.memory_items WHERE project = 'gorgias';
|
|
124
|
+
SELECT count(*) INTO after_gorgias_ticket_monitor
|
|
125
|
+
FROM public.memory_items WHERE project = 'gorgias-ticket-monitor';
|
|
126
|
+
RAISE NOTICE '[021-canonicalize] AFTER claimguard=% gorgias=% gorgias-ticket-monitor=%',
|
|
127
|
+
after_claimguard, after_gorgias, after_gorgias_ticket_monitor;
|
|
128
|
+
IF after_gorgias <> 0 OR after_gorgias_ticket_monitor <> 0 THEN
|
|
129
|
+
RAISE EXCEPTION
|
|
130
|
+
'[021-canonicalize] post-apply invariant violated: expected zero rows in gorgias / gorgias-ticket-monitor, got gorgias=% gorgias-ticket-monitor=%',
|
|
131
|
+
after_gorgias, after_gorgias_ticket_monitor;
|
|
132
|
+
END IF;
|
|
133
|
+
END $$;
|
|
134
|
+
|
|
135
|
+
COMMIT;
|
|
136
|
+
|
|
137
|
+
-- ============================================================
|
|
138
|
+
-- POST-APPLY: verification queries (NOT part of the migration; run separately
|
|
139
|
+
-- to confirm the merge took, the invariant tests stay green, and the recall
|
|
140
|
+
-- path returns the full history). Each query is safe to run repeatedly.
|
|
141
|
+
-- ============================================================
|
|
142
|
+
--
|
|
143
|
+
-- 1. Tag distribution after migration — claimguard should be the only
|
|
144
|
+
-- bucket among the three; gorgias / gorgias-ticket-monitor should be 0:
|
|
145
|
+
-- SELECT project, count(*) FROM public.memory_items
|
|
146
|
+
-- WHERE project IN ('claimguard', 'gorgias', 'gorgias-ticket-monitor')
|
|
147
|
+
-- GROUP BY project ORDER BY project;
|
|
148
|
+
--
|
|
149
|
+
-- 2. Confirm no orphan rows remain under either legacy tag (these should
|
|
150
|
+
-- return 0):
|
|
151
|
+
-- SELECT count(*) FROM public.memory_items
|
|
152
|
+
-- WHERE project IN ('gorgias', 'gorgias-ticket-monitor');
|
|
153
|
+
--
|
|
154
|
+
-- 3. Spot-check that the merged set carries content from all three
|
|
155
|
+
-- historical eras (look for varied dates, varied source_types):
|
|
156
|
+
-- SELECT date_trunc('week', created_at) AS week, count(*)
|
|
157
|
+
-- FROM public.memory_items
|
|
158
|
+
-- WHERE project = 'claimguard'
|
|
159
|
+
-- GROUP BY 1 ORDER BY 1;
|
|
160
|
+
--
|
|
161
|
+
-- 4. Confirm the project-tag invariant test for claimguard would now pass
|
|
162
|
+
-- if un-deferred (rows whose content matches gorgias-ticket-monitor or
|
|
163
|
+
-- Unagi/ identifiers should be top-tagged claimguard):
|
|
164
|
+
-- SELECT project, count(*) FROM public.memory_items
|
|
165
|
+
-- WHERE content ILIKE '%gorgias-ticket-monitor%'
|
|
166
|
+
-- OR content ILIKE '%Unagi/%'
|
|
167
|
+
-- GROUP BY project ORDER BY count(*) DESC LIMIT 5;
|
|
168
|
+
--
|
|
169
|
+
-- DOWN-MIGRATION (manual, NOT auto-applied):
|
|
170
|
+
-- Splitting the merged set back into three is non-trivial (no source-of-
|
|
171
|
+
-- truth on which rows were originally which tag — provenance is lost when
|
|
172
|
+
-- the UPDATE replaces the project string). If a roll-back is needed,
|
|
173
|
+
-- restore from a pg_dump taken before this migration was applied. Do NOT
|
|
174
|
+
-- attempt to reverse via heuristic — the row provenance is destroyed by
|
|
175
|
+
-- the merge.
|