@jhizzard/termdeck 1.0.13 → 1.1.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/init-mnestra.js +50 -10
- package/packages/server/src/agent-adapters/codex.js +18 -0
- package/packages/server/src/index.js +119 -8
- package/packages/server/src/session.js +27 -1
- package/packages/server/src/setup/migrations.js +488 -1
- package/packages/server/src/setup/mnestra-migrations/001_mnestra_tables.sql +2 -2
- package/packages/server/src/setup/mnestra-migrations/002_mnestra_search_function.sql +1 -1
- package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +1 -1
- package/packages/server/src/setup/mnestra-migrations/011_project_tag_backfill.sql +7 -3
- package/packages/server/src/setup/mnestra-migrations/012_project_tag_re_taxonomy.sql +2 -2
- package/packages/server/src/setup/mnestra-migrations/014_explicit_grants.sql +3 -3
- package/packages/server/src/setup/mnestra-migrations/016_mnestra_doctor_probes.sql +3 -3
- package/packages/server/src/setup/mnestra-migrations/017_memory_sessions_session_metadata.sql +5 -5
- package/packages/server/src/setup/mnestra-migrations/018_rumen_processed_at.sql +1 -1
- package/packages/server/src/setup/mnestra-migrations/019_security_hardening.sql +190 -0
- package/packages/server/src/setup/mnestra-migrations/020_migration_tracking.sql +57 -0
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +0 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -332,23 +332,63 @@ async function promptSecretWithValidation(validator) {
|
|
|
332
332
|
throw new Error('Too many invalid attempts — cancelling.');
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Sprint 61 T2 — collapsed fresh-install / upgrade paths. Pre-Sprint-61, the
|
|
336
|
+
// wizard re-applied every bundled mnestra migration on every invocation,
|
|
337
|
+
// relying on per-file IF NOT EXISTS / CREATE OR REPLACE idempotency. That
|
|
338
|
+
// works for fresh installs but doesn't tell the wizard which migrations the
|
|
339
|
+
// live database has actually received — so a user running
|
|
340
|
+
// `npm install -g @latest` against an existing project lands in Class A
|
|
341
|
+
// (schema drift on package upgrade): the npm package files upgrade, the
|
|
342
|
+
// database stays at first-kickstart state. Brad reported this 2026-05-02.
|
|
343
|
+
//
|
|
344
|
+
// applyPendingMigrations (migrations.js) replaces the loop with a tracker-
|
|
345
|
+
// aware diff: SELECT applied filenames from public.mnestra_migrations, run
|
|
346
|
+
// only the bundled-but-unapplied ones, INSERT a tracker row per apply.
|
|
347
|
+
// Pre-020 installs trigger a one-time backfill probe pass that seeds the
|
|
348
|
+
// tracker for migrations whose schema artifacts are already present.
|
|
335
349
|
async function applyMigrations(client, dryRun) {
|
|
336
350
|
const files = migrations.listMnestraMigrations();
|
|
337
351
|
if (files.length === 0) {
|
|
338
352
|
throw new Error('No Mnestra migrations found. TermDeck install looks corrupted.');
|
|
339
353
|
}
|
|
340
354
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
ok(
|
|
348
|
-
} else {
|
|
349
|
-
fail(result.error);
|
|
350
|
-
throw new Error(`Migration failed: ${base}`);
|
|
355
|
+
if (dryRun) {
|
|
356
|
+
// Preserve the per-file dry-run banner so the user sees the planned
|
|
357
|
+
// sequence without touching the database.
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
const base = path.basename(file);
|
|
360
|
+
step(`Applying migration ${base}...`);
|
|
361
|
+
ok('(dry-run)');
|
|
351
362
|
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
step('Running tracker-aware diff-and-apply (skips already-applied migrations)...');
|
|
367
|
+
const summary = await migrations.applyPendingMigrations(client);
|
|
368
|
+
|
|
369
|
+
if (summary.errored) {
|
|
370
|
+
fail(`${summary.errored.file}: ${summary.errored.error}`);
|
|
371
|
+
throw new Error(`Migration failed: ${summary.errored.file}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
ok(
|
|
375
|
+
`(applied ${summary.applied.length}, backfilled ${summary.backfilled.length}, ` +
|
|
376
|
+
`skipped ${summary.skipped.length})`
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
for (const f of summary.applied) {
|
|
380
|
+
process.stdout.write(` ✓ applied ${f}\n`);
|
|
381
|
+
}
|
|
382
|
+
for (const f of summary.backfilled) {
|
|
383
|
+
process.stdout.write(` ◇ backfilled ${f} (schema already present, recorded in tracker)\n`);
|
|
384
|
+
}
|
|
385
|
+
for (const w of summary.warnings) {
|
|
386
|
+
const tracked = (w.trackedChecksum || '').slice(0, 12) || '<empty>';
|
|
387
|
+
const bundled = (w.bundledChecksum || '').slice(0, 12) || '<empty>';
|
|
388
|
+
process.stdout.write(
|
|
389
|
+
` ! checksum drift on ${w.file}: tracked=${tracked}, bundled=${bundled} ` +
|
|
390
|
+
`(no auto-overwrite — investigate before re-running)\n`
|
|
391
|
+
);
|
|
352
392
|
}
|
|
353
393
|
}
|
|
354
394
|
|
|
@@ -69,6 +69,17 @@ const TOOL = /^(?:\$\s|→\s|exec(?:_command\b|\b)|Running\b|Calling\b)/m;
|
|
|
69
69
|
// label when it's done reasoning and waiting on the user.
|
|
70
70
|
const IDLE = /^codex\s*$/m;
|
|
71
71
|
|
|
72
|
+
// End-of-turn terminator (Sprint 60 v1.0.14 fix). After Codex finishes a
|
|
73
|
+
// reply the TUI renders a separator with the elapsed time, e.g.
|
|
74
|
+
// "─ Worked for 2m 50s ──────────" using box-drawing dashes (U+2500). This
|
|
75
|
+
// pattern is unambiguous: it only ever appears when the turn closes and the
|
|
76
|
+
// panel parks waiting for next input. Placed FIRST in the statusFor cascade
|
|
77
|
+
// because the same chunk may also contain a final "Working" spinner update
|
|
78
|
+
// that would otherwise stick `status: 'thinking'` indefinitely. Bit Sprint 59
|
|
79
|
+
// twice — orchestrator's `meta.status` reported "Codex is reasoning..." for
|
|
80
|
+
// 22+ minutes after Codex actually parked at end-of-turn.
|
|
81
|
+
const END_OF_TURN = /─\s*Worked for\s+(?:\d+m\s*)?\d+s\s*─/;
|
|
82
|
+
|
|
72
83
|
// Error patterns — line-anchored to avoid mid-line "error" mentions in tool
|
|
73
84
|
// output (grep results, test logs, file dumps) flagging false positives.
|
|
74
85
|
// Same shape as Claude with codex-specific OpenAI-API failure modes added
|
|
@@ -82,6 +93,12 @@ const ERROR = /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|F
|
|
|
82
93
|
// ──────────────────────────────────────────────────────────────────────────
|
|
83
94
|
|
|
84
95
|
function statusFor(data) {
|
|
96
|
+
// Sprint 60 v1.0.14: end-of-turn terminator wins over THINKING. Without
|
|
97
|
+
// this branch, a chunk that contains both a final "Working Xs" spinner
|
|
98
|
+
// line AND the closing "Worked for X" separator would stick on 'thinking'.
|
|
99
|
+
if (END_OF_TURN.test(data)) {
|
|
100
|
+
return { status: 'idle', statusDetail: '' };
|
|
101
|
+
}
|
|
85
102
|
if (THINKING.test(data)) {
|
|
86
103
|
return { status: 'thinking', statusDetail: 'Codex is reasoning...' };
|
|
87
104
|
}
|
|
@@ -261,6 +278,7 @@ const codexAdapter = {
|
|
|
261
278
|
patterns: {
|
|
262
279
|
prompt: PROMPT,
|
|
263
280
|
thinking: THINKING,
|
|
281
|
+
endOfTurn: END_OF_TURN,
|
|
264
282
|
editing: EDITING,
|
|
265
283
|
tool: TOOL,
|
|
266
284
|
idle: IDLE,
|
|
@@ -267,12 +267,92 @@ function _termdeckVersion() {
|
|
|
267
267
|
catch { return '0.0.0'; }
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
|
|
271
|
+
// forensic surfaced 25× `[ws] message handler error: Error: ioctl(2) failed,
|
|
272
|
+
// EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
|
|
273
|
+
// that pty-reaper has already closed (or the child has exited), and
|
|
274
|
+
// `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
|
|
275
|
+
// but the noisy console.error trace pollutes diagnostics and obscures real
|
|
276
|
+
// errors. This helper guards against the race and downgrades the known
|
|
277
|
+
// race-class errors (EBADF, ENOTTY, generic "ioctl failed" message shape) to
|
|
278
|
+
// a silent return. Set TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug
|
|
279
|
+
// for diagnostics.
|
|
280
|
+
function safelyResizePty(session, cols, rows) {
|
|
281
|
+
if (!session || !session.pty) return false;
|
|
282
|
+
if (session.meta && session.meta.status === 'exited') return false;
|
|
283
|
+
try {
|
|
284
|
+
session.pty.resize(cols || 120, rows || 30);
|
|
285
|
+
return true;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const msg = (err && err.message) || '';
|
|
288
|
+
const code = err && err.code;
|
|
289
|
+
// Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
|
|
290
|
+
// requires explicit EBADF or ENOTTY (in code OR message). The earlier
|
|
291
|
+
// shape — any "ioctl(N) failed" message — was too broad: it would have
|
|
292
|
+
// silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
|
|
293
|
+
// might indicate a real bug. Now: only the specific race-class signals
|
|
294
|
+
// get suppressed; everything else rethrows so it surfaces in logs.
|
|
295
|
+
const isRace =
|
|
296
|
+
code === 'EBADF' ||
|
|
297
|
+
code === 'ENOTTY' ||
|
|
298
|
+
/\b(?:EBADF|ENOTTY)\b/.test(msg);
|
|
299
|
+
if (isRace) {
|
|
300
|
+
if (process.env.TERMDECK_DEBUG_PTY_RACES) {
|
|
301
|
+
console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code || msg}`);
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
270
309
|
function createServer(config) {
|
|
271
310
|
const app = express();
|
|
272
311
|
const server = http.createServer(app);
|
|
273
312
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
274
313
|
|
|
275
|
-
|
|
314
|
+
// Sprint 60 v1.0.14 (Item 2) — pre-screen incoming JSON bodies for unescaped
|
|
315
|
+
// control characters in string contexts. Brad's 2026-05-07 r730 crash
|
|
316
|
+
// forensic logged 9x `SyntaxError: Bad control character in string literal
|
|
317
|
+
// in JSON at position 9` per 13h uptime. The post-Sprint-56 error-handler
|
|
318
|
+
// already returns a structured 400, but body-parser's internal
|
|
319
|
+
// `JSON.parse(body)` throws a verbose SyntaxError whose 10-line stack trace
|
|
320
|
+
// dumps to stderr (Express dev-mode default error logger). The verify
|
|
321
|
+
// callback below fails earlier with a tight ControlCharBodyError that our
|
|
322
|
+
// handler logs as a single-line warning instead of a stack trace.
|
|
323
|
+
//
|
|
324
|
+
// Most likely source of these bodies: agent-to-agent inject through
|
|
325
|
+
// /api/sessions/:id/input where the `text` field contains raw PTY escape
|
|
326
|
+
// sequences (e.g. one panel forwarding terminal output to another). The
|
|
327
|
+
// 400 response is the correct user-facing semantic; this just quiets the
|
|
328
|
+
// logs so real errors aren't drowned in noise.
|
|
329
|
+
app.use(express.json({
|
|
330
|
+
verify: (req, res, buf) => {
|
|
331
|
+
// O(N) single-pass scan. Only checks bytes inside double-quoted string
|
|
332
|
+
// regions so structural whitespace doesn't trigger false positives.
|
|
333
|
+
let inString = false;
|
|
334
|
+
let escape = false;
|
|
335
|
+
for (let i = 0; i < buf.length; i++) {
|
|
336
|
+
const b = buf[i];
|
|
337
|
+
if (!inString) {
|
|
338
|
+
if (b === 0x22) inString = true; // "
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (escape) { escape = false; continue; }
|
|
342
|
+
if (b === 0x5c) { escape = true; continue; } // backslash
|
|
343
|
+
if (b === 0x22) { inString = false; continue; } // closing quote
|
|
344
|
+
// JSON forbids unescaped control chars (0x00-0x1F and 0x7F) inside
|
|
345
|
+
// string literals. Reject with a structured error.
|
|
346
|
+
if (b < 0x20 || b === 0x7f) {
|
|
347
|
+
const err = new Error(`Body contains illegal control character 0x${b.toString(16).padStart(2, '0')} at byte ${i}`);
|
|
348
|
+
err.type = 'entity.verify.failed';
|
|
349
|
+
err.statusCode = 400;
|
|
350
|
+
err.code = 'CONTROL_CHAR_IN_STRING';
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}));
|
|
276
356
|
|
|
277
357
|
// Sprint 56 (T2 F-T2-1) — malformed-JSON body returns JSON 400, not
|
|
278
358
|
// express's default HTML error page. Pre-Sprint-56 every POST/PATCH
|
|
@@ -281,9 +361,23 @@ function createServer(config) {
|
|
|
281
361
|
// smoke tests). The status code (400) was correct; only the body
|
|
282
362
|
// shape regressed. Mounted IMMEDIATELY after express.json() so it
|
|
283
363
|
// catches body-parse errors before any route handler runs.
|
|
364
|
+
//
|
|
365
|
+
// Sprint 60 v1.0.14 — extended to also catch `entity.verify.failed` from
|
|
366
|
+
// the control-char pre-screen above, AND to log via console.warn (single
|
|
367
|
+
// line) instead of letting Express's default error logger dump a 10-line
|
|
368
|
+
// stack trace to stderr.
|
|
284
369
|
app.use((err, req, res, next) => {
|
|
285
|
-
if (err && (
|
|
286
|
-
|
|
370
|
+
if (err && (
|
|
371
|
+
err.type === 'entity.parse.failed' ||
|
|
372
|
+
err.type === 'entity.verify.failed' ||
|
|
373
|
+
err instanceof SyntaxError
|
|
374
|
+
)) {
|
|
375
|
+
console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path})`);
|
|
376
|
+
return res.status(400).json({
|
|
377
|
+
error: 'Malformed JSON body',
|
|
378
|
+
detail: err.message,
|
|
379
|
+
code: err.code,
|
|
380
|
+
});
|
|
287
381
|
}
|
|
288
382
|
return next(err);
|
|
289
383
|
});
|
|
@@ -1489,7 +1583,10 @@ function createServer(config) {
|
|
|
1489
1583
|
|
|
1490
1584
|
const { cols, rows } = req.body;
|
|
1491
1585
|
try {
|
|
1492
|
-
|
|
1586
|
+
const resized = safelyResizePty(session, cols, rows);
|
|
1587
|
+
if (!resized) {
|
|
1588
|
+
return res.status(409).json({ error: 'Session is exited or its PTY is no longer alive' });
|
|
1589
|
+
}
|
|
1493
1590
|
res.json({ ok: true, cols, rows });
|
|
1494
1591
|
} catch (err) {
|
|
1495
1592
|
res.status(500).json({ error: err.message });
|
|
@@ -2160,9 +2257,10 @@ function createServer(config) {
|
|
|
2160
2257
|
break;
|
|
2161
2258
|
|
|
2162
2259
|
case 'resize':
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2260
|
+
// Sprint 60 v1.0.14 — safelyResizePty guards against the
|
|
2261
|
+
// pty-reaper-closed-the-fd race that surfaced 25x in Brad's
|
|
2262
|
+
// 13h uptime as ioctl EBADF/ENOTTY noise.
|
|
2263
|
+
safelyResizePty(session, parsed.cols, parsed.rows);
|
|
2166
2264
|
break;
|
|
2167
2265
|
|
|
2168
2266
|
case 'meta':
|
|
@@ -2454,7 +2552,16 @@ if (require.main === module) {
|
|
|
2454
2552
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
2455
2553
|
|
|
2456
2554
|
server.listen(port, host, () => {
|
|
2457
|
-
|
|
2555
|
+
// Sprint 60 v1.0.14 (Item 5) — per-boot banner with ISO timestamp + PID.
|
|
2556
|
+
// Brad's 2026-05-07 forensic: a single 260KB termdeck.log spanned Apr 25
|
|
2557
|
+
// through May 7 with only ONE boot banner at the top. Crash → restart
|
|
2558
|
+
// dropped its own banner somewhere we couldn't find, making post-mortem
|
|
2559
|
+
// diagnosis harder. Per-boot timestamps make crash boundaries trivially
|
|
2560
|
+
// greppable and let `journalctl`/`tail` users scan a single log to find
|
|
2561
|
+
// the most recent restart instantly.
|
|
2562
|
+
const bootIso = new Date().toISOString();
|
|
2563
|
+
console.log(`\n ════ TermDeck server boot · ${bootIso} · pid ${process.pid} ════`);
|
|
2564
|
+
console.log(` TermDeck running at http://${host}:${port}\n`);
|
|
2458
2565
|
console.log(` Terminals: 0 active`);
|
|
2459
2566
|
console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
|
|
2460
2567
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
@@ -2470,6 +2577,10 @@ if (require.main === module) {
|
|
|
2470
2577
|
module.exports = {
|
|
2471
2578
|
createServer,
|
|
2472
2579
|
loadConfig,
|
|
2580
|
+
// Sprint 60 v1.0.14 (Item 3) — exported so tests can import the production
|
|
2581
|
+
// helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
|
|
2582
|
+
// the prior re-implementation pattern in the test could drift silently.
|
|
2583
|
+
safelyResizePty,
|
|
2473
2584
|
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2474
2585
|
readTermdeckSecretsForPty,
|
|
2475
2586
|
_resetTermdeckSecretsCache,
|
|
@@ -516,10 +516,29 @@ class Session {
|
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
toJSON() {
|
|
519
|
+
const meta = { ...this.meta };
|
|
520
|
+
// Sprint 60 v1.0.14 — stale-status guard. If a panel's status is in the
|
|
521
|
+
// sticky set ('thinking', 'editing') but no PTY output has arrived for
|
|
522
|
+
// STALE_STATUS_THRESHOLD_MS, treat it as parked at end-of-turn and report
|
|
523
|
+
// 'idle' instead. Lazy: only evaluated on serialization (zero timer cost).
|
|
524
|
+
// Backstops adapter-specific end-of-turn detection — Codex's "Worked for"
|
|
525
|
+
// terminator catches the precise case; this catches the general one
|
|
526
|
+
// (Claude's stuck-on-thinking, future adapters that forget end-of-turn,
|
|
527
|
+
// any adapter where the terminator chunk is split across reads). Bit
|
|
528
|
+
// Sprint 59 twice — orchestrator's GET /api/sessions reported sticky
|
|
529
|
+
// 'thinking' for 22 minutes after the panel actually parked.
|
|
530
|
+
const STICKY_STATUSES = Session.STICKY_STATUSES;
|
|
531
|
+
if (STICKY_STATUSES.has(meta.status)) {
|
|
532
|
+
const ageMs = Date.now() - new Date(meta.lastActivity).getTime();
|
|
533
|
+
if (ageMs > Session.STALE_STATUS_THRESHOLD_MS) {
|
|
534
|
+
meta.status = 'idle';
|
|
535
|
+
meta.statusDetail = '';
|
|
536
|
+
}
|
|
537
|
+
}
|
|
519
538
|
return {
|
|
520
539
|
id: this.id,
|
|
521
540
|
pid: this.pid,
|
|
522
|
-
meta
|
|
541
|
+
meta
|
|
523
542
|
};
|
|
524
543
|
}
|
|
525
544
|
|
|
@@ -530,6 +549,13 @@ class Session {
|
|
|
530
549
|
}
|
|
531
550
|
}
|
|
532
551
|
|
|
552
|
+
// Sprint 60 v1.0.14 — class statics for the stale-status guard. Exposed on
|
|
553
|
+
// the class (not const-locked inside toJSON) so tests can stub them and so
|
|
554
|
+
// the threshold can be tuned in one place if signal/noise needs adjustment.
|
|
555
|
+
Session.STICKY_STATUSES = new Set(['thinking', 'editing']);
|
|
556
|
+
Session.STALE_STATUS_THRESHOLD_MS = 30000;
|
|
557
|
+
|
|
558
|
+
|
|
533
559
|
class SessionManager {
|
|
534
560
|
constructor(db) {
|
|
535
561
|
this.sessions = new Map();
|
|
@@ -26,9 +26,120 @@
|
|
|
26
26
|
|
|
27
27
|
const fs = require('fs');
|
|
28
28
|
const path = require('path');
|
|
29
|
+
const crypto = require('crypto');
|
|
29
30
|
|
|
30
31
|
const SETUP_DIR = __dirname;
|
|
31
32
|
|
|
33
|
+
// Sprint 61 T2 — durable migration tracking table + filename + table name.
|
|
34
|
+
// `mnestra_migrations` is created by bundled migration 020 (RLS-on,
|
|
35
|
+
// service_role-only, no policies). The applyPendingMigrations diff loop and
|
|
36
|
+
// the backfill probe both target this table.
|
|
37
|
+
const TRACKER_TABLE = 'public.mnestra_migrations';
|
|
38
|
+
const TRACKER_FILE = '020_migration_tracking.sql';
|
|
39
|
+
|
|
40
|
+
// Sprint 61 T2 — declarative probe set.
|
|
41
|
+
//
|
|
42
|
+
// One row per bundled mnestra migration 001-019 (020 itself is the tracker
|
|
43
|
+
// and is bootstrap-special-cased; not probed). Each probe is a single
|
|
44
|
+
// presence-style SQL statement: returns ≥1 row when the migration's schema
|
|
45
|
+
// artifact is in place, 0 rows otherwise.
|
|
46
|
+
//
|
|
47
|
+
// Used by applyPendingMigrations() during the backfill pass: when an install
|
|
48
|
+
// is pre-020 (no tracker table yet) and a bundled migration is not in the
|
|
49
|
+
// applied-set, the probe decides whether the migration's effects are already
|
|
50
|
+
// present (→ INSERT a backfill tracker row, skip apply) or genuinely missing
|
|
51
|
+
// (→ run the migration via the normal apply path, INSERT a real tracker row).
|
|
52
|
+
//
|
|
53
|
+
// Probe values:
|
|
54
|
+
// - string: SQL fragment to run via client.query(). Probe is "present"
|
|
55
|
+
// when the result has ≥1 row.
|
|
56
|
+
// - null: no schema artifact to introspect (DML migrations, comments-only
|
|
57
|
+
// placeholders). The first apply runs the migration; the tracker
|
|
58
|
+
// row prevents re-application on subsequent passes. The brief
|
|
59
|
+
// notes 003 (event_webhook placeholder), 011 (project_tag_backfill
|
|
60
|
+
// DML), and 012 (project_tag_re_taxonomy DML) fall here.
|
|
61
|
+
const MIGRATION_PROBES = Object.freeze({
|
|
62
|
+
'001_mnestra_tables.sql':
|
|
63
|
+
"select 1 from information_schema.tables where table_schema='public' and table_name='memory_items'",
|
|
64
|
+
'002_mnestra_search_function.sql':
|
|
65
|
+
"select 1 from pg_proc where proname='memory_hybrid_search'",
|
|
66
|
+
// 003 is a comments-only placeholder migration with no DDL/DML body. The
|
|
67
|
+
// apply path is a no-op on every install. Always-present probe is the
|
|
68
|
+
// honest schema fingerprint — every install for which 001 has run is
|
|
69
|
+
// also "compatible with 003." Post-Sprint-61-T2-audit refinement.
|
|
70
|
+
'003_mnestra_event_webhook.sql':
|
|
71
|
+
"select 1",
|
|
72
|
+
'004_mnestra_match_count_cap_and_explain.sql':
|
|
73
|
+
"select 1 from pg_proc where proname='memory_hybrid_search_explain'",
|
|
74
|
+
'005_v0_1_to_v0_2_upgrade.sql':
|
|
75
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='archived'",
|
|
76
|
+
'006_memory_status_rpc.sql':
|
|
77
|
+
"select 1 from pg_proc where proname='memory_status_aggregation'",
|
|
78
|
+
'007_add_source_session_id.sql':
|
|
79
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='source_session_id'",
|
|
80
|
+
'008_legacy_rag_tables.sql':
|
|
81
|
+
"select 1 from information_schema.tables where table_schema='public' and table_name='mnestra_session_memory'",
|
|
82
|
+
'009_memory_relationship_metadata.sql':
|
|
83
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_relationships' and column_name='weight'",
|
|
84
|
+
'010_memory_recall_graph.sql':
|
|
85
|
+
"select 1 from pg_proc where proname='memory_recall_graph'",
|
|
86
|
+
// 011 retags chopin-nashville rows into post-Sprint-41 buckets (termdeck,
|
|
87
|
+
// rumen, podium, pvb, dor). Probe present iff any row carries one of those
|
|
88
|
+
// tags — meaning either 011 has run, or the install legitimately has rows
|
|
89
|
+
// tagged that way through other means. Either way the apply is a no-op
|
|
90
|
+
// (the UPDATEs are gated on `project = 'chopin-nashville'`), so a false-
|
|
91
|
+
// positive backfill costs nothing. Post-Sprint-61-T2-audit refinement.
|
|
92
|
+
'011_project_tag_backfill.sql':
|
|
93
|
+
"select 1 from memory_items where project in ('termdeck', 'rumen', 'podium', 'pvb', 'dor') limit 1",
|
|
94
|
+
// 012 expands 011's taxonomy with chopin-in-bohemia, chopin-scheduler, and
|
|
95
|
+
// claimguard buckets. Probe present iff any row is in those expanded
|
|
96
|
+
// buckets. Same false-positive-is-harmless reasoning as 011.
|
|
97
|
+
// Post-Sprint-61-T2-audit refinement.
|
|
98
|
+
'012_project_tag_re_taxonomy.sql':
|
|
99
|
+
"select 1 from memory_items where project in ('chopin-in-bohemia', 'chopin-scheduler', 'claimguard') limit 1",
|
|
100
|
+
'013_reclassify_uncertain.sql':
|
|
101
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='reclassified_by'",
|
|
102
|
+
'014_explicit_grants.sql':
|
|
103
|
+
"select 1 where has_table_privilege('service_role', 'public.memory_items', 'INSERT')",
|
|
104
|
+
'015_source_agent.sql':
|
|
105
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='source_agent'",
|
|
106
|
+
'016_mnestra_doctor_probes.sql':
|
|
107
|
+
"select 1 from pg_proc where proname='mnestra_doctor_vault_secret_exists'",
|
|
108
|
+
'017_memory_sessions_session_metadata.sql':
|
|
109
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='session_id'",
|
|
110
|
+
'018_rumen_processed_at.sql':
|
|
111
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='rumen_processed_at'",
|
|
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%'"
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Sprint 61 T2 — self-transactional detection.
|
|
117
|
+
//
|
|
118
|
+
// Bundled migrations 011 + 012 contain top-level `BEGIN;` and `COMMIT;`
|
|
119
|
+
// statements (011:75/217, 012:76/353). When the diff-apply loop wrapped
|
|
120
|
+
// these in its own outer BEGIN/COMMIT, the inner COMMIT closed the outer
|
|
121
|
+
// transaction prematurely and the subsequent `recordApplied` INSERT ran
|
|
122
|
+
// auto-committed — defeating the per-file atomicity contract. T4-CODEX
|
|
123
|
+
// audit-concern 2026-05-07 18:51 ET surfaced this.
|
|
124
|
+
//
|
|
125
|
+
// Detection: case-sensitive match for a line that is exactly `BEGIN;` or
|
|
126
|
+
// `COMMIT;` (top-level). PL/pgSQL anonymous block delimiters (`begin ... end`
|
|
127
|
+
// inside `do $$ ... $$`) use lowercase without trailing semicolon-on-its-
|
|
128
|
+
// own-line, so they don't match.
|
|
129
|
+
//
|
|
130
|
+
// Behavior for self-transactional migrations: SKIP the outer wrapper.
|
|
131
|
+
// Apply via pgRunner.applyFile (which sends the file as a single batched
|
|
132
|
+
// query, inner BEGIN/COMMIT handled by Postgres). Then INSERT the tracker
|
|
133
|
+
// row in a separate auto-commit. The tracker INSERT is recoverable on
|
|
134
|
+
// failure: 011/012 are idempotent (`WHERE project = 'chopin-nashville'`
|
|
135
|
+
// gates every UPDATE; re-running on an already-retagged install is a no-op),
|
|
136
|
+
// so a missing tracker row from a failed INSERT will be re-applied on the
|
|
137
|
+
// next pass and the INSERT retried. The brief explicitly notes this
|
|
138
|
+
// recovery shape under "out-of-T2 scope: rumen migration tracker."
|
|
139
|
+
function isSelfTransactional(sql) {
|
|
140
|
+
return /^[ \t]*(BEGIN|COMMIT)[ \t]*;[ \t]*$/m.test(sql);
|
|
141
|
+
}
|
|
142
|
+
|
|
32
143
|
function listBundled(subdir) {
|
|
33
144
|
const dir = path.join(SETUP_DIR, subdir);
|
|
34
145
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -127,11 +238,387 @@ function readFile(filepath) {
|
|
|
127
238
|
return fs.readFileSync(filepath, 'utf-8');
|
|
128
239
|
}
|
|
129
240
|
|
|
241
|
+
// ── Sprint 61 T2 — durable migration tracker + diff-and-apply ──────────────
|
|
242
|
+
//
|
|
243
|
+
// applyPendingMigrations(client, opts) replaces the per-wizard
|
|
244
|
+
// "apply every bundled migration" loop with a tracker-aware diff loop:
|
|
245
|
+
//
|
|
246
|
+
// 1. Try `SELECT filename, checksum FROM public.mnestra_migrations`.
|
|
247
|
+
// On 42P01 (relation does not exist), the project is pre-020 — bootstrap
|
|
248
|
+
// by applying 020 directly, then INSERT 020's own tracker row, then
|
|
249
|
+
// re-query.
|
|
250
|
+
// 2. Iterate bundled migrations 001..N in lex-filename order. For each
|
|
251
|
+
// bundled file:
|
|
252
|
+
// - Already in tracker: skip; if tracked checksum != bundled checksum,
|
|
253
|
+
// push to warnings[] (do NOT auto-overwrite).
|
|
254
|
+
// - Not in tracker AND probe says present: INSERT backfill row
|
|
255
|
+
// (applied_at = '1970-01-01T00:00:00Z', schema_version = 'backfill'),
|
|
256
|
+
// skip apply. (As of Sprint 61 T2 audit refinement, every bundled
|
|
257
|
+
// migration 001-019 has a non-null probe in MIGRATION_PROBES; the
|
|
258
|
+
// null-probe branch is preserved for forward-compatibility.)
|
|
259
|
+
// - Not in tracker AND probe absent (or null probe):
|
|
260
|
+
// * Self-transactional file (top-level BEGIN; / COMMIT;, currently
|
|
261
|
+
// 011/012): SKIP the outer wrapper. apply via pgRunner.applyFile
|
|
262
|
+
// (the file's own transaction control runs through Postgres).
|
|
263
|
+
// INSERT tracker row in a separate auto-commit. Tracker INSERT
|
|
264
|
+
// failure is recoverable: re-running applyPendingMigrations
|
|
265
|
+
// re-applies the migration (idempotent — the bundled self-tx
|
|
266
|
+
// files are gated on chopin-nashville rows, no-op on subsequent
|
|
267
|
+
// runs) and retries the tracker INSERT.
|
|
268
|
+
// * Non-self-transactional file: BEGIN, apply via
|
|
269
|
+
// pgRunner.applyFile, INSERT real tracker row, COMMIT. On
|
|
270
|
+
// error, ROLLBACK and halt — record errored summary, do not
|
|
271
|
+
// attempt subsequent migrations.
|
|
272
|
+
//
|
|
273
|
+
// Returns:
|
|
274
|
+
// {
|
|
275
|
+
// applied: string[] // filenames applied this pass
|
|
276
|
+
// skipped: string[] // filenames already in tracker
|
|
277
|
+
// backfilled: string[] // filenames probe-seeded this pass
|
|
278
|
+
// warnings: Array<{ file, trackedChecksum, bundledChecksum }>
|
|
279
|
+
// errored: null | { file, error }
|
|
280
|
+
// }
|
|
281
|
+
//
|
|
282
|
+
// Idempotent: a second invocation against an up-to-date project reports
|
|
283
|
+
// applied=[], backfilled=[], errored=null, and skipped=[...all bundled].
|
|
284
|
+
//
|
|
285
|
+
// Test injection (every dependency is replaceable):
|
|
286
|
+
// opts._migrations — module override for listMnestraMigrations / readFile
|
|
287
|
+
// (defaults to this module's own exports).
|
|
288
|
+
// opts._readFile — file-read shim (defaults to fs.readFileSync utf-8).
|
|
289
|
+
// opts._applyFile — pgRunner.applyFile shim (defaults to lazy-required
|
|
290
|
+
// ./pg-runner.applyFile to avoid pulling node-postgres
|
|
291
|
+
// at module-load time).
|
|
292
|
+
// opts._probes — MIGRATION_PROBES override (defaults to the constant).
|
|
293
|
+
|
|
294
|
+
function computeChecksum(content) {
|
|
295
|
+
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Lazy-resolve pgRunner.applyFile so callers in test environments can avoid
|
|
299
|
+
// the `require('pg')` cost. Tests override via opts._applyFile.
|
|
300
|
+
function defaultApplyFile() {
|
|
301
|
+
const pgRunner = require('./pg-runner');
|
|
302
|
+
return (client, file) => pgRunner.applyFile(client, file);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Returns Map<filename, checksum> when the tracker exists, null when the
|
|
306
|
+
// table is missing (PG error code 42P01 — relation does not exist). Any
|
|
307
|
+
// other error propagates.
|
|
308
|
+
async function loadAppliedSet(client) {
|
|
309
|
+
let res;
|
|
310
|
+
try {
|
|
311
|
+
res = await client.query(
|
|
312
|
+
`SELECT filename, checksum FROM ${TRACKER_TABLE}`
|
|
313
|
+
);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err && err.code === '42P01') return null;
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
const map = new Map();
|
|
319
|
+
for (const row of (res && res.rows) || []) {
|
|
320
|
+
map.set(row.filename, row.checksum);
|
|
321
|
+
}
|
|
322
|
+
return map;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Pre-020 bootstrap: the tracker doesn't exist yet, so apply 020 to create
|
|
326
|
+
// it, then INSERT 020's own tracker row. Caller re-queries the tracker
|
|
327
|
+
// afterwards to pick up the seeded row.
|
|
328
|
+
async function bootstrapTracker(client, files, applyFile, readFileImpl) {
|
|
329
|
+
const trackerPath = files.find(
|
|
330
|
+
(f) => path.basename(f) === TRACKER_FILE
|
|
331
|
+
);
|
|
332
|
+
if (!trackerPath) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`applyPendingMigrations: bundled ${TRACKER_FILE} not found in migration set — ` +
|
|
335
|
+
`the migration tracker cannot bootstrap. Re-publish the package or sync ` +
|
|
336
|
+
`the engram migrations directory into packages/server/src/setup/mnestra-migrations/.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const result = await applyFile(client, trackerPath);
|
|
340
|
+
if (!result || result.ok !== true) {
|
|
341
|
+
const detail = (result && result.error) || 'apply returned not-ok with no error message';
|
|
342
|
+
throw new Error(
|
|
343
|
+
`applyPendingMigrations: failed to bootstrap tracker via ${TRACKER_FILE}: ${detail}`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const sql = readFileImpl(trackerPath);
|
|
347
|
+
const checksum = computeChecksum(sql);
|
|
348
|
+
await client.query(
|
|
349
|
+
`INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
|
|
350
|
+
`VALUES ($1, now(), $2, $3) ` +
|
|
351
|
+
`ON CONFLICT (filename) DO UPDATE SET applied_at = EXCLUDED.applied_at, ` +
|
|
352
|
+
`checksum = EXCLUDED.checksum, schema_version = EXCLUDED.schema_version`,
|
|
353
|
+
[TRACKER_FILE, checksum, null]
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Run a probe SQL string and return whether the schema artifact is present.
|
|
358
|
+
// Null probes always return false. Probe-side errors (e.g. relation does
|
|
359
|
+
// not exist when probing into the live schema) are swallowed and degrade
|
|
360
|
+
// to "absent" — same posture as audit-upgrade.js::probeOne. The artifact's
|
|
361
|
+
// real apply path will surface any underlying error with full context.
|
|
362
|
+
async function probePresent(client, probeSql) {
|
|
363
|
+
if (!probeSql) return false;
|
|
364
|
+
try {
|
|
365
|
+
const res = await client.query(probeSql);
|
|
366
|
+
return Array.isArray(res && res.rows) && res.rows.length > 0;
|
|
367
|
+
} catch (_err) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Insert a backfill row for a migration whose probe came back present on
|
|
373
|
+
// a pre-020 install. applied_at is the epoch sentinel so audit queries
|
|
374
|
+
// can distinguish "applied at install" from "tracked from day one." The
|
|
375
|
+
// checksum is recorded too so future bundle drift can still be detected
|
|
376
|
+
// against backfilled rows.
|
|
377
|
+
async function recordBackfill(client, filename, checksum) {
|
|
378
|
+
await client.query(
|
|
379
|
+
`INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
|
|
380
|
+
`VALUES ($1, $2::timestamptz, $3, $4) ` +
|
|
381
|
+
`ON CONFLICT (filename) DO NOTHING`,
|
|
382
|
+
[filename, '1970-01-01T00:00:00Z', checksum, 'backfill']
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Insert a real tracker row after a successful apply.
|
|
387
|
+
async function recordApplied(client, filename, checksum) {
|
|
388
|
+
await client.query(
|
|
389
|
+
`INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
|
|
390
|
+
`VALUES ($1, now(), $2, $3) ` +
|
|
391
|
+
`ON CONFLICT (filename) DO UPDATE SET applied_at = EXCLUDED.applied_at, ` +
|
|
392
|
+
`checksum = EXCLUDED.checksum, schema_version = EXCLUDED.schema_version`,
|
|
393
|
+
[filename, checksum, null]
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function applyPendingMigrations(client, opts = {}) {
|
|
398
|
+
if (!client || typeof client.query !== 'function') {
|
|
399
|
+
throw new Error('applyPendingMigrations: client with .query() is required');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const _migrations = opts._migrations || module.exports;
|
|
403
|
+
const _readFile = opts._readFile || ((p) => fs.readFileSync(p, 'utf-8'));
|
|
404
|
+
const _applyFile = opts._applyFile || defaultApplyFile();
|
|
405
|
+
const _probes = opts._probes || MIGRATION_PROBES;
|
|
406
|
+
|
|
407
|
+
const files = _migrations.listMnestraMigrations();
|
|
408
|
+
if (!files || files.length === 0) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
'applyPendingMigrations: no Mnestra migrations bundled — TermDeck install looks corrupted.'
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const summary = {
|
|
415
|
+
applied: [],
|
|
416
|
+
skipped: [],
|
|
417
|
+
backfilled: [],
|
|
418
|
+
warnings: [],
|
|
419
|
+
errored: null
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Step 1: load applied-set (or bootstrap if missing).
|
|
423
|
+
let applied = await loadAppliedSet(client);
|
|
424
|
+
let bootstrapped = false;
|
|
425
|
+
if (applied === null) {
|
|
426
|
+
// Pre-020 — apply 020 + INSERT row.
|
|
427
|
+
await bootstrapTracker(client, files, _applyFile, _readFile);
|
|
428
|
+
bootstrapped = true;
|
|
429
|
+
summary.applied.push(TRACKER_FILE);
|
|
430
|
+
applied = await loadAppliedSet(client);
|
|
431
|
+
if (applied === null) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
'applyPendingMigrations: tracker still missing after bootstrap — ' +
|
|
434
|
+
'check that 020_migration_tracking.sql actually created public.mnestra_migrations.'
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Step 2: iterate bundled files in lex order.
|
|
440
|
+
for (const file of files) {
|
|
441
|
+
const base = path.basename(file);
|
|
442
|
+
|
|
443
|
+
// Tracker file: bootstrap already accounted for it in summary.applied.
|
|
444
|
+
// On a non-bootstrap pass (post-020 install where 020 is in the tracker),
|
|
445
|
+
// record as skipped. On any other state (tracker present but somehow
|
|
446
|
+
// missing 020), fall through to the normal apply path so the diff loop
|
|
447
|
+
// can re-record it.
|
|
448
|
+
if (base === TRACKER_FILE) {
|
|
449
|
+
if (bootstrapped) {
|
|
450
|
+
// Already in summary.applied via bootstrap; do not duplicate.
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (applied.has(TRACKER_FILE)) {
|
|
454
|
+
summary.skipped.push(base);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// Defensive fall-through: tracker exists (loadAppliedSet succeeded) but
|
|
458
|
+
// doesn't have 020's row. Apply path below will re-record it.
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let sql;
|
|
462
|
+
try {
|
|
463
|
+
sql = _readFile(file);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
summary.errored = {
|
|
466
|
+
file: base,
|
|
467
|
+
error: err && err.message ? err.message : String(err)
|
|
468
|
+
};
|
|
469
|
+
return summary;
|
|
470
|
+
}
|
|
471
|
+
const checksum = computeChecksum(sql);
|
|
472
|
+
|
|
473
|
+
if (applied.has(base)) {
|
|
474
|
+
// Already applied — checksum-drift guard.
|
|
475
|
+
const trackedChecksum = applied.get(base);
|
|
476
|
+
if (trackedChecksum && trackedChecksum !== checksum) {
|
|
477
|
+
summary.warnings.push({
|
|
478
|
+
file: base,
|
|
479
|
+
trackedChecksum,
|
|
480
|
+
bundledChecksum: checksum
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
summary.skipped.push(base);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Not applied. Try probe-backfill (only meaningful on pre-020 installs
|
|
488
|
+
// that just bootstrapped, but cheap to evaluate on every pass; an
|
|
489
|
+
// already-tracked migration short-circuits above before reaching here).
|
|
490
|
+
const probeSql = Object.prototype.hasOwnProperty.call(_probes, base)
|
|
491
|
+
? _probes[base]
|
|
492
|
+
: null;
|
|
493
|
+
if (probeSql) {
|
|
494
|
+
const present = await probePresent(client, probeSql);
|
|
495
|
+
if (present) {
|
|
496
|
+
try {
|
|
497
|
+
await recordBackfill(client, base, checksum);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
summary.errored = {
|
|
500
|
+
file: base,
|
|
501
|
+
error: `backfill INSERT failed: ${err && err.message ? err.message : String(err)}`
|
|
502
|
+
};
|
|
503
|
+
return summary;
|
|
504
|
+
}
|
|
505
|
+
summary.backfilled.push(base);
|
|
506
|
+
applied.set(base, checksum);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Genuinely needs apply. Self-transactional migrations (011, 012) ship
|
|
512
|
+
// top-level BEGIN/COMMIT in their bodies — see isSelfTransactional + the
|
|
513
|
+
// header comment near its definition. For those, skip the outer wrapper
|
|
514
|
+
// and rely on the file's own transaction control + idempotency for
|
|
515
|
+
// recovery on tracker INSERT failure. For everything else, wrap in
|
|
516
|
+
// outer BEGIN/COMMIT for per-file atomicity (apply + tracker row commit
|
|
517
|
+
// or roll back together).
|
|
518
|
+
const selfTx = isSelfTransactional(sql);
|
|
519
|
+
|
|
520
|
+
if (selfTx) {
|
|
521
|
+
let applyResult;
|
|
522
|
+
try {
|
|
523
|
+
applyResult = await _applyFile(client, file);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
summary.errored = {
|
|
526
|
+
file: base,
|
|
527
|
+
error: err && err.message ? err.message : String(err)
|
|
528
|
+
};
|
|
529
|
+
return summary;
|
|
530
|
+
}
|
|
531
|
+
if (!applyResult || applyResult.ok !== true) {
|
|
532
|
+
summary.errored = {
|
|
533
|
+
file: base,
|
|
534
|
+
error: (applyResult && applyResult.error) || 'apply returned not-ok with no error message'
|
|
535
|
+
};
|
|
536
|
+
return summary;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
await recordApplied(client, base, checksum);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
summary.errored = {
|
|
542
|
+
file: base,
|
|
543
|
+
error: `tracker INSERT failed (self-transactional ${base} applied; re-run will replay it idempotently and retry the tracker insert): ${err && err.message ? err.message : String(err)}`
|
|
544
|
+
};
|
|
545
|
+
return summary;
|
|
546
|
+
}
|
|
547
|
+
summary.applied.push(base);
|
|
548
|
+
applied.set(base, checksum);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Non-self-transactional path: outer BEGIN/COMMIT wrapper.
|
|
553
|
+
try {
|
|
554
|
+
await client.query('BEGIN');
|
|
555
|
+
} catch (err) {
|
|
556
|
+
summary.errored = {
|
|
557
|
+
file: base,
|
|
558
|
+
error: `BEGIN failed: ${err && err.message ? err.message : String(err)}`
|
|
559
|
+
};
|
|
560
|
+
return summary;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let applyResult;
|
|
564
|
+
try {
|
|
565
|
+
applyResult = await _applyFile(client, file);
|
|
566
|
+
} catch (err) {
|
|
567
|
+
try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
|
|
568
|
+
summary.errored = {
|
|
569
|
+
file: base,
|
|
570
|
+
error: err && err.message ? err.message : String(err)
|
|
571
|
+
};
|
|
572
|
+
return summary;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!applyResult || applyResult.ok !== true) {
|
|
576
|
+
try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
|
|
577
|
+
summary.errored = {
|
|
578
|
+
file: base,
|
|
579
|
+
error: (applyResult && applyResult.error) || 'apply returned not-ok with no error message'
|
|
580
|
+
};
|
|
581
|
+
return summary;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
await recordApplied(client, base, checksum);
|
|
586
|
+
await client.query('COMMIT');
|
|
587
|
+
} catch (err) {
|
|
588
|
+
try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
|
|
589
|
+
summary.errored = {
|
|
590
|
+
file: base,
|
|
591
|
+
error: `tracker INSERT failed: ${err && err.message ? err.message : String(err)}`
|
|
592
|
+
};
|
|
593
|
+
return summary;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
summary.applied.push(base);
|
|
597
|
+
applied.set(base, checksum);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return summary;
|
|
601
|
+
}
|
|
602
|
+
|
|
130
603
|
module.exports = {
|
|
131
604
|
listMnestraMigrations,
|
|
132
605
|
listRumenMigrations,
|
|
133
606
|
rumenFunctionsRoot,
|
|
134
607
|
listRumenFunctions,
|
|
135
608
|
rumenFunctionDir,
|
|
136
|
-
readFile
|
|
609
|
+
readFile,
|
|
610
|
+
// Sprint 61 T2 — migration tracker.
|
|
611
|
+
applyPendingMigrations,
|
|
612
|
+
MIGRATION_PROBES,
|
|
613
|
+
TRACKER_TABLE,
|
|
614
|
+
TRACKER_FILE,
|
|
615
|
+
// Test surface — kept exported so tests/migration-tracker.test.js can pin
|
|
616
|
+
// each helper without a live pg client.
|
|
617
|
+
_computeChecksum: computeChecksum,
|
|
618
|
+
_loadAppliedSet: loadAppliedSet,
|
|
619
|
+
_bootstrapTracker: bootstrapTracker,
|
|
620
|
+
_probePresent: probePresent,
|
|
621
|
+
_recordBackfill: recordBackfill,
|
|
622
|
+
_recordApplied: recordApplied,
|
|
623
|
+
_isSelfTransactional: isSelfTransactional
|
|
137
624
|
};
|
|
@@ -79,8 +79,8 @@ create index if not exists memory_relationships_target_idx on memory_relationshi
|
|
|
79
79
|
-- ── match_memories helper RPC ─────────────────────────────────────────────
|
|
80
80
|
-- Used by remember.ts (dedup) and consolidate.ts (cluster seeding).
|
|
81
81
|
--
|
|
82
|
-
-- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs
|
|
83
|
-
--
|
|
82
|
+
-- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs,
|
|
83
|
+
-- match_memories was created by
|
|
84
84
|
-- a prior Mnestra version with a different RETURN-table column shape:
|
|
85
85
|
-- (id, content, metadata, source_type, category, project, created_at, similarity)
|
|
86
86
|
-- vs the canonical:
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
-- Sprint 51.9 — signature-drift guard. Same Class A pattern Sprint 52.1
|
|
17
17
|
-- closed for `match_memories` (mig 001:81-95). Codex T4 surfaced the cousin
|
|
18
18
|
-- 2026-05-04 14:42 ET during Sprint 51.5b dogfood: long-lived v0.6.x-era
|
|
19
|
-
-- installs
|
|
19
|
+
-- installs ALSO have a
|
|
20
20
|
-- 10-arg drift overload of `memory_hybrid_search` coexisting with the
|
|
21
21
|
-- canonical 8-arg signature. The drift overload carries the never-shipped
|
|
22
22
|
-- `recency_weight`/`decay_days` parameters from a pre-canonical Mnestra
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
--
|
|
9
9
|
-- Idempotent: safe to re-run.
|
|
10
10
|
--
|
|
11
|
-
-- Pre-existing state (verified against
|
|
11
|
+
-- Pre-existing state (verified against the reference Mnestra project 2026-04-27 17:25 ET):
|
|
12
12
|
-- memory_relationships has 749 live edges. The migration adds nullable
|
|
13
13
|
-- columns and a wider CHECK; no existing row violates the new constraint.
|
|
14
14
|
--
|
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
-- 1. termdeck / mnestra — keywords: termdeck, mnestra, "4+1 sprint"
|
|
37
37
|
-- 2. rumen — keyword: rumen
|
|
38
38
|
-- 3. podium — keyword: podium
|
|
39
|
-
-- 4. pvb — keywords: PVB,
|
|
39
|
+
-- 4. pvb — keywords: PVB, pet vet bid (and the
|
|
40
|
+
-- legacy single-word identifier matched
|
|
41
|
+
-- by the load-bearing classifier on
|
|
42
|
+
-- line 156)
|
|
40
43
|
-- 5. dor / openclaw — TIGHTENED:
|
|
41
44
|
-- word-boundary uppercase DOR (rules out
|
|
42
45
|
-- "dormant", "vendored", "indoor", etc.),
|
|
@@ -49,7 +52,8 @@
|
|
|
49
52
|
-- rumen: 92 rows, all 6 sampled were true positives.
|
|
50
53
|
-- podium: 58 rows, all 6 sampled were true positives.
|
|
51
54
|
-- pvb: 7 rows, 1 of those overlaps with mnestra ("Mnestra
|
|
52
|
-
-- repo …
|
|
55
|
+
-- repo … legacy single-word project name") and gets
|
|
56
|
+
-- claimed by bucket 1.
|
|
53
57
|
-- dor (tightened): 3 rows after tightening from 6 — the original
|
|
54
58
|
-- `%dor%` ILIKE pattern caught false positives like
|
|
55
59
|
-- "dormant", "vendored". Final 3 rows are all true
|
|
@@ -143,7 +147,7 @@ BEGIN
|
|
|
143
147
|
END $$;
|
|
144
148
|
|
|
145
149
|
-- ============================================================
|
|
146
|
-
-- BUCKET 4 — PVB (case-insensitive
|
|
150
|
+
-- BUCKET 4 — PVB (case-insensitive content markers — see code below)
|
|
147
151
|
-- ============================================================
|
|
148
152
|
DO $$
|
|
149
153
|
DECLARE
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
-- by Joshua; case-sensitive word-boundary token
|
|
50
50
|
-- avoids matching unrelated "[maestro]" log
|
|
51
51
|
-- prefixes)
|
|
52
|
-
-- 6. pvb — PVB,
|
|
52
|
+
-- 6. pvb — PVB, "pet vet bid"
|
|
53
53
|
-- 7. claimguard — claimguard, gorgias-ticket-monitor,
|
|
54
54
|
-- "gorgias ticket monitor"
|
|
55
55
|
-- 8. dor — \mDOR\M, /DOR/, ~/Documents/DOR, dor.config,
|
|
@@ -234,7 +234,7 @@ BEGIN
|
|
|
234
234
|
END $$;
|
|
235
235
|
|
|
236
236
|
-- ============================================================
|
|
237
|
-
-- BUCKET 6 — pvb (case-insensitive
|
|
237
|
+
-- BUCKET 6 — pvb (case-insensitive content markers — see code below)
|
|
238
238
|
--
|
|
239
239
|
-- Same pattern as 011 bucket 4. PVB is small in the chopin-nashville bucket
|
|
240
240
|
-- (Sprint 39 dry-run found 7 rows; live apply landed 3 because bucket 1
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
-- PostgREST checks table-level privileges before evaluating RLS, so
|
|
18
18
|
-- service_role's bypassrls attribute does not help.
|
|
19
19
|
--
|
|
20
|
-
-- Reported and root-caused by Brad Heath 2026-04-28 against
|
|
21
|
-
--
|
|
22
|
-
--
|
|
20
|
+
-- Reported and root-caused by Brad Heath 2026-04-28 against his Mnestra
|
|
21
|
+
-- project; fix verified end-to-end on his install before being upstreamed
|
|
22
|
+
-- here.
|
|
23
23
|
--
|
|
24
24
|
-- This migration is idempotent and safe on greenfield projects where
|
|
25
25
|
-- the auto-grant default already fired (the GRANTs become no-ops).
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
-- their grants are now wrapped in a do$$ guard that only emits them
|
|
28
28
|
-- when `pg_cron` is enabled. The doctor's cron-related probes return
|
|
29
29
|
-- the existing `unknown` band (Sprint 51.5 T2 already established it)
|
|
30
|
-
-- when the wrappers don't exist — graceful degradation.
|
|
31
|
-
--
|
|
30
|
+
-- when the wrappers don't exist — graceful degradation. Existing
|
|
31
|
+
-- rumen-bearing installs are unaffected because both have rumen installed,
|
|
32
32
|
-- which enables pg_cron via rumen's mig 002. Closes the
|
|
33
33
|
-- mnestra-only-no-rumen fresh-install path.
|
|
34
34
|
|
|
@@ -103,7 +103,7 @@ grant execute on function mnestra_doctor_vault_secret_exists(text) to ser
|
|
|
103
103
|
--
|
|
104
104
|
-- Idempotent: do$$ runs every replay; CREATE OR REPLACE keeps the
|
|
105
105
|
-- function definitions in sync if pg_cron later gets enabled and the
|
|
106
|
-
-- migration re-runs. Existing installs
|
|
106
|
+
-- migration re-runs. Existing installs typically have
|
|
107
107
|
-- pg_cron from Rumen's install path and emit these unconditionally.
|
|
108
108
|
|
|
109
109
|
do $cron_guard$
|
package/packages/server/src/setup/mnestra-migrations/017_memory_sessions_session_metadata.sql
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
-- Sprint 51.6 T3 (TermDeck v1.0.2 hotfix wave). Brings the canonical engram
|
|
4
4
|
-- memory_sessions schema in line with the rag-system writer's column set so
|
|
5
5
|
-- TermDeck's bundled session-end hook can write a uniform shape on both
|
|
6
|
-
-- fresh-canonical installs and Joshua's daily-driver
|
|
6
|
+
-- fresh-canonical installs and Joshua's daily-driver the reference Mnestra project (where the
|
|
7
7
|
-- columns were already added by hand when rag-system bootstrap ran).
|
|
8
8
|
--
|
|
9
9
|
-- Why: until v1.0.2 the bundled hook only wrote memory_items. The actual
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
-- the schema it expects exists everywhere.
|
|
18
18
|
--
|
|
19
19
|
-- Idempotent — safe on:
|
|
20
|
-
-- 1.
|
|
20
|
+
-- 1. the reference Mnestra project (where these columns are already present from hand-applied
|
|
21
21
|
-- DDL Joshua ran when setting up rag-system; the IF NOT EXISTS guards
|
|
22
22
|
-- no-op on every column).
|
|
23
23
|
-- 2. Fresh canonical installs that ran migrations 001-016 only (the canonical
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
--
|
|
27
27
|
-- The unique constraint on session_id is wrapped in a do-block because
|
|
28
28
|
-- ADD CONSTRAINT does not support IF NOT EXISTS in PostgreSQL. Joshua's
|
|
29
|
-
--
|
|
29
|
+
-- the reference Mnestra project already has the constraint as memory_sessions_session_id_key
|
|
30
30
|
-- (auto-named by the rag-system bootstrap); this block detects that name
|
|
31
31
|
-- and skips re-adding.
|
|
32
32
|
--
|
|
33
|
-
-- session_id is added NULLABLE on canonical installs even though
|
|
33
|
+
-- session_id is added NULLABLE on canonical installs even though the reference Mnestra project's
|
|
34
34
|
-- existing constraint is NOT NULL. Adding NOT NULL via ALTER TABLE on a
|
|
35
35
|
-- table with existing rows would fail; the bundled hook always supplies
|
|
36
36
|
-- session_id at write time, so nullability is non-blocking. A future sprint
|
|
@@ -56,7 +56,7 @@ alter table public.memory_sessions
|
|
|
56
56
|
-- Unique constraint on session_id. Skip if any unique constraint on
|
|
57
57
|
-- (session_id) is already in place — covers both the canonical name
|
|
58
58
|
-- memory_sessions_session_id_key and any alternate name from a manual
|
|
59
|
-
-- ALTER TABLE Joshua may have run on
|
|
59
|
+
-- ALTER TABLE Joshua may have run on the reference Mnestra project.
|
|
60
60
|
do $$
|
|
61
61
|
declare
|
|
62
62
|
has_unique boolean;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
-- 1. Joshua's daily-driver (pre-Sprint-53; column will be added with
|
|
20
20
|
-- every existing memory_sessions row at NULL → all become candidates
|
|
21
21
|
-- on the first post-deploy tick, which is the desired bootstrap).
|
|
22
|
-
-- 2.
|
|
22
|
+
-- 2. Linux SSH installs (same shape, same null-bootstrap).
|
|
23
23
|
-- 3. Fresh canonical installs (post-mig-017 schema; column added on
|
|
24
24
|
-- first run, no rows to backfill).
|
|
25
25
|
-- 4. Re-runs (ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS).
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
-- Mnestra v0.4.6 — security hardening (revised from 0.4.4 / 0.4.5).
|
|
2
|
+
--
|
|
3
|
+
-- Source: external Supabase-advisor sweep by Brad Heath / Nacho Money LLC,
|
|
4
|
+
-- 2026-05-06. See docs/SECURITY-HARDENING-2026-05-06.md for the full flag
|
|
5
|
+
-- and root-cause analysis. The standing rule lives in the global Claude
|
|
6
|
+
-- Code instructions: "MANDATORY: Supabase RLS + privilege hygiene".
|
|
7
|
+
--
|
|
8
|
+
-- Two corrections folded into this revision:
|
|
9
|
+
--
|
|
10
|
+
-- A. **search_path must include `extensions`.** The 0.4.4/0.4.5 version of
|
|
11
|
+
-- this migration set search_path = public, pg_catalog on the memory_*
|
|
12
|
+
-- RPCs. Supabase >= 2024 installs pgvector in the `extensions` schema,
|
|
13
|
+
-- so the `<=>` cosine-distance operator becomes unreachable from those
|
|
14
|
+
-- RPCs after the alter — semantic recall fails with "operator does not
|
|
15
|
+
-- exist: extensions.vector <=> extensions.vector". Confirmed live
|
|
16
|
+
-- against the reference Mnestra project on 2026-05-06; fixed by
|
|
17
|
+
-- including `extensions` in search_path.
|
|
18
|
+
--
|
|
19
|
+
-- B. **Schema-generation-aware.** Some Mnestra installs are on the older
|
|
20
|
+
-- "memory_items-only" generation — they have memory_items /
|
|
21
|
+
-- memory_relationships / memory_sessions + the 6 memory_* RPCs, but
|
|
22
|
+
-- NOT the layered-memory tables (mnestra_session_memory,
|
|
23
|
+
-- mnestra_developer_memory, mnestra_project_memory, mnestra_commands)
|
|
24
|
+
-- and NOT the mnestra_doctor_* SECURITY DEFINER probes. The 0.4.4 / 0.4.5
|
|
25
|
+
-- migration body assumed the layered shape and threw "relation does
|
|
26
|
+
-- not exist" / "function does not exist" mid-migration on older
|
|
27
|
+
-- installs. Brad caught this on three of his projects (Structural,
|
|
28
|
+
-- aetheria-payroll, aetheria-phase1) and worked around with a
|
|
29
|
+
-- signature-agnostic DO-block subset.
|
|
30
|
+
--
|
|
31
|
+
-- This revision restructures every section as defensive lookups
|
|
32
|
+
-- against pg_class / pg_proc / pg_views, so each statement only fires
|
|
33
|
+
-- when its target exists. The migration runs cleanly on:
|
|
34
|
+
-- - layered-memory generation (Josh's reference project): full fix
|
|
35
|
+
-- - memory_items-only generation (Brad's three projects): function
|
|
36
|
+
-- hardening only; mnestra_*-targeting statements are skipped
|
|
37
|
+
-- - mixed generation: each statement applies to whatever exists
|
|
38
|
+
--
|
|
39
|
+
-- Closes four hole classes (where applicable to the install's schema
|
|
40
|
+
-- generation):
|
|
41
|
+
--
|
|
42
|
+
-- 1. Permissive PUBLIC INSERT RLS on mnestra_{commands,developer_memory,
|
|
43
|
+
-- project_memory,session_memory}. Created by Supabase Studio's
|
|
44
|
+
-- "Allow insert for all" default-policy template at table-creation
|
|
45
|
+
-- time. Anyone with the project's anon key could write directly to
|
|
46
|
+
-- memory tables, poisoning the corpus or session-id-squatting.
|
|
47
|
+
--
|
|
48
|
+
-- 2. PUBLIC EXECUTE on every Mnestra function. Postgres defaults
|
|
49
|
+
-- function EXECUTE to PUBLIC; the explicit `grant ... to service_role`
|
|
50
|
+
-- in earlier migrations is additive, not exclusive.
|
|
51
|
+
--
|
|
52
|
+
-- 3. Mutable search_path on memory_* and mnestra_doctor_* functions
|
|
53
|
+
-- (Supabase lint 0011).
|
|
54
|
+
--
|
|
55
|
+
-- 4. mnestra_recent_activity SECURITY DEFINER view (Supabase lint 0010)
|
|
56
|
+
-- with anon+authenticated SELECT.
|
|
57
|
+
--
|
|
58
|
+
-- Backward-compat: zero behavior change for any Mnestra installation that
|
|
59
|
+
-- follows the documented architecture (service-role writes via MCP server).
|
|
60
|
+
-- service_role keeps EXECUTE on every function and SELECT on the view.
|
|
61
|
+
--
|
|
62
|
+
-- Idempotent: every section guards on object existence and uses
|
|
63
|
+
-- IF EXISTS / signature-agnostic patterns. Re-running this migration is
|
|
64
|
+
-- safe and is in fact the recommended way to upgrade a 0.4.4/0.4.5 install
|
|
65
|
+
-- to pick up the search_path fix.
|
|
66
|
+
|
|
67
|
+
-- ====================================================================
|
|
68
|
+
-- 1. Drop permissive PUBLIC INSERT policies on mnestra_* tables, when
|
|
69
|
+
-- those tables exist on this install. Skipped silently on older
|
|
70
|
+
-- memory_items-only schema generation.
|
|
71
|
+
-- ====================================================================
|
|
72
|
+
|
|
73
|
+
do $$
|
|
74
|
+
declare
|
|
75
|
+
tbl text;
|
|
76
|
+
tables text[] := array[
|
|
77
|
+
'mnestra_commands',
|
|
78
|
+
'mnestra_developer_memory',
|
|
79
|
+
'mnestra_project_memory',
|
|
80
|
+
'mnestra_session_memory'
|
|
81
|
+
];
|
|
82
|
+
begin
|
|
83
|
+
foreach tbl in array tables loop
|
|
84
|
+
if to_regclass(format('public.%I', tbl)) is not null then
|
|
85
|
+
execute format('drop policy if exists "Allow insert for all" on public.%I', tbl);
|
|
86
|
+
end if;
|
|
87
|
+
end loop;
|
|
88
|
+
end $$;
|
|
89
|
+
|
|
90
|
+
-- ====================================================================
|
|
91
|
+
-- 2 + 3. Revoke EXECUTE from public + anon + authenticated AND pin
|
|
92
|
+
-- search_path on every Mnestra function. Signature-agnostic — iterates
|
|
93
|
+
-- pg_proc to apply to whatever functions exist on this install. Covers
|
|
94
|
+
-- memory_*, match_memories, expand_memory_neighborhood, and
|
|
95
|
+
-- mnestra_doctor_*.
|
|
96
|
+
--
|
|
97
|
+
-- search_path includes `extensions` for the pgvector operator and
|
|
98
|
+
-- pg_catalog for built-ins; doctor functions don't use vectors but the
|
|
99
|
+
-- inclusion is harmless and keeps every Mnestra function uniform.
|
|
100
|
+
-- ====================================================================
|
|
101
|
+
|
|
102
|
+
do $$
|
|
103
|
+
declare
|
|
104
|
+
fn record;
|
|
105
|
+
sig text;
|
|
106
|
+
begin
|
|
107
|
+
for fn in
|
|
108
|
+
select n.nspname,
|
|
109
|
+
p.proname,
|
|
110
|
+
pg_get_function_identity_arguments(p.oid) as ident_args
|
|
111
|
+
from pg_proc p
|
|
112
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
113
|
+
where n.nspname = 'public'
|
|
114
|
+
and p.prokind = 'f'
|
|
115
|
+
and (
|
|
116
|
+
p.proname like 'memory_%'
|
|
117
|
+
or p.proname in ('match_memories', 'expand_memory_neighborhood')
|
|
118
|
+
or p.proname like 'mnestra_doctor_%'
|
|
119
|
+
)
|
|
120
|
+
loop
|
|
121
|
+
sig := format('%I.%I(%s)', fn.nspname, fn.proname, fn.ident_args);
|
|
122
|
+
execute format('revoke execute on function %s from public, anon, authenticated', sig);
|
|
123
|
+
execute format('alter function %s set search_path = public, extensions, pg_catalog', sig);
|
|
124
|
+
-- service_role keeps EXECUTE; the revoke above only targets public/anon/authenticated.
|
|
125
|
+
end loop;
|
|
126
|
+
end $$;
|
|
127
|
+
|
|
128
|
+
-- ====================================================================
|
|
129
|
+
-- 4. Recreate mnestra_recent_activity view without SECURITY DEFINER and
|
|
130
|
+
-- restrict SELECT to service_role. Skipped silently if the view doesn't
|
|
131
|
+
-- exist or any of the three underlying tables are missing.
|
|
132
|
+
-- ====================================================================
|
|
133
|
+
|
|
134
|
+
do $$
|
|
135
|
+
begin
|
|
136
|
+
if to_regclass('public.mnestra_session_memory') is not null
|
|
137
|
+
and to_regclass('public.mnestra_project_memory') is not null
|
|
138
|
+
and to_regclass('public.mnestra_developer_memory') is not null
|
|
139
|
+
then
|
|
140
|
+
drop view if exists public.mnestra_recent_activity;
|
|
141
|
+
|
|
142
|
+
execute $view$
|
|
143
|
+
create view public.mnestra_recent_activity as
|
|
144
|
+
select 'session'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_session_memory
|
|
145
|
+
union all
|
|
146
|
+
select 'project'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_project_memory
|
|
147
|
+
union all
|
|
148
|
+
select 'developer'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_developer_memory
|
|
149
|
+
order by 8 desc
|
|
150
|
+
limit 100
|
|
151
|
+
$view$;
|
|
152
|
+
|
|
153
|
+
revoke all on public.mnestra_recent_activity from public, anon, authenticated;
|
|
154
|
+
grant select on public.mnestra_recent_activity to service_role;
|
|
155
|
+
end if;
|
|
156
|
+
end $$;
|
|
157
|
+
|
|
158
|
+
-- ====================================================================
|
|
159
|
+
-- Post-apply verification (run separately in Studio SQL editor):
|
|
160
|
+
--
|
|
161
|
+
-- -- Should return zero rows:
|
|
162
|
+
-- with bad_policies as (
|
|
163
|
+
-- select policyname from pg_policies
|
|
164
|
+
-- where schemaname='public' and tablename like 'mnestra_%'
|
|
165
|
+
-- and ('public' = any(roles) or roles = '{}')
|
|
166
|
+
-- and (with_check='true' or qual='true')
|
|
167
|
+
-- ),
|
|
168
|
+
-- public_exec as (
|
|
169
|
+
-- select p.proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace
|
|
170
|
+
-- where n.nspname='public'
|
|
171
|
+
-- and (p.proname like 'mnestra_doctor_%' or p.proname like 'memory_%'
|
|
172
|
+
-- or p.proname in ('match_memories','expand_memory_neighborhood'))
|
|
173
|
+
-- and has_function_privilege('public', p.oid, 'EXECUTE')
|
|
174
|
+
-- ),
|
|
175
|
+
-- mutable_path as (
|
|
176
|
+
-- select p.proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace
|
|
177
|
+
-- where n.nspname='public' and p.prokind='f'
|
|
178
|
+
-- and (p.proname like 'memory_%' or p.proname like 'mnestra_doctor_%')
|
|
179
|
+
-- and not exists (
|
|
180
|
+
-- select 1 from unnest(coalesce(p.proconfig,'{}'::text[])) c
|
|
181
|
+
-- where c like 'search_path=%'
|
|
182
|
+
-- )
|
|
183
|
+
-- )
|
|
184
|
+
-- select 'BAD_POLICY' as kind, policyname as detail from bad_policies
|
|
185
|
+
-- union all select 'PUBLIC_EXEC', proname from public_exec
|
|
186
|
+
-- union all select 'MUTABLE_SEARCH_PATH', proname from mutable_path;
|
|
187
|
+
--
|
|
188
|
+
-- Verified zero rows on the reference Mnestra project on 2026-05-06.
|
|
189
|
+
-- Smoke test: select count(*) from memory_hybrid_search('smoke', array_fill(0::real, ARRAY[1536])::vector, 1) → 1 row, no operator-resolution error.
|
|
190
|
+
-- ====================================================================
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- 020_migration_tracking.sql
|
|
2
|
+
-- Adds durable tracking of which Mnestra migrations have been applied to a project,
|
|
3
|
+
-- so upgrade paths can compute (bundled - applied) and apply only the diff.
|
|
4
|
+
-- Sprint 61 (TermDeck Convergence Keystone), Mnestra 0.4.7.
|
|
5
|
+
--
|
|
6
|
+
-- Why this exists: prior to 020, the mnestra/rumen wizards re-applied every
|
|
7
|
+
-- bundled migration on every invocation, relying on per-migration
|
|
8
|
+
-- `IF NOT EXISTS` / `CREATE OR REPLACE` idempotency to avoid duplicate work.
|
|
9
|
+
-- That works for a fresh install but doesn't tell the wizard which migrations
|
|
10
|
+
-- the live database is missing — so a user running `npm install -g @latest`
|
|
11
|
+
-- against an existing project gets the new package files without any way to
|
|
12
|
+
-- detect schema drift. Class A (schema drift on package upgrade) per
|
|
13
|
+
-- termdeck/docs/INSTALLER-PITFALLS.md.
|
|
14
|
+
--
|
|
15
|
+
-- Shape:
|
|
16
|
+
-- - `filename` text PK — the bundled migration filename, e.g.
|
|
17
|
+
-- `015_source_agent.sql`. PK because each
|
|
18
|
+
-- bundled file applies at most once.
|
|
19
|
+
-- - `applied_at` timestamptz — wall-clock time of apply. Backfilled
|
|
20
|
+
-- rows (rows seeded by the post-020 backfill
|
|
21
|
+
-- probe for migrations applied pre-020) use
|
|
22
|
+
-- epoch (1970-01-01T00:00:00Z) as a sentinel.
|
|
23
|
+
-- - `checksum` text — SHA-256 of the bundled file content at apply
|
|
24
|
+
-- time. Lets future runs detect bundle drift
|
|
25
|
+
-- without auto-overwriting the live schema.
|
|
26
|
+
-- - `schema_version` text — optional free-text marker. Backfill rows use
|
|
27
|
+
-- the literal `'backfill'` so audit queries
|
|
28
|
+
-- can distinguish them.
|
|
29
|
+
--
|
|
30
|
+
-- RLS posture: ENABLE ROW LEVEL SECURITY + REVOKE ALL FROM PUBLIC. No
|
|
31
|
+
-- policies are intentional — anon and authenticated have NO access, full
|
|
32
|
+
-- stop. service_role bypasses RLS in Postgres by default, which is the only
|
|
33
|
+
-- caller that should ever touch this table (the migration runner connects
|
|
34
|
+
-- via DATABASE_URL using service-role credentials).
|
|
35
|
+
--
|
|
36
|
+
-- Idempotent: re-applying this migration on a project that already has the
|
|
37
|
+
-- table is a no-op (CREATE TABLE IF NOT EXISTS, ALTER TABLE ... ENABLE RLS
|
|
38
|
+
-- is a no-op when already enabled, REVOKE/GRANT are idempotent).
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS public.mnestra_migrations (
|
|
41
|
+
filename text PRIMARY KEY,
|
|
42
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
checksum text NOT NULL,
|
|
44
|
+
schema_version text
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
ALTER TABLE public.mnestra_migrations ENABLE ROW LEVEL SECURITY;
|
|
48
|
+
|
|
49
|
+
-- Service-role-only. anon and authenticated have NO access (no policies = denied by RLS).
|
|
50
|
+
-- Service role bypasses RLS by default; the table is queried only by the migration runner
|
|
51
|
+
-- which uses the service-role key.
|
|
52
|
+
|
|
53
|
+
REVOKE ALL ON public.mnestra_migrations FROM PUBLIC;
|
|
54
|
+
GRANT ALL ON public.mnestra_migrations TO service_role;
|
|
55
|
+
|
|
56
|
+
COMMENT ON TABLE public.mnestra_migrations IS
|
|
57
|
+
'Tracking table for applied Mnestra migrations. service_role-only; RLS-on; no policies.';
|
|
@@ -52,40 +52,10 @@ serve(async (_req: Request) => {
|
|
|
52
52
|
|
|
53
53
|
const pool = createPoolFromUrl(url);
|
|
54
54
|
|
|
55
|
-
// Sprint 56 (T3 Cell #1 backlog catch-up) — env-var overrides for one-off
|
|
56
|
-
// historic processing. Set via `supabase secrets set`:
|
|
57
|
-
// RUMEN_LOOKBACK_HOURS_OVERRIDE=2880 (120 days; bypasses default 72h)
|
|
58
|
-
// RUMEN_MAX_SESSIONS_OVERRIDE=300 (processes whole 289-session
|
|
59
|
-
// backlog in one tick rather than
|
|
60
|
-
// 28 ticks at default 10 each)
|
|
61
|
-
// After the catch-up settles, unset both with
|
|
62
|
-
// `supabase secrets unset RUMEN_LOOKBACK_HOURS_OVERRIDE
|
|
63
|
-
// RUMEN_MAX_SESSIONS_OVERRIDE`
|
|
64
|
-
// and the function reverts to the rumen-package defaults (72h / 10 sessions).
|
|
65
|
-
// Both gates fail closed: invalid integer string → ignored, default used.
|
|
66
|
-
const lookbackOverrideRaw = Deno.env.get('RUMEN_LOOKBACK_HOURS_OVERRIDE');
|
|
67
|
-
const maxSessionsOverrideRaw = Deno.env.get('RUMEN_MAX_SESSIONS_OVERRIDE');
|
|
68
|
-
const lookbackOverride = lookbackOverrideRaw && /^\d+$/.test(lookbackOverrideRaw)
|
|
69
|
-
? parseInt(lookbackOverrideRaw, 10)
|
|
70
|
-
: undefined;
|
|
71
|
-
const maxSessionsOverride = maxSessionsOverrideRaw && /^\d+$/.test(maxSessionsOverrideRaw)
|
|
72
|
-
? parseInt(maxSessionsOverrideRaw, 10)
|
|
73
|
-
: undefined;
|
|
74
|
-
if (lookbackOverride !== undefined || maxSessionsOverride !== undefined) {
|
|
75
|
-
console.log(
|
|
76
|
-
'[rumen] override active: lookbackHours=' +
|
|
77
|
-
(lookbackOverride ?? 'default') +
|
|
78
|
-
' maxSessions=' +
|
|
79
|
-
(maxSessionsOverride ?? 'default'),
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
55
|
try {
|
|
84
56
|
console.log('[rumen] edge function tick starting');
|
|
85
57
|
const summary = await runRumenJob(pool, {
|
|
86
58
|
triggeredBy: 'schedule',
|
|
87
|
-
...(lookbackOverride !== undefined ? { lookbackHours: lookbackOverride } : {}),
|
|
88
|
-
...(maxSessionsOverride !== undefined ? { maxSessions: maxSessionsOverride } : {}),
|
|
89
59
|
});
|
|
90
60
|
console.log(
|
|
91
61
|
'[rumen] edge function tick complete job_id=' +
|