← 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 →