← back to Wallco Ai
cactus-curator: gate-held → 200 park (was 422 dropping vote, same loop as drunk-curator HOT bug); frontend offers amber 'Publish anyway' toast w/ force:true override
37d5e6d02fb4e086034b15eec56c5614acd5a592 · 2026-05-28 05:56:51 -0700 · Steve Abrams
Files touched
M data/cactus-decisions.jsonlM public/admin/cactus-curator.htmlM server.js
Diff
commit 37d5e6d02fb4e086034b15eec56c5614acd5a592
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 05:56:51 2026 -0700
cactus-curator: gate-held → 200 park (was 422 dropping vote, same loop as drunk-curator HOT bug); frontend offers amber 'Publish anyway' toast w/ force:true override
---
data/cactus-decisions.jsonl | 2 ++
public/admin/cactus-curator.html | 24 +++++++++++++++++++++++-
server.js | 32 +++++++++++++++++++++++++-------
3 files changed, 50 insertions(+), 8 deletions(-)
diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index 347b207..9e7b4f2 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -3056,3 +3056,5 @@
{"ts":"2026-05-28T12:51:37.869Z","id":53777,"action":"live"}
{"ts":"2026-05-28T12:51:40.311Z","id":53778,"action":"live"}
{"ts":"2026-05-28T12:51:42.654Z","id":53783,"action":"live"}
+{"ts":"2026-05-28T12:54:20.069Z","id":41230,"action":"live"}
+{"ts":"2026-05-28T12:56:16.541Z","id":9948,"action":"bad"}
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 52551e2..76c7f6d 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -508,7 +508,29 @@ async function decide(id, action, el){
const r = await fetch(q(`/api/cactus-decision/${id}`), {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action })
});
- if(!r.ok){ const j = await r.json().catch(()=>({})); throw new Error(j.error||r.status); }
+ const j = await r.json().catch(()=>({}));
+ if(!r.ok || j.ok===false){ throw new Error(j.error||('HTTP '+r.status)); }
+ // Live + gate-fail → the verdict IS saved (parked w/ needs_fixing) — show
+ // amber "Publish anyway" toast that retries with force:true. Same pattern as
+ // drunk-curator's HOT-gate-held flow ([[feedback_wallco_hot_vote_save_even_when_gate_blocks]]).
+ if(j.gate_held){
+ decided.set(id,'fix');
+ el?.classList.remove('decided'); el?.classList.add('decided');
+ if(st){ st.style.display='block'; st.textContent='⚠ NEEDS FIXING'; }
+ const reasons=(j.reasons||[]).join(' · ')||'failed publish gate';
+ const t=document.createElement('div');
+ t.style.cssText='position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#b07a00;color:#1a1a1a;padding:8px 14px;border-radius:8px;z-index:99;font-size:13px;box-shadow:0 4px 18px rgba(0,0,0,.5)';
+ t.innerHTML=`#${id} parked — gate held (${reasons}). <button style="margin-left:10px;background:#1a1a1a;color:#ffd68a;border:1px solid #ffd68a;padding:3px 9px;border-radius:5px;cursor:pointer;font-weight:700">Publish anyway</button>`;
+ t.querySelector('button').onclick=async()=>{
+ t.remove();
+ const r2=await fetch(q(`/api/cactus-decision/${id}`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'live',force:true})});
+ const j2=await r2.json().catch(()=>({}));
+ if(r2.ok && j2.ok){ decided.set(id,'live'); if(st){st.textContent='✓ PUBLISHED';} flash(`#${id} force-published`); }
+ else flash(`Force-publish failed: ${j2.error||r2.status}`);
+ };
+ document.body.appendChild(t);
+ setTimeout(()=>t.remove(), 10000);
+ }
} catch(e){
el?.classList.remove('decided');
if(st) st.style.display='none';
diff --git a/server.js b/server.js
index abbc763..0d8f4fd 100644
--- a/server.js
+++ b/server.js
@@ -1480,10 +1480,22 @@ function applyCactusDecision(id, action, opts = {}) {
WHERE id=${id};`);
} else if (action === 'live') {
// 4) Keep, go live in a web viewer — but ONLY through the 100% publish gate
- // (Steve "needs to be 100%"). Throws on fail; the endpoint surfaces reasons.
+ // (Steve "needs to be 100%"). When the gate fails WITHOUT force, do NOT throw
+ // (the old behavior returned 422 + the curator dropped the vote — same loop
+ // as the drunk-curator's old HOT bug, [[feedback_wallco_hot_vote_save_even
+ // _when_gate_blocks]]). Instead PARK the design: human verdict recorded as
+ // "loved but needs fixing", item leaves the queue, gate reasons returned so
+ // the frontend can offer "Publish anyway" (force:true).
if (!opts.force) {
const gate = runPublishGate(id);
- if (!gate.pass) { const err = new Error('gate_failed'); err.gate = gate; throw err; }
+ if (!gate.pass) {
+ psqlExecLocal(`UPDATE all_designs
+ SET is_published=FALSE, user_removed=FALSE, needs_fixing_at=now(),
+ tags = (array_remove(array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing'), 'curator-loved'))
+ || ARRAY['needs-fixing','curator-loved']::text[]
+ WHERE id=${id};`);
+ return { gate_held: true, reasons: gate.reasons || [], checks: gate.checks || {} };
+ }
}
psqlExecLocal(`UPDATE all_designs
SET is_published=TRUE, web_viewer=TRUE, user_removed=FALSE,
@@ -1506,10 +1518,12 @@ app.post('/api/cactus-decision/:id', express.json({ limit: '2kb' }), (req, res)
return res.status(400).json({ error: 'action must be bad|digital|fix|live' });
}
try {
- applyCactusDecision(id, action, { force: req.body?.force === true });
+ const r = applyCactusDecision(id, action, { force: req.body?.force === true });
+ // gate-held → 200 with gate_held:true so the curator records the verdict and
+ // offers "Publish anyway" instead of dropping the vote (see in-fn comment).
+ if (r && r.gate_held) return res.json({ ok: true, id, action, gate_held: true, reasons: r.reasons, checks: r.checks });
res.json({ ok: true, id, action });
} catch (e) {
- if (e.message === 'gate_failed') return res.status(422).json({ ok: false, gate_failed: true, id, reasons: e.gate?.reasons || [], checks: e.gate?.checks || {} });
res.status(e.message === 'design not found' ? 404 : 500).json({ error: e.message });
}
});
@@ -1521,12 +1535,16 @@ app.post('/api/cactus-decision/bulk', express.json({ limit: '64kb' }), (req, res
const action = String(req.body?.action || '').toLowerCase();
if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
if (!['bad', 'digital', 'fix', 'live'].includes(action)) return res.status(400).json({ error: 'bad action' });
- let ok = 0, err = 0; const results = [];
+ let ok = 0, err = 0, held = 0; const results = [];
for (const id of ids) {
- try { applyCactusDecision(id, action); results.push({ id, ok: true }); ok++; }
+ try {
+ const r = applyCactusDecision(id, action);
+ if (r && r.gate_held) { results.push({ id, ok: true, gate_held: true, reasons: r.reasons }); held++; }
+ else { results.push({ id, ok: true }); ok++; }
+ }
catch (e) { results.push({ id, ok: false, error: e.message.slice(0, 200) }); err++; }
}
- res.json({ ok: true, total: ids.length, applied: ok, errored: err, action, results });
+ res.json({ ok: true, total: ids.length, applied: ok, gate_held: held, errored: err, action, results });
});
// POST /api/admin/cactus/:id/annotations body: { boxes:[{x,y,w,h,label}] }
← 041d703 drunk-curator: richer ID cues — density tier, subject+emoji,
·
back to Wallco Ai
·
cactus-decision: route guard for /bulk shadowing fix + publi 312e90c →