@jhizzard/termdeck 0.6.7 → 0.6.9
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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"
|
|
@@ -43,7 +43,8 @@ const {
|
|
|
43
43
|
yaml,
|
|
44
44
|
supabaseUrl: urlHelper,
|
|
45
45
|
migrations,
|
|
46
|
-
pgRunner
|
|
46
|
+
pgRunner,
|
|
47
|
+
preconditions
|
|
47
48
|
} = require(SETUP_DIR);
|
|
48
49
|
|
|
49
50
|
const HELP = [
|
|
@@ -514,7 +515,19 @@ async function main(argv) {
|
|
|
514
515
|
await checkExistingStore(client);
|
|
515
516
|
await applyMigrations(client, false);
|
|
516
517
|
writeYamlConfig(false);
|
|
518
|
+
// v0.6.9: post-write outcome verification. Confirms each migration's
|
|
519
|
+
// expected schema bits actually landed — including memory_items.
|
|
520
|
+
// source_session_id (the v0.6.5 column whose absence cascaded into
|
|
521
|
+
// Brad's Rumen failures). This is the test that, if it had existed
|
|
522
|
+
// before v0.6.5, would have caught the silent-shadow saga at install
|
|
523
|
+
// time instead of cron-tick time.
|
|
517
524
|
if (!flags.skipVerify) {
|
|
525
|
+
const verify = await preconditions.verifyMnestraOutcomes({ secrets: { DATABASE_URL: inputs.databaseUrl }, _pgClient: client });
|
|
526
|
+
preconditions.printVerifyReport(verify, 'mnestra');
|
|
527
|
+
if (!verify.ok) {
|
|
528
|
+
printResumeHint();
|
|
529
|
+
return 8;
|
|
530
|
+
}
|
|
518
531
|
const verified = await verifyStatus(client);
|
|
519
532
|
if (!verified) {
|
|
520
533
|
process.stdout.write(
|
|
@@ -34,7 +34,8 @@ const {
|
|
|
34
34
|
dotenv,
|
|
35
35
|
supabaseUrl: urlHelper,
|
|
36
36
|
migrations,
|
|
37
|
-
pgRunner
|
|
37
|
+
pgRunner,
|
|
38
|
+
preconditions
|
|
38
39
|
} = require(SETUP_DIR);
|
|
39
40
|
|
|
40
41
|
// Pinned fallback used only when the npm registry is unreachable. Bump this
|
|
@@ -593,6 +594,18 @@ async function main(argv) {
|
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
// v0.6.9: front-loaded precondition audit. Runs BEFORE link so we don't
|
|
598
|
+
// create state (function deploy, function secrets, schedule SQL) that the
|
|
599
|
+
// user would have to manually clean up if a precondition is missing. Every
|
|
600
|
+
// gap is reported in one pass with actionable hints. The audit class — env
|
|
601
|
+
// tokens, pg extensions, Vault secret — covers the v0.6.4 / v0.6.6 / v0.6.7
|
|
602
|
+
// / v0.6.9-equivalent failure modes that previously surfaced one-per-patch.
|
|
603
|
+
if (!flags.dryRun) {
|
|
604
|
+
const audit = await preconditions.auditRumenPreconditions({ secrets, env: process.env });
|
|
605
|
+
preconditions.printAuditReport(audit, 'rumen');
|
|
606
|
+
if (!audit.ok) return 10;
|
|
607
|
+
}
|
|
608
|
+
|
|
596
609
|
if (!(await link(projectRef, flags.dryRun))) return 4;
|
|
597
610
|
|
|
598
611
|
// Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json now that
|
|
@@ -630,6 +643,15 @@ async function main(argv) {
|
|
|
630
643
|
if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
|
|
631
644
|
if (!flags.skipSchedule) {
|
|
632
645
|
if (!(await applySchedule(projectRef, secrets, flags.dryRun))) return 9;
|
|
646
|
+
// v0.6.9: post-write outcome verification. Confirms cron.job has the
|
|
647
|
+
// active rumen-tick row. Doesn't poll for the first 15-min tick — that's
|
|
648
|
+
// too long for an interactive wizard — but tells the user the exact
|
|
649
|
+
// query to run after waiting if they want firing-confirmation.
|
|
650
|
+
if (!flags.dryRun) {
|
|
651
|
+
const verify = await preconditions.verifyRumenOutcomes({ secrets });
|
|
652
|
+
preconditions.printVerifyReport(verify, 'rumen');
|
|
653
|
+
if (!verify.ok) return 11;
|
|
654
|
+
}
|
|
633
655
|
} else {
|
|
634
656
|
process.stdout.write('→ Skipping pg_cron schedule (per --skip-schedule) ✓\n');
|
|
635
657
|
}
|
|
@@ -11,5 +11,6 @@ module.exports = {
|
|
|
11
11
|
supabaseUrl: require('./supabase-url'),
|
|
12
12
|
migrations: require('./migrations'),
|
|
13
13
|
pgRunner: require('./pg-runner'),
|
|
14
|
-
migrationRunner: require('./migration-runner')
|
|
14
|
+
migrationRunner: require('./migration-runner'),
|
|
15
|
+
preconditions: require('./preconditions')
|
|
15
16
|
};
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
// Discover the SQL migration files that ship bundled inside the TermDeck
|
|
2
|
-
// package. Both init wizards call this — init-mnestra for the
|
|
2
|
+
// package. Both init wizards call this — init-mnestra for the seven Mnestra
|
|
3
3
|
// migrations, init-rumen for the two Rumen migrations.
|
|
4
4
|
//
|
|
5
5
|
// The wizards intentionally do NOT fall back to a sibling `../../mnestra`
|
|
6
|
-
// working copy. Resolution order:
|
|
6
|
+
// working copy. Resolution order (BUNDLED FIRST as of v0.6.8):
|
|
7
7
|
//
|
|
8
8
|
// 1. Files bundled at `packages/server/src/setup/mnestra-migrations/*.sql`
|
|
9
9
|
// (this directory is covered by the root package.json `files` glob).
|
|
10
|
+
// ALWAYS preferred when it has any .sql files.
|
|
10
11
|
// 2. Files at `node_modules/@jhizzard/mnestra/migrations/*.sql` if that
|
|
11
|
-
// package is installed alongside TermDeck
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// package is installed alongside TermDeck. Used ONLY as a fallback when
|
|
13
|
+
// the bundled directory is missing (e.g. someone deleted it manually).
|
|
14
|
+
//
|
|
15
|
+
// Why bundled-first: the meta-installer (`@jhizzard/termdeck-stack`) installs
|
|
16
|
+
// `@jhizzard/mnestra` globally as a peer. When TermDeck releases a new
|
|
17
|
+
// migration ahead of a Mnestra release, or when a user upgrades TermDeck
|
|
18
|
+
// without also upgrading the global Mnestra package, the previous loader
|
|
19
|
+
// silently picked the older Mnestra migration set. This bit Brad on
|
|
20
|
+
// 2026-04-26 with v0.6.5: he upgraded TermDeck, ran `init --mnestra --yes`,
|
|
21
|
+
// the wizard reported "6 migrations applied cleanly" (because his global
|
|
22
|
+
// mnestra@0.2.1 had only 6), and the bundled 007 — the one we shipped to
|
|
23
|
+
// fix his Rumen schema-drift issue — was never seen. Bundled is the source
|
|
24
|
+
// of truth TermDeck developed and tested against. Fall-back to node_modules
|
|
25
|
+
// is preserved as a safety valve, not a preference.
|
|
14
26
|
|
|
15
27
|
const fs = require('fs');
|
|
16
28
|
const path = require('path');
|
|
@@ -45,15 +57,20 @@ function tryNodeModules(packageName, migrationSubdir = 'migrations') {
|
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
function listMnestraMigrations() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
// Bundled FIRST (v0.6.8+). See the file header for why — this prevents
|
|
61
|
+
// a stale `@jhizzard/mnestra` install in global node_modules from
|
|
62
|
+
// silently shadowing migrations TermDeck ships with the latest version.
|
|
63
|
+
const bundled = listBundled('mnestra-migrations');
|
|
64
|
+
if (bundled.length > 0) return bundled;
|
|
65
|
+
return tryNodeModules('@jhizzard/mnestra');
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
function listRumenMigrations() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
// Bundled FIRST (v0.6.8+). Same rationale as listMnestraMigrations —
|
|
70
|
+
// a stale global `@jhizzard/rumen` cannot shadow newer bundled migrations.
|
|
71
|
+
const bundled = listBundled(path.join('rumen', 'migrations'));
|
|
72
|
+
if (bundled.length > 0) return bundled;
|
|
73
|
+
return tryNodeModules('@jhizzard/rumen');
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
function rumenFunctionDir() {
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Front-loaded precondition audits and post-write outcome verifications for
|
|
2
|
+
// the `termdeck init --mnestra` and `termdeck init --rumen` wizards.
|
|
3
|
+
//
|
|
4
|
+
// Why this module exists (v0.6.9)
|
|
5
|
+
// ───────────────────────────────
|
|
6
|
+
// The v0.6.x lineage shipped 8 patch releases in 48 hours. Four of them —
|
|
7
|
+
// v0.6.4 (SUPABASE_ACCESS_TOKEN), v0.6.6 (pgbouncer params), v0.6.7
|
|
8
|
+
// (mcp.json placeholder), and what would have been v0.6.9 (pg_cron +
|
|
9
|
+
// pg_net extensions, Vault secret) — were the SAME failure mode: a
|
|
10
|
+
// precondition was DOCUMENTED in GETTING-STARTED.md or a migration-file
|
|
11
|
+
// header but not VERIFIED in code. Each unsupervised user (Brad) hit
|
|
12
|
+
// them sequentially because there was no audit step at the start of the
|
|
13
|
+
// wizard. Documentation is not verification.
|
|
14
|
+
//
|
|
15
|
+
// Shape of the defense
|
|
16
|
+
// ────────────────────
|
|
17
|
+
// `auditRumenPreconditions()` runs FIRST in init-rumen, before any
|
|
18
|
+
// state-changing operation. It collects EVERY external precondition gap
|
|
19
|
+
// in a single pass — supabase CLI auth, pg_cron + pg_net extensions,
|
|
20
|
+
// Vault secret presence — and returns a structured `{ ok, gaps[] }`.
|
|
21
|
+
// Callers that see `ok=false` print the gaps with actionable hints and
|
|
22
|
+
// refuse to proceed. No partial work, no half-applied state.
|
|
23
|
+
//
|
|
24
|
+
// `verifyRumenOutcomes()` runs LAST after the schedule SQL applies. It
|
|
25
|
+
// confirms `cron.job` has an active rumen-tick row. Doesn't poll for the
|
|
26
|
+
// first 15-min tick (too long for an interactive wizard) — but tells the
|
|
27
|
+
// user the exact query to run after waiting if they want to confirm
|
|
28
|
+
// firing.
|
|
29
|
+
//
|
|
30
|
+
// `verifyMnestraOutcomes()` runs after migrations apply and confirms
|
|
31
|
+
// the column we shipped in v0.6.5 (`source_session_id`) actually landed.
|
|
32
|
+
// This is the test that — if it had existed — would have caught Brad's
|
|
33
|
+
// v0.6.5/v0.6.8 saga at install time instead of pg_cron-tick time.
|
|
34
|
+
//
|
|
35
|
+
// All async, all defensive: any unexpected error is captured into a gap
|
|
36
|
+
// rather than thrown, so the audit always returns a complete picture.
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const { spawnSync } = require('child_process');
|
|
41
|
+
const pgRunner = require('./pg-runner');
|
|
42
|
+
|
|
43
|
+
// Render a single gap into 2-3 lines of CLI output (one indented hint per
|
|
44
|
+
// non-empty `hint` line). Format aligned with the rest of the wizard's
|
|
45
|
+
// step lines.
|
|
46
|
+
function printGap(gap, index) {
|
|
47
|
+
process.stdout.write(` ${index + 1}. ✗ ${gap.message}\n`);
|
|
48
|
+
if (gap.hint) {
|
|
49
|
+
for (const line of gap.hint.split('\n')) {
|
|
50
|
+
if (line.trim().length > 0) {
|
|
51
|
+
process.stdout.write(` ${line}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Print the audit report. Returns no value — the caller decides what to
|
|
59
|
+
// do with `result.ok`.
|
|
60
|
+
function printAuditReport(result, context) {
|
|
61
|
+
if (result.ok) {
|
|
62
|
+
process.stdout.write(`→ Auditing ${context} preconditions... ✓\n`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write(`\n→ Auditing ${context} preconditions... ✗\n\n`);
|
|
66
|
+
process.stdout.write(`${result.gaps.length} precondition${result.gaps.length === 1 ? '' : 's'} failed:\n\n`);
|
|
67
|
+
result.gaps.forEach((g, i) => printGap(g, i));
|
|
68
|
+
process.stdout.write(
|
|
69
|
+
`Fix the items above and re-run \`termdeck init --${context}\`. The wizard ` +
|
|
70
|
+
`will not proceed; it would create state you'd have to manually clean up.\n\n`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Same structure for outcome verification — same { ok, gaps[] } shape so
|
|
75
|
+
// callers don't branch on which kind of report they're handling.
|
|
76
|
+
function printVerifyReport(result, context) {
|
|
77
|
+
if (result.ok) {
|
|
78
|
+
process.stdout.write(`→ Verifying ${context} outcomes... ✓\n`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
process.stdout.write(`\n→ Verifying ${context} outcomes... ✗\n\n`);
|
|
82
|
+
process.stdout.write(`${result.gaps.length} expected outcome${result.gaps.length === 1 ? '' : 's'} not found:\n\n`);
|
|
83
|
+
result.gaps.forEach((g, i) => printGap(g, i));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Rumen precondition audit ────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
// Probe the supabase CLI's auth state without running `link`. If the user
|
|
89
|
+
// has run `supabase login` previously, `supabase projects list` succeeds.
|
|
90
|
+
// If they have SUPABASE_ACCESS_TOKEN in env, same. If neither, this exits
|
|
91
|
+
// non-zero and we surface the gap.
|
|
92
|
+
function probeSupabaseAuth() {
|
|
93
|
+
// Cheap, network-bound, ~1-2s. Capture both streams so we can inspect
|
|
94
|
+
// for the "Access token not provided" signal.
|
|
95
|
+
const r = spawnSync('supabase', ['projects', 'list', '--output', 'env'], {
|
|
96
|
+
encoding: 'utf-8',
|
|
97
|
+
timeout: 15000
|
|
98
|
+
});
|
|
99
|
+
return { ok: r.status === 0, stderr: r.stderr || '' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Run all Rumen preconditions in parallel where independent. Returns
|
|
103
|
+
// `{ ok: boolean, gaps: [{ key, message, hint }] }`.
|
|
104
|
+
//
|
|
105
|
+
// Inputs:
|
|
106
|
+
// - secrets: dotenv-loaded ~/.termdeck/secrets.env (must have DATABASE_URL)
|
|
107
|
+
// - env: process.env or test fixture
|
|
108
|
+
//
|
|
109
|
+
// Optional `_pgClient` injection lets tests substitute a fake client; in
|
|
110
|
+
// production we open one and close it at the end of the audit.
|
|
111
|
+
async function auditRumenPreconditions({ secrets, env, _pgClient } = {}) {
|
|
112
|
+
const gaps = [];
|
|
113
|
+
|
|
114
|
+
// 1. Supabase CLI auth — sync; doesn't need pg.
|
|
115
|
+
if (!env || !env.SUPABASE_ACCESS_TOKEN) {
|
|
116
|
+
const probe = probeSupabaseAuth();
|
|
117
|
+
if (!probe.ok) {
|
|
118
|
+
gaps.push({
|
|
119
|
+
key: 'SUPABASE_ACCESS_TOKEN',
|
|
120
|
+
message: 'Supabase CLI is not authenticated and no SUPABASE_ACCESS_TOKEN in environment',
|
|
121
|
+
hint:
|
|
122
|
+
'Generate a Personal Access Token:\n' +
|
|
123
|
+
' https://supabase.com/dashboard/account/tokens\n' +
|
|
124
|
+
'Then export it in this shell:\n' +
|
|
125
|
+
' export SUPABASE_ACCESS_TOKEN=sbp_...\n' +
|
|
126
|
+
'(`supabase login` works on desktops but opens a browser; not viable over SSH.)'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2-4. DB-side checks — open one connection, run them sequentially,
|
|
132
|
+
// close it at the end. Errors get captured per-check so a single
|
|
133
|
+
// flaky query doesn't blank the whole audit.
|
|
134
|
+
const client = _pgClient || (await safeConnect(secrets && secrets.DATABASE_URL));
|
|
135
|
+
if (!client) {
|
|
136
|
+
gaps.push({
|
|
137
|
+
key: 'DATABASE_URL',
|
|
138
|
+
message: 'Could not connect to Postgres using DATABASE_URL from ~/.termdeck/secrets.env',
|
|
139
|
+
hint:
|
|
140
|
+
'Verify the URL is reachable from this host:\n' +
|
|
141
|
+
' psql "$DATABASE_URL" -c "SELECT 1;"\n' +
|
|
142
|
+
'If the connection is fine but you see another error, copy the wizard output and report it.'
|
|
143
|
+
});
|
|
144
|
+
return { ok: gaps.length === 0, gaps };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// pg_cron extension
|
|
149
|
+
const cron = await safeQuery(client,
|
|
150
|
+
"SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_cron'");
|
|
151
|
+
if (!cron.ok) {
|
|
152
|
+
gaps.push({
|
|
153
|
+
key: 'pg_cron',
|
|
154
|
+
message: 'The pg_cron extension is not enabled on this Supabase project',
|
|
155
|
+
hint:
|
|
156
|
+
'Enable it in the Supabase dashboard:\n' +
|
|
157
|
+
' Database → Extensions → pg_cron → toggle ON\n' +
|
|
158
|
+
'(Without pg_cron, the rumen-tick schedule cannot run.)'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// pg_net extension
|
|
163
|
+
const net = await safeQuery(client,
|
|
164
|
+
"SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_net'");
|
|
165
|
+
if (!net.ok) {
|
|
166
|
+
gaps.push({
|
|
167
|
+
key: 'pg_net',
|
|
168
|
+
message: 'The pg_net extension is not enabled on this Supabase project',
|
|
169
|
+
hint:
|
|
170
|
+
'Enable it in the Supabase dashboard:\n' +
|
|
171
|
+
' Database → Extensions → pg_net → toggle ON\n' +
|
|
172
|
+
'(pg_net is what the cron schedule uses to call the Edge Function.)'
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Vault secret rumen_service_role_key — accessing vault.decrypted_secrets
|
|
177
|
+
// requires service_role privileges. Distinguish "no row" from "permission
|
|
178
|
+
// denied" so the hint is actionable.
|
|
179
|
+
const vault = await safeQuery(client,
|
|
180
|
+
"SELECT 1 AS ok FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key'");
|
|
181
|
+
if (vault.error) {
|
|
182
|
+
gaps.push({
|
|
183
|
+
key: 'vault.decrypted_secrets',
|
|
184
|
+
message: `Cannot read vault.decrypted_secrets: ${vault.error}`,
|
|
185
|
+
hint:
|
|
186
|
+
'Verify your DATABASE_URL is using the service_role connection (port 6543 + service_role auth).\n' +
|
|
187
|
+
'If permission is denied, the Vault is not accessible to this connection — double-check secrets.env.'
|
|
188
|
+
});
|
|
189
|
+
} else if (!vault.ok) {
|
|
190
|
+
gaps.push({
|
|
191
|
+
key: 'rumen_service_role_key',
|
|
192
|
+
message: 'Vault secret "rumen_service_role_key" is missing',
|
|
193
|
+
hint:
|
|
194
|
+
'Create it in the Supabase dashboard:\n' +
|
|
195
|
+
' Project Settings → Vault → New secret\n' +
|
|
196
|
+
' Name: rumen_service_role_key (exact, case-sensitive)\n' +
|
|
197
|
+
' Value: your service_role key from Project Settings → API\n' +
|
|
198
|
+
'(The pg_cron schedule calls the Edge Function with this key as the bearer token.)'
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
if (!_pgClient) {
|
|
203
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { ok: gaps.length === 0, gaps };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Rumen outcome verification ──────────────────────────────────────────────
|
|
211
|
+
//
|
|
212
|
+
// Runs after the pg_cron schedule SQL applies. Confirms the row is in
|
|
213
|
+
// cron.job and active. Doesn't wait for first run (15 min is too long
|
|
214
|
+
// to block an interactive wizard) — instead tells the user the query to
|
|
215
|
+
// run after waiting if they want firing-confirmation.
|
|
216
|
+
|
|
217
|
+
async function verifyRumenOutcomes({ secrets, _pgClient } = {}) {
|
|
218
|
+
const gaps = [];
|
|
219
|
+
const client = _pgClient || (await safeConnect(secrets && secrets.DATABASE_URL));
|
|
220
|
+
if (!client) {
|
|
221
|
+
gaps.push({
|
|
222
|
+
key: 'DATABASE_URL',
|
|
223
|
+
message: 'Could not reconnect to Postgres to verify the schedule landed',
|
|
224
|
+
hint: 'Re-run `termdeck init --rumen` once connectivity is restored.'
|
|
225
|
+
});
|
|
226
|
+
return { ok: false, gaps };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const job = await safeQuery(client,
|
|
231
|
+
"SELECT active FROM cron.job WHERE jobname = 'rumen-tick'",
|
|
232
|
+
{ wantRows: true });
|
|
233
|
+
if (job.error) {
|
|
234
|
+
gaps.push({
|
|
235
|
+
key: 'cron.job',
|
|
236
|
+
message: `Cannot read cron.job: ${job.error}`,
|
|
237
|
+
hint: 'pg_cron may have been disabled after the schedule was applied. Re-enable it and re-run the wizard.'
|
|
238
|
+
});
|
|
239
|
+
} else if (!job.rows || job.rows.length === 0) {
|
|
240
|
+
gaps.push({
|
|
241
|
+
key: 'cron.job',
|
|
242
|
+
message: 'Schedule was applied but cron.job has no rumen-tick row',
|
|
243
|
+
hint:
|
|
244
|
+
'This usually means the SELECT cron.schedule(...) call returned NULL. Re-run `termdeck init --rumen` ' +
|
|
245
|
+
'or apply migrations/002_pg_cron_schedule.sql manually to investigate.'
|
|
246
|
+
});
|
|
247
|
+
} else if (!job.rows[0].active) {
|
|
248
|
+
gaps.push({
|
|
249
|
+
key: 'cron.job.active',
|
|
250
|
+
message: 'rumen-tick exists but is paused (active=false)',
|
|
251
|
+
hint:
|
|
252
|
+
'Resume it with:\n' +
|
|
253
|
+
" SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'rumen-tick'), active := true);"
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
if (!_pgClient) {
|
|
258
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { ok: gaps.length === 0, gaps };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Mnestra outcome verification ────────────────────────────────────────────
|
|
266
|
+
//
|
|
267
|
+
// After the 7 migrations apply, confirm:
|
|
268
|
+
// - memory_items table exists
|
|
269
|
+
// - source_session_id column exists on it (the v0.6.5 fix actually landed)
|
|
270
|
+
// - memory_status_aggregation function exists
|
|
271
|
+
//
|
|
272
|
+
// This is the test that would have caught the v0.6.5 / v0.6.8 silent-shadow
|
|
273
|
+
// saga at install time instead of cron-tick time.
|
|
274
|
+
|
|
275
|
+
async function verifyMnestraOutcomes({ secrets, _pgClient } = {}) {
|
|
276
|
+
const gaps = [];
|
|
277
|
+
const client = _pgClient || (await safeConnect(secrets && secrets.DATABASE_URL));
|
|
278
|
+
if (!client) {
|
|
279
|
+
gaps.push({
|
|
280
|
+
key: 'DATABASE_URL',
|
|
281
|
+
message: 'Could not reconnect to Postgres to verify migrations landed',
|
|
282
|
+
hint: 'This is unexpected — the migrations just ran. Check connectivity and re-run with --yes.'
|
|
283
|
+
});
|
|
284
|
+
return { ok: false, gaps };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
// memory_items table
|
|
289
|
+
const tbl = await safeQuery(client,
|
|
290
|
+
"SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'memory_items'");
|
|
291
|
+
if (!tbl.ok) {
|
|
292
|
+
gaps.push({
|
|
293
|
+
key: 'memory_items',
|
|
294
|
+
message: 'memory_items table is missing after migrations',
|
|
295
|
+
hint: 'Migration 001 should have created it. Re-run with --reset to start fresh, or report a bug.'
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
// source_session_id column (v0.6.5 fix)
|
|
299
|
+
const col = await safeQuery(client,
|
|
300
|
+
"SELECT 1 AS ok FROM information_schema.columns " +
|
|
301
|
+
"WHERE table_schema = 'public' AND table_name = 'memory_items' AND column_name = 'source_session_id'");
|
|
302
|
+
if (!col.ok) {
|
|
303
|
+
gaps.push({
|
|
304
|
+
key: 'memory_items.source_session_id',
|
|
305
|
+
message: 'memory_items.source_session_id column is missing — Rumen will fail',
|
|
306
|
+
hint:
|
|
307
|
+
'Migration 007 should have added it. If you see this, the migration loader picked up a stale\n' +
|
|
308
|
+
'set — upgrade with: npm cache clean --force && npm i -g @jhizzard/termdeck@latest\n' +
|
|
309
|
+
'then re-run `termdeck init --mnestra --yes`.'
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// memory_status_aggregation RPC
|
|
315
|
+
const rpc = await safeQuery(client,
|
|
316
|
+
"SELECT 1 AS ok FROM pg_proc WHERE proname = 'memory_status_aggregation'");
|
|
317
|
+
if (!rpc.ok) {
|
|
318
|
+
gaps.push({
|
|
319
|
+
key: 'memory_status_aggregation',
|
|
320
|
+
message: 'memory_status_aggregation() function is missing',
|
|
321
|
+
hint: 'Migration 006 should have created it. The wizard\'s status check will fall back to client-side aggregation, but that hits PostgREST row caps.'
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
if (!_pgClient) {
|
|
326
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { ok: gaps.length === 0, gaps };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async function safeConnect(databaseUrl) {
|
|
336
|
+
if (!databaseUrl) return null;
|
|
337
|
+
try {
|
|
338
|
+
return await pgRunner.connect(databaseUrl);
|
|
339
|
+
} catch (_err) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Run a query that returns at most one row. Returns:
|
|
345
|
+
// { ok: true } when the row exists / value is truthy
|
|
346
|
+
// { ok: false } when no rows or the value is falsy
|
|
347
|
+
// { error: string } on query failure
|
|
348
|
+
//
|
|
349
|
+
// `wantRows: true` returns the rows array instead of just an ok bit.
|
|
350
|
+
async function safeQuery(client, sql, opts = {}) {
|
|
351
|
+
try {
|
|
352
|
+
const r = await client.query(sql);
|
|
353
|
+
if (opts.wantRows) return { rows: r.rows };
|
|
354
|
+
if (r.rows && r.rows.length > 0 && r.rows[0].ok) return { ok: true };
|
|
355
|
+
return { ok: false };
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
auditRumenPreconditions,
|
|
363
|
+
verifyRumenOutcomes,
|
|
364
|
+
verifyMnestraOutcomes,
|
|
365
|
+
printAuditReport,
|
|
366
|
+
printVerifyReport,
|
|
367
|
+
// Test surface
|
|
368
|
+
_probeSupabaseAuth: probeSupabaseAuth,
|
|
369
|
+
_safeQuery: safeQuery
|
|
370
|
+
};
|