[object Object]

← back to Wallco Ai

designer-studio compose: async background generation — return run_id instantly + /api/studio/compose/status poll endpoint (fixes nginx 60s 504 on slow renders) + attribute real design id from generate_designs.js 'Created … IDs:' log (fixes ORDER BY id DESC race); frontend polls every 3s with elapsed timer instead of awaiting a blocking response

606f45c2ba5fe5935a7831562cfacdd4525cf6b1 · 2026-05-31 17:29:00 -0700 · Steve Abrams

Files touched

Diff

commit 606f45c2ba5fe5935a7831562cfacdd4525cf6b1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 31 17:29:00 2026 -0700

    designer-studio compose: async background generation — return run_id instantly + /api/studio/compose/status poll endpoint (fixes nginx 60s 504 on slow renders) + attribute real design id from generate_designs.js 'Created … IDs:' log (fixes ORDER BY id DESC race); frontend polls every 3s with elapsed timer instead of awaiting a blocking response
---
 server.js | 197 ++++++++++++++++++++++++++++++++++++++++++++++++--------------
 1 file changed, 155 insertions(+), 42 deletions(-)

diff --git a/server.js b/server.js
index 23e2299..be8d529 100644
--- a/server.js
+++ b/server.js
@@ -967,6 +967,57 @@ setInterval(() => load(true), 30000);
 // check as a CRITICAL bypass (anyone could mint a token with {role:"admin"}).
 const { isAdmin, requireAdmin } = require('./src/admin-gate');
 
+// ── Global PDP theme takeover (Steve 2026-05-31, /admin/theme-gallery "Set Live").
+// The admin can promote one of the 17 theme-variant templates to BE the live
+// customer PDP. When set, the bare /design/:id route serves that static template
+// (which fetches /api/design/:id client-side → real data) instead of the giant
+// server-side monolith. Setting is a single global default, persisted to
+// data/pdp-theme.json (prod-authored — added to .deploy.conf excludes so a Mac2
+// deploy never clobbers the live choice). Bypass per-request with ?classic=1
+// (monolith A/B) or ?theme=<slug> (preview a specific variant).
+const PDP_THEME_FILE = path.join(__dirname, 'data', 'pdp-theme.json');
+const THEME_FILE_MAP = {
+  compact:'v1-compact.html', bento:'v2-bento.html', tabs:'v3-tabs.html',
+  hero:'v4-hero.html', vertical:'v5-vertical.html', drawer:'v6-drawer.html',
+  editorial:'v7-editorial.html', spec:'v8-spec.html', config:'v9-config.html',
+  stack:'v10-stack.html', best:'v11-best.html',
+  roomsplit:'v12-roomsplit.html', roomdrawer:'v13-roomdrawer.html',
+  roomzine:'v14-roomzine.html', roomsticky:'v15-roomsticky.html',
+  roommin:'v16-roommin.html', roomshow:'v17-roomshow.html',
+};
+let _pdpThemeCache = { val: undefined, at: 0 };
+function getActivePdpTheme() {
+  const now = Date.now();
+  if (_pdpThemeCache.val !== undefined && now - _pdpThemeCache.at < 5000) return _pdpThemeCache.val;
+  let theme = null;
+  try {
+    const j = JSON.parse(fs.readFileSync(PDP_THEME_FILE, 'utf8'));
+    if (j && typeof j.theme === 'string' && THEME_FILE_MAP[j.theme]) theme = j.theme;
+  } catch { /* no file / bad json = no override = serve monolith */ }
+  _pdpThemeCache = { val: theme, at: now };
+  return theme;
+}
+function setActivePdpTheme(theme, who) {
+  if (theme !== null && !THEME_FILE_MAP[theme]) throw new Error('unknown theme: ' + theme);
+  const payload = { theme: theme || null, setAt: new Date().toISOString(), setBy: who || 'admin' };
+  fs.writeFileSync(PDP_THEME_FILE, JSON.stringify(payload, null, 2));
+  _pdpThemeCache = { val: theme || null, at: Date.now() };
+  return payload;
+}
+// Public read — the gallery (and anyone) can see which variant is live.
+app.get('/api/pdp-theme', (req, res) => {
+  res.json({ ok: true, theme: getActivePdpTheme(), themes: Object.keys(THEME_FILE_MAP) });
+});
+// Admin write — set the global live PDP theme (or pass theme:null to clear → monolith).
+app.post('/api/admin/pdp-theme', requireAdmin, (req, res) => {
+  const theme = (req.body && (req.body.theme === null || typeof req.body.theme === 'string'))
+    ? req.body.theme : undefined;
+  if (theme === undefined) return res.status(400).json({ ok: false, error: 'theme required (slug, or null to clear)' });
+  if (theme && !THEME_FILE_MAP[theme]) return res.status(400).json({ ok: false, error: 'unknown theme slug' });
+  try { res.json({ ok: true, ...setActivePdpTheme(theme, 'admin') }); }
+  catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
 // ── Fliepaper Bugs — Gucci Resort Edition (6 patterns × 6 colorways = 36 cells)
 // Settlement-gated regen routes a job file into data/fliepaper-bugs/queue/ that
 // yolo-runner picks up. Public collection page + admin grid; placeholders show
@@ -11778,6 +11829,21 @@ app.get('/design/:id', (req, res) => {
     return res.status(404).type('html').send(renderDesignNotFound(req, id));
   }
 
+  // ── Global PDP theme takeover. If an admin promoted a variant via
+  // /admin/theme-gallery "Set Live", serve that template here (it fetches
+  // /api/design/:id client-side — id parsed from this bare path — so it renders
+  // real data). Runs AFTER the unpublished gate above so a variant can never
+  // expose an unpublished design. ?classic=1 → force monolith; ?theme=<slug> →
+  // preview a specific variant for this request only.
+  {
+    const previewSlug = (typeof req.query.theme === 'string' && THEME_FILE_MAP[req.query.theme])
+      ? req.query.theme : null;
+    const liveSlug = req.query.classic === '1' ? null : (previewSlug || getActivePdpTheme());
+    if (liveSlug && THEME_FILE_MAP[liveSlug]) {
+      return res.sendFile(path.join(__dirname, 'public', 'theme-variants', THEME_FILE_MAP[liveSlug]));
+    }
+  }
+
   // Safety net: if the in-memory DESIGNS entry is a stub from another route
   // (e.g., the by-id image route's lazy push) and is missing title/category,
   // backfill from PG so the page doesn't render "undefined".
@@ -21811,58 +21877,89 @@ app.post('/api/studio/compose', (req, res) => {
       VALUES (NULL, '${prompt.replace(/'/g,"''")}', '{}'::bigint[], 'running')
       RETURNING id;`);
 
-    // Spawn the generator script directly
-    const r = spawnSync('node', [
+    // Spawn the generator in the BACKGROUND (mirrors /api/generator/batch) so
+    // the HTTP request returns in <1s. A synchronous spawnSync here took
+    // 10-120s and tripped nginx's 60s proxy_read_timeout (→ 504, the user never
+    // saw the result), and reading the new id via ORDER BY id DESC raced any
+    // concurrent generator. We log child stdout to a file, parse the real
+    // "Created N/N · IDs: …" line on exit, and the frontend polls
+    // /api/studio/compose/status for completion.
+    const logFile = path.join(__dirname, 'data', 'tmp', `studio-run-${runId}.log`);
+    try { fs.mkdirSync(path.dirname(logFile), { recursive: true }); } catch {}
+    const out = fs.openSync(logFile, 'a');
+    const child = spawn('node', [
       path.join(__dirname, 'scripts', 'generate_designs.js'),
       '--n', '1', '--kind', 'seamless_tile',
       '--category', (styles[0] || 'mixed').toLowerCase(),
       '--prompts', prompt,
     ], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
-         encoding: 'utf8', timeout: 120000 });
+         stdio: ['ignore', out, out], timeout: 180000 });
 
-    let designId = null;
-    try {
-      designId = parseInt(psqlQuery(`SELECT id FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`), 10);
-    } catch {}
+    child.on('exit', (code) => {
+      try { fs.closeSync(out); } catch {}
+      // Attribute from the LOG, not the global max id (concurrent-generator race).
+      let createdIds = [];
+      try {
+        const logText = fs.readFileSync(logFile, 'utf8');
+        const m = logText.match(/Created\s+\d+\/\d+\s+designs\s*·?\s*IDs?:\s*([0-9,\s]+)/i);
+        if (m) createdIds = m[1].split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite);
+      } catch {}
+      const ok = code === 0 && createdIds.length > 0;
+      const designId = createdIds[0] || null;
+      if (ok && designId) {
+        try {
+          const safePrompt = prompt.replace(/'/g, "''");
+          const safeSid = String(sid).replace(/'/g, "''");
+          psqlQuery(`INSERT INTO studio_renders (session_uuid, design_id, prompt)
+            VALUES ('${safeSid}', ${designId}, '${safePrompt}') RETURNING id;`);
+        } catch (e) { console.error('[studio_renders insert]', e.message); }
+      }
+      try {
+        psqlQuery(`UPDATE wallco_generator_runs SET
+          status='${ok ? 'done' : 'error'}',
+          design_id=${designId || 'NULL'},
+          error=${ok ? 'NULL' : `'${(createdIds.length === 0
+              ? `0/1 designs survived the quality gate (code ${code})`
+              : `exited code ${code}`).replace(/'/g,"''")}'`},
+          finished_at=now() WHERE id=${runId};`);
+      } catch {}
+    });
+    child.on('error', (err) => {
+      try { fs.closeSync(out); } catch {}
+      try { psqlQuery(`UPDATE wallco_generator_runs SET status='error',
+        error='${String(err.message||'spawn failed').replace(/'/g,"''")}',
+        finished_at=now() WHERE id=${runId};`); } catch {}
+    });
+
+    // Respond immediately — generation continues in the background; frontend polls.
+    res.json({ ok: true, run_id: parseInt(runId, 10), status: 'running', prompt });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
 
-    const ok = r.status === 0;
-    psqlQuery(`UPDATE wallco_generator_runs SET
-      status='${ok ? 'done' : 'error'}',
-      design_id=${designId || 'NULL'},
-      error=${ok ? 'NULL' : "'"+((r.stderr||r.stdout||'').slice(-400).replace(/'/g,"''"))+"'"},
-      finished_at=now()
-      WHERE id=${runId};`);
-
-    // Look up the freshly-inserted design's image so the /studio payoff frame
-    // can render the final pattern (R0a — Steve ask 2026-05-12 late).
-    let imageUrl = null;
-    let imageFilename = null;
-    if (ok && designId) {
+// Poll target for the studio compose flow — returns run status, and once done
+// the design id + image url for the payoff frame. Replaces the old synchronous
+// compose response that 504'd on slow renders.
+app.get('/api/studio/compose/status', (req, res) => {
+  const runId = parseInt(req.query.run_id, 10);
+  if (!Number.isFinite(runId)) return res.status(400).json({ error: 'run_id required' });
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t),'[]'::json) FROM
+      (SELECT id, status, design_id, prompt, error FROM wallco_generator_runs WHERE id=${runId}) t;`);
+    const rows = JSON.parse(raw || '[]');
+    if (!rows.length) return res.status(404).json({ error: 'run not found' });
+    const run = rows[0];
+    let imageUrl = null, imageFilename = null, renderId = null;
+    if (run.status === 'done' && run.design_id) {
       try {
-        const lp = psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${designId} LIMIT 1;`);
-        if (lp) {
-          imageFilename = path.basename(lp);
-          imageUrl = '/designs/img/' + imageFilename;
-        }
+        const lp = psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${run.design_id} LIMIT 1;`);
+        if (lp) { imageFilename = path.basename(lp); imageUrl = '/designs/img/' + imageFilename; }
       } catch {}
+      try { renderId = parseInt(psqlQuery(`SELECT id FROM studio_renders WHERE design_id=${run.design_id} ORDER BY id DESC LIMIT 1;`), 10) || null; } catch {}
     }
-    // R0c — persist every render to studio_renders so the user can revisit/delete.
-    let renderId = null;
-    if (ok && designId) {
-      try {
-        const safePrompt = prompt.replace(/'/g, "''");
-        const safeSid = String(sid).replace(/'/g, "''");
-        const out = psqlQuery(`INSERT INTO studio_renders
-          (session_uuid, design_id, prompt) VALUES
-          ('${safeSid}', ${designId}, '${safePrompt}')
-          RETURNING id;`);
-        renderId = parseInt(out, 10) || null;
-      } catch (e) { console.error('[studio_renders insert]', e.message); }
-    }
-    res.json({ ok, prompt, design_id: designId, run_id: parseInt(runId, 10),
-               render_id: renderId,
+    res.json({ ok: run.status === 'done', status: run.status, run_id: runId,
+               design_id: run.design_id || null, render_id: renderId, prompt: run.prompt || '',
                image_url: imageUrl, image_filename: imageFilename,
-               error: ok ? null : (r.stderr || r.stdout || '').slice(-600) });
+               error: run.status === 'error' ? (run.error || 'generation failed') : null });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
@@ -22328,11 +22425,27 @@ document.getElementById('btn-compose').addEventListener('click', async function(
   var btn = this; btn.textContent = 'Composing (10-30s)…';
   var stat = document.getElementById('compose-result');
   stat.style.display='block'; stat.innerHTML = '<div style="padding:10px;background:#fff3cd;color:#856404;border-radius:4px">Generating…</div>';
-  var j = await post('/api/studio/compose', {
+  var started = await post('/api/studio/compose', {
     sid: SID,
     extra_elements: Array.from(EXTRA),
     extra_text: document.getElementById('extra-text').value || ''
   });
+  var j = started;
+  // Background generation (2026-05-31): compose returns {run_id,status:'running'}
+  // in <1s — no more 60s nginx 504. Poll the status endpoint until done.
+  if (started && started.run_id && started.status === 'running') {
+    var startedAt = Date.now();
+    while (Date.now() - startedAt < 240000) {
+      await new Promise(function(r){ setTimeout(r, 3000); });
+      var elapsed = Math.round((Date.now()-startedAt)/1000);
+      stat.innerHTML = '<div style="padding:10px;background:#fff3cd;color:#856404;border-radius:4px">Generating your pattern… '+elapsed+'s <span style="color:#999">· seamless tiling + quality gate, safe to wait</span></div>';
+      try {
+        var st = await get('/api/studio/compose/status?run_id='+encodeURIComponent(started.run_id));
+        if (st && st.status && st.status !== 'running') { j = st; break; }
+      } catch(_) {}
+    }
+    if (!j || j.status === 'running') { j = { ok:false, error:'Still rendering after 4 min — it may appear in "Your saved renders" shortly.' }; }
+  }
   btn.disabled = false; btn.textContent = 'Compose new design';
   if (j.ok) {
     // R0a — payoff frame: big pattern preview + actions. Replaces the old "✓ N generated" toast.

← 7f2e28e Fix PDP inline-script escape-collapse + restore grid density  ·  back to Wallco Ai  ·  theme-gallery: Set Live promotes a variant to take over the 6b5a6a1 →