@jhizzard/termdeck 1.9.0 → 1.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "1.9.0",
3
+ "version": "1.10.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"
@@ -346,6 +346,16 @@ async function _runSchemaCheck(opts = {}) {
346
346
  let client = optsObj._pgClient || null;
347
347
  let ownsClient = false;
348
348
  if (!client) {
349
+ // Sprint 75 T2 (part C): classify + warn BEFORE the connect attempt —
350
+ // a direct-endpoint URL on an IPv4-only host doesn't fail fast, it
351
+ // hangs until a pool timeout, so the warning must print first.
352
+ // Warn-only; fail-soft if the helper is unavailable.
353
+ try {
354
+ const urlHelper = require(path.join(SETUP_DIR, 'supabase-url'));
355
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(secrets.DATABASE_URL))) {
356
+ process.stdout.write(` ${line}\n`);
357
+ }
358
+ } catch (_e) { /* warn-only — never block the doctor pass */ }
349
359
  try {
350
360
  client = await pgRunner.connect(secrets.DATABASE_URL);
351
361
  ownsClient = true;
@@ -524,6 +534,7 @@ function renderSchemaResult(result, c) {
524
534
  if (result.connectError) {
525
535
  out.push(` ${c.yellow('✗')} could not connect: ${result.connectError}`);
526
536
  out.push(` ${c.dim('Check DATABASE_URL in ~/.termdeck/secrets.env, then re-run.')}`);
537
+ out.push(` ${c.dim('If this host is IPv4-only and the URL is the db.<project-ref> direct endpoint, that is the cause — switch to the Shared Pooler.')}`);
527
538
  return out.join('\n');
528
539
  }
529
540
  for (const section of result.sections) {
@@ -72,8 +72,9 @@ const HELP = [
72
72
  ' --skip-verify Skip the final memory_status_aggregation() sanity call',
73
73
  '',
74
74
  'What this does:',
75
- ' 1. Prompts for Supabase URL, service_role key, direct Postgres connection',
76
- ' string, OpenAI API key, and (optional) Anthropic API key — or reuses',
75
+ ' 1. Prompts for Supabase URL, service_role key, Postgres connection',
76
+ ' string (Shared Pooler; IPv4-safe), OpenAI API key, and (optional)',
77
+ ' Anthropic API key — or reuses',
77
78
  ' saved values if a complete set already exists in secrets.env.',
78
79
  ' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
79
80
  ' pg connect or migration failure does not lose what you typed in.',
@@ -157,6 +158,11 @@ function inputsFromEnv() {
157
158
 
158
159
  const dbErr = urlHelper.looksLikePostgresUrl(required.DATABASE_URL);
159
160
  if (dbErr) throw new Error(`DATABASE_URL: ${dbErr}`);
161
+ // Sprint 75 T2 (part B): warn-only — a direct-endpoint URL passes
162
+ // validation (warn ≠ reject) but gets the IPv4 trap warning printed once.
163
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(required.DATABASE_URL))) {
164
+ process.stdout.write(` ${line}\n`);
165
+ }
160
166
 
161
167
  const srErr = urlHelper.looksLikeServiceRole(required.SUPABASE_SERVICE_ROLE_KEY);
162
168
  if (srErr) throw new Error(`SUPABASE_SERVICE_ROLE_KEY: ${srErr}`);
@@ -188,7 +194,7 @@ TermDeck Mnestra Setup
188
194
 
189
195
  This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
190
196
  1. Asking for your Supabase URL and service_role key
191
- 2. Asking for a direct Postgres connection string
197
+ 2. Asking for a Postgres connection string (Shared Pooler)
192
198
  3. Asking for an OpenAI API key (embeddings)
193
199
  4. Asking for an Anthropic API key (optional, summaries)
194
200
  5. Writing ~/.termdeck/secrets.env (before any database work, so a
@@ -253,6 +259,12 @@ async function collectInputs({ yes, reset }) {
253
259
  process.stdout.write(
254
260
  `Found saved secrets in ~/.termdeck/secrets.env (project ${ref}, db ${masked}).\n`
255
261
  );
262
+ // Sprint 75 T2 (part B): highest-value warn site — an operator whose
263
+ // EARLIER install stored a direct-endpoint URL (the Brad case) would
264
+ // otherwise sail through reuse with zero feedback. Warn-only.
265
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(found.inputs.databaseUrl))) {
266
+ process.stdout.write(` ${line}\n`);
267
+ }
256
268
  const reuse = yes ? true : await prompts.confirm(' Reuse saved secrets?', { defaultYes: true });
257
269
  if (reuse) {
258
270
  process.stdout.write(' Reusing saved secrets. Skipping prompts.\n\n');
@@ -284,11 +296,19 @@ async function collectInputs({ yes, reset }) {
284
296
  );
285
297
 
286
298
  process.stdout.write(
287
- '? Direct Postgres connection string\n' +
288
- ` (Supabase dashboard → Project Settings Database Connection String → Transaction pooler)\n` +
289
- ' postgres://postgres.REF:PW@... '
299
+ '? Postgres connection string (Shared Pooler)\n' +
300
+ ' (Supabase dashboard → Connect (green button) → Transaction pooler →\n' +
301
+ ' toggle ON "Use IPv4 connection (Shared Pooler)" — the OFF default shows an\n' +
302
+ ' IPv6-only URL that hangs on IPv4-only hosts)\n' +
303
+ ' postgres://postgres.<project-ref>:PW@aws-<n>-<region>.pooler.supabase.com:6543/postgres '
290
304
  );
291
305
  const databaseUrl = await promptSecretWithValidation(urlHelper.looksLikePostgresUrl);
306
+ // Sprint 75 T2 (part B): warn-only endpoint-shape feedback. The validator
307
+ // above ACCEPTS direct URLs (IPv6-capable hosts use them legitimately);
308
+ // this prints the IPv4 trap warning without changing acceptance.
309
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(databaseUrl))) {
310
+ process.stdout.write(` ${line}\n`);
311
+ }
292
312
 
293
313
  process.stdout.write('? OpenAI API key (starts sk-proj- or sk-): ');
294
314
  const openaiKey = await promptSecretWithValidation(urlHelper.looksLikeOpenAiKey);
@@ -713,13 +733,34 @@ function refreshBundledHookIfNewer(opts = {}) {
713
733
  // actually run after upgrading the package.
714
734
 
715
735
  const SETTINGS_JSON_PATH = path.join(require('os').homedir(), '.claude', 'settings.json');
716
- const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
736
+
737
+ // Sprint 75 T2 — hook commands are written into ~/.claude/settings.json with
738
+ // ABSOLUTE paths. The pre-1.10 literal `node ~/.claude/hooks/...` shape relied
739
+ // on shell tilde expansion — it worked on macOS/Linux only by luck of how the
740
+ // harness invokes hook commands, and is a hard break on Windows (audit item 4).
741
+ // Computed at CALL time (not require time) from os.homedir() so a process that
742
+ // re-points HOME (tests, sandboxed installs) gets the right path. The path is
743
+ // double-quoted so a home dir containing spaces (`/Users/First Last/`) still
744
+ // produces a command the harness shell can execute. Lockstep twin lives in
745
+ // packages/stack-installer/src/index.js (`_hookCommandFor`) — INSTALLER-
746
+ // PITFALLS Class N: change both or neither.
747
+ function _hookCommandFor(filename) {
748
+ return `node "${path.join(require('os').homedir(), '.claude', 'hooks', filename)}"`;
749
+ }
750
+
751
+ // True when an entry's command still carries the legacy tilde shape and
752
+ // should be rewritten to the absolute form.
753
+ function _isTildeHookCommand(command) {
754
+ return typeof command === 'string' && command.includes('~/');
755
+ }
756
+
757
+ const HOOK_COMMAND = _hookCommandFor('memory-session-end.js');
717
758
  const HOOK_TIMEOUT_SECONDS = 30;
718
759
 
719
760
  // Sprint 64 T3 — PreCompact hook (Investigation 2 of CRITICAL-READ-FIRST-
720
761
  // 2026-05-07.md). Lives alongside the SessionEnd hook; refreshes via the
721
762
  // same Sprint 51.6 T3 version-stamp gate.
722
- const PRECOMPACT_HOOK_COMMAND = 'node ~/.claude/hooks/memory-pre-compact.js';
763
+ const PRECOMPACT_HOOK_COMMAND = _hookCommandFor('memory-pre-compact.js');
723
764
  const PRECOMPACT_HOOK_TIMEOUT_SECONDS = 30;
724
765
 
725
766
  function _isSessionEndHookEntry(entry) {
@@ -734,7 +775,8 @@ function _isSessionEndHookEntry(entry) {
734
775
  // `packages/stack-installer/src/index.js:451` byte-for-byte (modulo
735
776
  // constants pulled from this file's scope).
736
777
  function _mergeSessionEndHookEntry(settings, opts = {}) {
737
- const command = opts.command || HOOK_COMMAND;
778
+ // Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
779
+ const command = opts.command || _hookCommandFor('memory-session-end.js');
738
780
  const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
739
781
  const entry = { type: 'command', command, timeout };
740
782
 
@@ -759,10 +801,29 @@ function _mergeSessionEndHookEntry(settings, opts = {}) {
759
801
 
760
802
  if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
761
803
 
804
+ // Sprint 75 T2 — rewrite a stale literal-`~` command (written by installers
805
+ // ≤ v1.9.x) to the absolute form. The "already wired?" predicate matches by
806
+ // hook FILENAME substring, so without this rewrite a legacy entry would be
807
+ // reported already-installed and keep its `~` forever. Idempotent: absolute
808
+ // commands (and user-custom commands without `~/`) are never touched.
809
+ let tildeMigrated = false;
810
+ for (const group of settings.hooks.SessionEnd) {
811
+ if (!group || !Array.isArray(group.hooks)) continue;
812
+ for (const e of group.hooks) {
813
+ if (_isSessionEndHookEntry(e) && _isTildeHookCommand(e.command)) {
814
+ e.command = command;
815
+ tildeMigrated = true;
816
+ }
817
+ }
818
+ }
819
+
762
820
  for (const group of settings.hooks.SessionEnd) {
763
821
  if (!group || !Array.isArray(group.hooks)) continue;
764
822
  if (group.hooks.some(_isSessionEndHookEntry)) {
765
- return { settings, status: migrated ? 'migrated-from-stop' : 'already-installed' };
823
+ const status = tildeMigrated ? 'migrated-tilde-path'
824
+ : migrated ? 'migrated-from-stop'
825
+ : 'already-installed';
826
+ return { settings, status };
766
827
  }
767
828
  }
768
829
 
@@ -788,17 +849,30 @@ function _isPreCompactHookEntry(entry) {
788
849
  }
789
850
 
790
851
  function _mergePreCompactHookEntry(settings, opts = {}) {
791
- const command = opts.command || PRECOMPACT_HOOK_COMMAND;
852
+ // Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
853
+ const command = opts.command || _hookCommandFor('memory-pre-compact.js');
792
854
  const timeout = opts.timeout != null ? opts.timeout : PRECOMPACT_HOOK_TIMEOUT_SECONDS;
793
855
  const entry = { type: 'command', command, timeout };
794
856
 
795
857
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
796
858
  if (!Array.isArray(settings.hooks.PreCompact)) settings.hooks.PreCompact = [];
797
859
 
860
+ // Sprint 75 T2 — same stale literal-`~` rewrite as the SessionEnd merge.
861
+ let tildeMigrated = false;
862
+ for (const group of settings.hooks.PreCompact) {
863
+ if (!group || !Array.isArray(group.hooks)) continue;
864
+ for (const e of group.hooks) {
865
+ if (_isPreCompactHookEntry(e) && _isTildeHookCommand(e.command)) {
866
+ e.command = command;
867
+ tildeMigrated = true;
868
+ }
869
+ }
870
+ }
871
+
798
872
  for (const group of settings.hooks.PreCompact) {
799
873
  if (!group || !Array.isArray(group.hooks)) continue;
800
874
  if (group.hooks.some(_isPreCompactHookEntry)) {
801
- return { settings, status: 'already-installed' };
875
+ return { settings, status: tildeMigrated ? 'migrated-tilde-path' : 'already-installed' };
802
876
  }
803
877
  }
804
878
 
@@ -934,10 +1008,14 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
934
1008
  ok(r.backup ? `installed (SessionEnd; backup: ${path.basename(r.backup)})` : 'installed (SessionEnd)');
935
1009
  } else if (r.status === 'migrated-from-stop') {
936
1010
  ok(r.backup ? `migrated Stop → SessionEnd (was firing on every turn; backup: ${path.basename(r.backup)})` : 'migrated Stop → SessionEnd (was firing on every turn)');
1011
+ } else if (r.status === 'migrated-tilde-path') {
1012
+ ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
937
1013
  } else if (r.status === 'would-installed') {
938
1014
  ok('would install (SessionEnd) (dry-run)');
939
1015
  } else if (r.status === 'would-migrated-from-stop') {
940
1016
  ok('would migrate Stop → SessionEnd (dry-run)');
1017
+ } else if (r.status === 'would-migrated-tilde-path') {
1018
+ ok('would rewrite legacy ~ command to absolute path (dry-run)');
941
1019
  } else if (r.status === 'malformed') {
942
1020
  ok(`(skipped: settings.json malformed: ${r.error})`);
943
1021
  } else {
@@ -964,8 +1042,12 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
964
1042
  ok('already wired (PreCompact)');
965
1043
  } else if (r.status === 'installed') {
966
1044
  ok(r.backup ? `installed (PreCompact; backup: ${path.basename(r.backup)})` : 'installed (PreCompact)');
1045
+ } else if (r.status === 'migrated-tilde-path') {
1046
+ ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
967
1047
  } else if (r.status === 'would-installed') {
968
1048
  ok('would install (PreCompact) (dry-run)');
1049
+ } else if (r.status === 'would-migrated-tilde-path') {
1050
+ ok('would rewrite legacy ~ command to absolute path (dry-run)');
969
1051
  } else if (r.status === 'malformed') {
970
1052
  ok(`(skipped: settings.json malformed: ${r.error})`);
971
1053
  } else {
@@ -1162,7 +1244,10 @@ async function main(argv) {
1162
1244
  } catch (err) {
1163
1245
  fail(err.message);
1164
1246
  process.stderr.write(
1165
- '\nDouble-check the connection string from Supabase → Project Settings Database Connection String.\n'
1247
+ '\nDouble-check the connection string: Supabase dashboard ConnectTransaction pooler →\n' +
1248
+ 'toggle ON "Use IPv4 connection (Shared Pooler)". If the connect HUNG (timeout rather than\n' +
1249
+ 'auth error), the URL is probably the IPv6-only db.<project-ref> endpoint and this host has\n' +
1250
+ 'no IPv6 route — use the Shared Pooler URL.\n'
1166
1251
  );
1167
1252
  printResumeHint();
1168
1253
  return 3;
@@ -1233,3 +1318,9 @@ module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
1233
1318
  module.exports.SETTINGS_JSON_PATH = SETTINGS_JSON_PATH;
1234
1319
  module.exports.HOOK_COMMAND = HOOK_COMMAND;
1235
1320
  module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
1321
+ // Sprint 75 T2 — absolute-path hook-command builders (lockstep twin in
1322
+ // packages/stack-installer/src/index.js; exported for tests).
1323
+ module.exports._hookCommandFor = _hookCommandFor;
1324
+ module.exports._isTildeHookCommand = _isTildeHookCommand;
1325
+ module.exports._mergePreCompactHookEntry = _mergePreCompactHookEntry;
1326
+ module.exports.migrateSettingsJsonPreCompactEntry = migrateSettingsJsonPreCompactEntry;
@@ -212,6 +212,13 @@ function preflight() {
212
212
  ok();
213
213
  }
214
214
 
215
+ // Sprint 75 T2 (part B): warn-only endpoint-shape feedback — the same
216
+ // stored URL feeds the Edge Function secrets, so a direct-endpoint URL
217
+ // (IPv6-only) carried over from an earlier install gets flagged here too.
218
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(secrets.DATABASE_URL))) {
219
+ process.stdout.write(` ${line}\n`);
220
+ }
221
+
215
222
  // OPENAI_API_KEY is optional: when present, Rumen's Relate phase generates
216
223
  // real embeddings for semantic+keyword hybrid search. When absent, Rumen
217
224
  // falls back to keyword-only matching (still works, but loses cross-project
@@ -64,6 +64,21 @@
64
64
 
65
65
  const http = require('http');
66
66
  const https = require('https');
67
+ // Sprint 75 T2 (part C): endpoint-shape classifier — could-not-connect
68
+ // envelopes name the IPv6-only direct endpoint as the likely cause when
69
+ // DATABASE_URL has that shape. Warn-only suffix; never changes categories.
70
+ const { classifyDbEndpoint } = require('./setup/supabase-url');
71
+
72
+ // Suffix appended to connect-failure details when DATABASE_URL is the
73
+ // IPv6-only direct endpoint (db.<project-ref>.supabase.co — AAAA-only DNS).
74
+ function directEndpointSuffix(databaseUrl) {
75
+ try {
76
+ if (classifyDbEndpoint(databaseUrl).kind === 'direct') {
77
+ return ' (DATABASE_URL is the IPv6-only db.<project-ref> direct endpoint — on IPv4-only hosts pg clients hang until a pool/connect timeout; use the Shared Pooler URL)';
78
+ }
79
+ } catch (_e) { /* warn-only */ }
80
+ return '';
81
+ }
67
82
 
68
83
  const TTL_SECONDS = 30;
69
84
  const TTL_MS = TTL_SECONDS * 1000;
@@ -276,9 +291,10 @@ async function runPgChecks({ databaseUrl, _pgClient }) {
276
291
  } else {
277
292
  // URL set but connect failed → classify by code (timeout vs unreachable).
278
293
  const cat = classifyDbFailure(connectEnvelope || {});
279
- const why = connectEnvelope && connectEnvelope.error
294
+ const why = (connectEnvelope && connectEnvelope.error
280
295
  ? `could not connect to Postgres using DATABASE_URL — ${connectEnvelope.error}`
281
- : 'could not connect to Postgres using DATABASE_URL';
296
+ : 'could not connect to Postgres using DATABASE_URL')
297
+ + directEndpointSuffix(databaseUrl);
282
298
  pushPgUnavailableChecks(checks, 'mnestra-pg', cat, why, 'pg unavailable — connect failed');
283
299
  }
284
300
  return checks;
@@ -479,7 +495,9 @@ async function checkRumenPool(config, options) {
479
495
  return warnCheck('rumen-pool', CATEGORIES.DEPENDENCY_DOWN, 'SELECT 1 returned unexpected result');
480
496
  } catch (err) {
481
497
  const cat = classifyDbFailure(err);
482
- return warnCheck('rumen-pool', cat, err && err.message ? err.message : String(err));
498
+ const detail = (err && err.message ? err.message : String(err))
499
+ + directEndpointSuffix(dbUrl);
500
+ return warnCheck('rumen-pool', cat, detail);
483
501
  } finally {
484
502
  try { await pool.end(); } catch (_e) { /* ignore */ }
485
503
  }
@@ -10,6 +10,9 @@ const http = require('http');
10
10
  const fs = require('fs');
11
11
  const os = require('os');
12
12
  const path = require('path');
13
+ // Sprint 75 T2 (part C): endpoint-shape classifier — used to explain
14
+ // connect failures against the IPv6-only direct endpoint. Warn-only.
15
+ const { classifyDbEndpoint } = require('./setup/supabase-url');
13
16
 
14
17
  // Cache preflight results for 60s
15
18
  let _cachedResult = null;
@@ -352,10 +355,16 @@ async function runPreflight(config) {
352
355
  name: 'rumen_recent', passed: false,
353
356
  detail: `check failed — ${err.message}`,
354
357
  })),
355
- checkDatabase().catch((err) => ({
356
- name: 'database_url', passed: false,
357
- detail: `connection failed ${err.message}`,
358
- })),
358
+ checkDatabase().catch((err) => {
359
+ // Sprint 75 T2 (part C): a connect failure against the IPv6-only
360
+ // direct endpoint (db.<project-ref>.supabase.co) on an IPv4-only
361
+ // host presents as a timeout — name the likely cause in the detail.
362
+ let detail = `connection failed — ${err.message}`;
363
+ if (classifyDbEndpoint(process.env.DATABASE_URL).kind === 'direct') {
364
+ detail += ' (DATABASE_URL is the IPv6-only db.<project-ref> direct endpoint — on IPv4-only hosts pg clients hang until a pool/connect timeout; use the Shared Pooler URL)';
365
+ }
366
+ return { name: 'database_url', passed: false, detail };
367
+ }),
359
368
  checkProjectPaths(config).catch((err) => ({
360
369
  name: 'project_paths', passed: false,
361
370
  detail: `check failed — ${err.message}`,
@@ -413,7 +422,7 @@ const REMEDIATION = {
413
422
  mnestra_reachable: 'Start Mnestra with `mnestra serve`',
414
423
  mnestra_has_memories: 'Run `mnestra ingest` to populate the memory store',
415
424
  rumen_recent: 'Check Rumen Edge Function deployment or run `termdeck init --rumen`',
416
- database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env',
425
+ database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env (IPv4-only hosts: use the Shared Pooler URL)',
417
426
  project_paths: 'Fix paths in ~/.termdeck/config.yaml → projects',
418
427
  shell_sanity: 'Check $SHELL and your login profile (~/.zshrc or ~/.bashrc)',
419
428
  graph_health: 'Run T2 inference cron or apply migrations 009/010 to populate edges',
@@ -0,0 +1,105 @@
1
+ // Rumen Sprint 76 — inbox-promote Supabase Edge Function entry point.
2
+ //
3
+ // Drains Mnestra's memory_inbox quarantine: web-chat proposals (written via
4
+ // the bridge's memory_propose channel into engram migration 026's table) are
5
+ // promoted to canonical memory_items or rejected with an audit trail. One
6
+ // promotion pass per invocation; see src/promote.ts in @jhizzard/rumen for
7
+ // the gate sequence (caps -> source whitelist -> rate cap -> dedup ->
8
+ // kitchen-vs-recipe) and the doctrine amendment notes.
9
+ //
10
+ // Sibling of rumen-tick by design (NOT a step inside it): budget isolation
11
+ // (the tick already spends its wall-clock on the insight cycle), independent
12
+ // cadence, and failure isolation. Same thin-wrapper pattern: the npm:
13
+ // specifier freezes the package version at DEPLOY time — upgrading
14
+ // @jhizzard/rumen does nothing until this function is redeployed (the
15
+ // Sprint 66 Brad-Rumen-zero lesson).
16
+ //
17
+ // IMPORTANT: This file targets the Deno runtime, NOT Node. It will not
18
+ // compile under the root tsconfig.json — it is intentionally excluded.
19
+ // A sibling tsconfig.json in this directory keeps the types sane for
20
+ // editors, but the canonical build target is Deno's own type checker
21
+ // (`deno check`) and Supabase's `supabase functions deploy`.
22
+ //
23
+ // Deployment (ORCH at sprint close — deployable, NOT deployed from a lane):
24
+ // supabase functions deploy inbox-promote
25
+ // supabase secrets set DATABASE_URL="$DATABASE_URL" # Shared Pooler IPv4 URL
26
+ // supabase secrets set OPENAI_API_KEY="$OPENAI_API_KEY" # dedup-gate embeddings (3-large@1536)
27
+ // supabase secrets set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" # kitchen-vs-recipe Haiku gate
28
+ // # Optional tuning (defaults shown):
29
+ // supabase secrets set RUMEN_PROMOTE_BATCH=25
30
+ // supabase secrets set RUMEN_PROMOTE_RATE_CAP_24H=50
31
+ // supabase secrets set RUMEN_PROMOTE_MAX_ATTEMPTS=5
32
+ // supabase secrets set RUMEN_PROMOTE_CLAIM_LEASE_MINUTES=10
33
+ //
34
+ // Both model keys are REQUIRED: without them the pass skips (HTTP 503 below)
35
+ // rather than claiming rows it cannot gate — config absence must not burn
36
+ // promotion attempts across the inbox.
37
+ //
38
+ // Triggered on a schedule by pg_cron — see migrations/003_pg_cron_inbox_promote.sql.
39
+
40
+ // @ts-ignore Deno std import resolved at runtime.
41
+ import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
42
+ // @ts-ignore npm specifier resolved at runtime. Version is stamped at
43
+ // publish/deploy time by ORCH at sprint close — must be >= 0.6.0, the first
44
+ // version exporting promoteInbox.
45
+ import { promoteInbox, createPoolFromUrl } from 'npm:@jhizzard/rumen@0.6.0';
46
+
47
+ // @ts-ignore Deno global available at runtime.
48
+ declare const Deno: { env: { get: (k: string) => string | undefined } };
49
+
50
+ serve(async (_req: Request) => {
51
+ // Same fallback as rumen-tick: Supabase auto-injects SUPABASE_DB_URL.
52
+ const url = Deno.env.get('DATABASE_URL') ?? Deno.env.get('SUPABASE_DB_URL');
53
+ if (!url) {
54
+ console.error('[rumen-promote] DATABASE_URL / SUPABASE_DB_URL not set in Edge Function secrets');
55
+ return new Response(
56
+ JSON.stringify({ ok: false, error: 'DATABASE_URL not set' }),
57
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
58
+ );
59
+ }
60
+
61
+ const pool = createPoolFromUrl(url);
62
+
63
+ try {
64
+ console.log('[rumen-promote] edge function pass starting');
65
+ const summary = await promoteInbox(pool);
66
+
67
+ if (summary.skipped_reason) {
68
+ // Config-level skip (missing model key, rate-accounting failure):
69
+ // surface as a non-200 so pg_cron/operator dashboards notice, but the
70
+ // inbox is untouched and simply drains on a later pass.
71
+ console.error('[rumen-promote] pass skipped: ' + summary.skipped_reason);
72
+ return new Response(
73
+ JSON.stringify({ ok: false, skipped: summary.skipped_reason, summary }),
74
+ { status: 503, headers: { 'Content-Type': 'application/json' } },
75
+ );
76
+ }
77
+
78
+ console.log(
79
+ '[rumen-promote] edge function pass complete claimed=' +
80
+ summary.claimed +
81
+ ' promoted=' +
82
+ summary.promoted +
83
+ ' rejected=' +
84
+ summary.rejected,
85
+ );
86
+ // Row-level failures are fail-soft by design — the pass itself succeeded.
87
+ return new Response(
88
+ JSON.stringify({ ok: true, summary }),
89
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
90
+ );
91
+ } catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ console.error('[rumen-promote] edge function pass threw:', err);
94
+ return new Response(
95
+ JSON.stringify({ ok: false, error: message }),
96
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
97
+ );
98
+ } finally {
99
+ try {
100
+ await pool.end();
101
+ } catch (err) {
102
+ console.error('[rumen-promote] pool.end() failed:', err);
103
+ }
104
+ }
105
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": false,
11
+ "types": []
12
+ },
13
+ "include": ["index.ts"]
14
+ }
@@ -186,6 +186,104 @@ function normalizeDatabaseUrl(url) {
186
186
  return { url: u.toString(), modified: true };
187
187
  }
188
188
 
189
+ // ── DATABASE_URL endpoint-shape classification (Sprint 75 T2) ──────────────
190
+ //
191
+ // Ported from engram src/db-endpoint.ts (Sprint 74 T2 — Brad's Dell R730
192
+ // field report, 2026-06-09). Supabase's direct endpoint
193
+ // `db.<project-ref>.supabase.co` — which also hosts the Dedicated Pooler on
194
+ // :6543 — publishes ONLY an AAAA record. On a host without IPv6 (many CI
195
+ // runners and VPSes) pg clients don't fail fast; they hang until a pool
196
+ // timeout. The IPv4-compatible alternative is the Shared Pooler:
197
+ //
198
+ // postgres://postgres.<project-ref>:<pw>@aws-<n>-<region>.pooler.supabase.com:6543/postgres
199
+ //
200
+ // This classifier lets every DATABASE_URL ingress warn BEFORE the first
201
+ // hang. It never rewrites or rejects anything — `looksLikePostgresUrl`
202
+ // stays the blocking validator; direct URLs remain accepted because
203
+ // IPv6-capable hosts use them legitimately. Warn ≠ reject.
204
+
205
+ const LOCAL_DB_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
206
+
207
+ // Classify a raw DATABASE_URL string by endpoint family. Returns
208
+ // { kind, host?, port?, username?, poolerUserMismatch? } where kind is one of:
209
+ // 'absent' — nothing usable provided
210
+ // 'invalid' — set but not parseable as postgres:// / postgresql://
211
+ // 'direct' — db.<project-ref>.supabase.co|in (IPv6-only: AAAA, no A
212
+ // record; covers BOTH :5432 direct and :6543 Dedicated
213
+ // Pooler — same hostname, same IPv4 unreachability)
214
+ // 'shared-pooler' — *.pooler.supabase.com (IPv4-compatible)
215
+ // 'local' — loopback/local Postgres
216
+ // 'other' — self-hosted, RDS, IPv6 literal, … (no Supabase concerns)
217
+ // poolerUserMismatch is true when the host is the Shared Pooler but the
218
+ // username lacks the mandatory `.<project-ref>` suffix — the documented
219
+ // "Tenant or user not found" failure.
220
+ function classifyDbEndpoint(raw) {
221
+ if (raw === undefined || raw === null || typeof raw !== 'string') {
222
+ return { kind: 'absent' };
223
+ }
224
+ const trimmed = stripSurroundingQuotes(raw.trim());
225
+ if (trimmed === '') return { kind: 'absent' };
226
+
227
+ let u;
228
+ try {
229
+ u = new URL(trimmed);
230
+ } catch (_err) {
231
+ return { kind: 'invalid' };
232
+ }
233
+ if (u.protocol !== 'postgres:' && u.protocol !== 'postgresql:') {
234
+ return { kind: 'invalid' };
235
+ }
236
+
237
+ // Normalize: lowercase, drop a trailing FQDN dot.
238
+ const host = u.hostname.toLowerCase().replace(/\.$/, '');
239
+ let username = '';
240
+ try {
241
+ username = decodeURIComponent(u.username);
242
+ } catch (_err) {
243
+ username = u.username;
244
+ }
245
+ const base = { host, port: u.port, username };
246
+
247
+ if (LOCAL_DB_HOSTS.has(host)) return { kind: 'local', ...base };
248
+
249
+ if (/^db\.[a-z0-9-]+\.supabase\.(co|in)$/.test(host)) {
250
+ return { kind: 'direct', ...base };
251
+ }
252
+
253
+ if (host.endsWith('.pooler.supabase.com')) {
254
+ // Shared Pooler logins are `postgres.<project-ref>` — a dotless
255
+ // username means the URL was hand-assembled from direct-connection
256
+ // parts and will fail with "Tenant or user not found".
257
+ const poolerUserMismatch = username !== '' && !username.includes('.');
258
+ return { kind: 'shared-pooler', ...base, poolerUserMismatch };
259
+ }
260
+
261
+ return { kind: 'other', ...base };
262
+ }
263
+
264
+ // Warning lines for a classification — [] when there is nothing to say.
265
+ // Wording kept byte-similar to engram's doctor probe messages so grep /
266
+ // troubleshooting stays consistent across the stack. Print-only: callers
267
+ // write these to stdout after a PASSING validation and never change exit
268
+ // codes on their account.
269
+ function directEndpointWarningLines(classification) {
270
+ if (!classification || typeof classification !== 'object') return [];
271
+ if (classification.kind === 'direct') {
272
+ return [
273
+ '⚠ this is the IPv6-only endpoint (db.<project-ref>.supabase.co — AAAA-only DNS, no IPv4)',
274
+ 'on IPv4-only hosts pg clients hang until a pool/connect timeout',
275
+ 'IPv4-safe: Connect modal → Transaction pooler → toggle ON "Use IPv4 connection (Shared Pooler)"',
276
+ 'postgres://postgres.<project-ref>:<password>@aws-<n>-<region>.pooler.supabase.com:6543/postgres'
277
+ ];
278
+ }
279
+ if (classification.kind === 'shared-pooler' && classification.poolerUserMismatch) {
280
+ return [
281
+ `⚠ Shared Pooler host but username "${classification.username}" — pooler logins must be postgres.<project-ref>; fails with "Tenant or user not found"`
282
+ ];
283
+ }
284
+ return [];
285
+ }
286
+
189
287
  // Mask all but the last 4 chars of a secret for logging.
190
288
  function maskSecret(value) {
191
289
  if (!value || typeof value !== 'string') return '';
@@ -202,5 +300,7 @@ module.exports = {
202
300
  isTransactionPoolerUrl,
203
301
  normalizeDatabaseUrl,
204
302
  maskSecret,
205
- stripSurroundingQuotes
303
+ stripSurroundingQuotes,
304
+ classifyDbEndpoint,
305
+ directEndpointWarningLines
206
306
  };