[object Object]

← back to Wallco Ai

chat: surface psql stderr + redact replicate token + throw on null INSERT id

4622641523680130bffb4b65ae59a7f690d644c4 · 2026-05-14 09:35:42 -0700 · SteveStudio2

The chat-driven generate path was silently returning {id: null} when the
spoon_all_designs INSERT failed (column drift on prod). Three fixes:

1. psql() now uses spawnSync so we can see stderr and throw on non-zero exit.
   Previous execSync-only path swallowed PG errors as exit 0 with empty stdout.
2. toolGenerateDesign throws with stdout+exit+stderr context if RETURNING id
   isn't a finite number, instead of leaking null down the response chain.
3. Replicate token now travels via env var (REPLICATE_TOKEN) rather than
   inlined into the shell command, so curl error output can't leak the token
   in API responses to the client. Matching change for both submit and poll
   curl calls. Also dropped -sf in favor of -sS so HTTP-error bodies surface.

Surfaced the prod-side schema drift (18 missing columns on spoon_all_designs)
that was causing the silent failures — fix queued in /tmp/wallco_schema_parity.sql
pending Steve sign-off on prod migration.

Files touched

Diff

commit 4622641523680130bffb4b65ae59a7f690d644c4
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 14 09:35:42 2026 -0700

    chat: surface psql stderr + redact replicate token + throw on null INSERT id
    
    The chat-driven generate path was silently returning {id: null} when the
    spoon_all_designs INSERT failed (column drift on prod). Three fixes:
    
    1. psql() now uses spawnSync so we can see stderr and throw on non-zero exit.
       Previous execSync-only path swallowed PG errors as exit 0 with empty stdout.
    2. toolGenerateDesign throws with stdout+exit+stderr context if RETURNING id
       isn't a finite number, instead of leaking null down the response chain.
    3. Replicate token now travels via env var (REPLICATE_TOKEN) rather than
       inlined into the shell command, so curl error output can't leak the token
       in API responses to the client. Matching change for both submit and poll
       curl calls. Also dropped -sf in favor of -sS so HTTP-error bodies surface.
    
    Surfaced the prod-side schema drift (18 missing columns on spoon_all_designs)
    that was causing the silent failures — fix queued in /tmp/wallco_schema_parity.sql
    pending Steve sign-off on prod migration.
---
 src/chat.js | 37 ++++++++++++++++++++++++++++++-------
 1 file changed, 30 insertions(+), 7 deletions(-)

diff --git a/src/chat.js b/src/chat.js
index bd413cb..4ff2de2 100644
--- a/src/chat.js
+++ b/src/chat.js
@@ -42,7 +42,15 @@ const PSQL_CMD = (process.platform === 'linux')
   : `psql dw_unified -At -q`;
 
 function psql(sql) {
-  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
+  const r = spawnSync('/bin/sh', ['-c', PSQL_CMD], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) {
+    const tag = sql.length > 200 ? sql.slice(0, 200) + '…' : sql;
+    throw new Error(`psql exit ${r.status}: ${(r.stderr || '').trim() || '(no stderr)'} | sql: ${tag}`);
+  }
+  if (r.stderr && r.stderr.trim()) {
+    console.error('[psql stderr]', r.stderr.trim());
+  }
+  return (r.stdout || '').trim();
 }
 function esc(s) { if (s == null) return 'NULL'; return "'" + String(s).replace(/'/g, "''") + "'"; }
 
@@ -168,16 +176,25 @@ async function toolGenerateDesign({ prompt, category = 'mixed' }, ctx = {}) {
       seed, refine: 'expert_ensemble_refiner', apply_watermark: false
     }
   };
-  const submit = execSync(`curl -sf -m 30 -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' -X POST 'https://api.replicate.com/v1/predictions' -d @-`,
-    { input: JSON.stringify(body), encoding: 'utf8' });
-  const pred = JSON.parse(submit);
-  if (!pred.id) throw new Error('replicate no prediction id');
+  let submit;
+  try {
+    submit = execSync(`curl -sS -m 30 -H "Authorization: Bearer $REPLICATE_TOKEN" -H 'Content-Type: application/json' -X POST 'https://api.replicate.com/v1/predictions' -d @-`,
+      { input: JSON.stringify(body), encoding: 'utf8', env: { ...process.env, REPLICATE_TOKEN: TOKEN } });
+  } catch (e) {
+    const msg = String(e.stderr || e.stdout || e.message || '').slice(0, 400);
+    throw new Error(`replicate submit failed: ${msg}`);
+  }
+  let pred;
+  try { pred = JSON.parse(submit); }
+  catch { throw new Error(`replicate non-JSON response: ${String(submit).slice(0, 200)}`); }
+  if (!pred.id) throw new Error(`replicate no prediction id: ${String(submit).slice(0, 200)}`);
   // Poll
   const start = Date.now();
   let final;
   while (Date.now() - start < 3 * 60_000) {
     execSync('sleep 3');
-    const p = JSON.parse(execSync(`curl -sf -m 10 -H 'Authorization: Bearer ${TOKEN}' 'https://api.replicate.com/v1/predictions/${pred.id}'`, { encoding: 'utf8' }));
+    const p = JSON.parse(execSync(`curl -sS -m 10 -H "Authorization: Bearer $REPLICATE_TOKEN" 'https://api.replicate.com/v1/predictions/${pred.id}'`,
+      { encoding: 'utf8', env: { ...process.env, REPLICATE_TOKEN: TOKEN } }));
     if (p.status === 'succeeded') { final = p; break; }
     if (p.status === 'failed' || p.status === 'canceled') throw new Error(`replicate ${p.status}: ${JSON.stringify(p.error).slice(0, 200)}`);
   }
@@ -204,7 +221,13 @@ print(json.dumps(palette))
   const promptEsc = prompt.replace(/'/g, "''");
   const insertSql = `INSERT INTO spoon_all_designs (kind, width_in, height_in, generator, prompt, seed, dominant_hex, palette, local_path, category, is_published, parent_design_id, chat_session_id, request_text)
     VALUES ('seamless_tile', 24, 24, 'replicate', '${promptEsc}', ${seed}, ${dominant ? "'" + dominant + "'" : 'NULL'}, '${palJson}'::jsonb, ${esc(outPath)}, '${category.replace(/'/g,"''")}', FALSE, ${ctx.parent_design_id ? ctx.parent_design_id : 'NULL'}, ${ctx.chat_session_id ? ctx.chat_session_id : 'NULL'}, ${esc(ctx.request_text)}) RETURNING id;`;
-  const id = parseInt(psql(insertSql), 10);
+  const probe = spawnSync('/bin/sh', ['-c', PSQL_CMD], { input: insertSql, encoding: 'utf8' });
+  const idRaw = (probe.stdout || '').trim();
+  const id = parseInt(idRaw, 10);
+  if (!Number.isFinite(id)) {
+    const stderr = (probe.stderr || '').trim();
+    throw new Error(`spoon_all_designs INSERT returned non-numeric id (stdout=${JSON.stringify(idRaw)}, exit=${probe.status}, stderr=${JSON.stringify(stderr)}); image saved at ${outPath} but row not persisted`);
+  }
   return { id, prompt, dominant_hex: dominant, category, image_url: '/designs/img/' + filename, parent_design_id: ctx.parent_design_id || null };
 }
 

← e850309 fix(/designs): studio-adjust summary precedence bug — state.  ·  back to Wallco Ai  ·  fix(/designs): move card-hover OUT of card-link so the Sampl 3291dd7 →