← back to Ticket Triage Page
server.js
196 lines
#!/usr/bin/env node
// Ticket Triage — HYBRID: fire the safe/reversible actions, copy the prod ones.
// Zero deps. Basic-auth admin/DW2024!. Open /healthz. Port 9759, bound 127.0.0.1 ONLY.
//
// SAFETY MODEL: the server's spawn() is reachable ONLY from exec.mode==='fire', whose
// commands are local + reversible (TikTok dry-run). Prod/destructive actions are
// mode:'copy' — the server NEVER runs them; the page copies pasteCmd to the clipboard and
// you run it in a terminal (per-command gated). 'copy' Mark-fired only records to the ticket.
const http = require('http');
const fs = require('fs');
const { execFileSync, spawn } = require('child_process');
const path = require('path');
const os = require('os');
const PORT = process.env.PORT || 9759;
const HOST = '127.0.0.1';
const USER = process.env.BASIC_AUTH_USER || 'admin';
const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
const TK = path.join(os.homedir(), 'Projects/ticket-system/tk');
const TICKETS = require('./tickets');
const MEMO_ROOTS = [ path.join(os.homedir(), '.claude/yolo-queue'), path.join(os.homedir(), 'Projects/tools-dw-hub') ];
const byRef = r => TICKETS.find(t => t.ref === r);
function tk(args) { try { return execFileSync(TK, args, { encoding: 'utf8', env: { ...process.env, TK_AGENT: 'steve' } }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } }
function liveBlocked() { const s = new Set(); tk(['list', '--status', 'blocked', '--all']).split('\n').forEach(l => { const m = l.match(/^(TK-[0-9]+)/); if (m) s.add(m[1]); }); return s; }
function esc(x) { return String(x == null ? '' : x).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
function last4(v) { v = String(v || ''); return v.length <= 4 ? '****' : '…' + v.slice(-4); }
const BUCKET = { approval: ['#16a34a', '1-word approval'], deploy: ['#2563eb', 'deploy'], paste: ['#7c3aed', 'command'], task: ['#ea580c', 'hands-on task'], auto: ['#64748b', 'no action'] };
// runCmd is only ever called with a `fire`-mode ticket's own cmd (local + reversible).
function runCmd(cmd, cb) {
const child = spawn('bash', ['-lc', cmd], { env: process.env });
let out = '', done = false;
const finish = code => { if (done) return; done = true; cb(code, out.slice(0, 60000)); };
const to = setTimeout(() => { out += '\n[triage] TIMEOUT 120s — killed.\n'; child.kill('SIGKILL'); finish(124); }, 120000);
child.stdout.on('data', d => out += d); child.stderr.on('data', d => out += d);
child.on('close', code => { clearTimeout(to); finish(code); });
child.on('error', e => { clearTimeout(to); out += '\n[triage] spawn error: ' + e.message + '\n'; finish(1); });
}
function page() {
const blocked = liveBlocked();
const cards = TICKETS.map(t => {
const [color, blabel] = BUCKET[t.bucket];
const still = blocked.has(t.ref.match(/TK-[0-9]+/)[0]);
const e = t.exec;
let ctrl;
if (e.mode === 'handsoff') ctrl = `<span class="handsoff">⬜ hands-off</span>`;
else if (e.mode === 'copy') ctrl = `<button type="button" class="btn copy" data-ref="${esc(t.ref)}">📋 Copy: ${esc(e.btn)}</button><button type="button" class="btn mark" data-ref="${esc(t.ref)}">✓ Mark fired</button>`;
else if (e.mode === 'input') ctrl = `<button type="button" class="btn input act" data-ref="${esc(t.ref)}">${esc(e.btn)}</button>`;
else ctrl = `<button type="button" class="btn ${e.mode === 'fire' ? 'fire' : 'dec'} act" data-ref="${esc(t.ref)}">${e.mode === 'fire' ? '▶ ' : ''}${esc(e.btn)}</button>`;
return `<div class="card${still ? '' : ' cleared'}" data-bucket="${t.bucket}" id="card-${esc(t.ref)}">
<div class="top"><span class="ref">${esc(t.ref)}</span>
<span class="badge" style="background:${color}">${blabel}</span>
<span class="rec">rec: <b>${esc(t.recommend)}</b></span>
${e.danger === 'live' ? '<span class="livetag">LIVE — paste in terminal</span>' : ''}
${still ? '' : '<span class="done-pill">✓ cleared</span>'}</div>
<div class="title">${esc(t.title)}</div>
<div class="action">${esc(t.action)}</div>
${e.pasteCmd ? `<pre class="cmd">${esc(e.pasteCmd)}</pre>` : ''}
<div class="foot">${ctrl}${t.memo ? `<button type="button" class="btn ghost memo" data-file="${esc(t.memo)}">📄 runbook</button>` : ''}</div>
<pre class="output" id="out-${esc(t.ref)}" hidden></pre>
</div>`;
}).join('\n');
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Blocked-Ticket Triage · fire safe · copy prod</title><style>
:root{--bg:#0f172a;--card:#1e293b;--ink:#e2e8f0;--mut:#94a3b8;--line:#334155}
*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,system-ui,sans-serif;background:var(--bg);color:var(--ink)}
header{position:sticky;top:0;background:#0b1220;border-bottom:1px solid var(--line);padding:14px 20px;z-index:5}
h1{margin:0;font-size:18px}.sub{color:var(--mut);font-size:13px;margin-top:2px}
.bar{display:flex;gap:10px;margin-top:12px}
select,input,button{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px}
.btn{cursor:pointer;font-weight:600}.act.fire,.btn.fire{background:#16a34a;border-color:#16a34a}
.btn.dec{background:#0ea5e9;border-color:#0ea5e9}.btn.input{background:#7c3aed;border-color:#7c3aed}
.btn.copy{background:#334155}.btn.mark{background:#1e293b}.btn.ghost{background:transparent}
.btn:disabled{opacity:.5;cursor:progress}.btn.copied{background:#16a34a;border-color:#16a34a}
main{padding:18px 20px;max-width:900px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}.card.cleared{opacity:.5}
.top{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
.ref{font-weight:700;font-family:ui-monospace,monospace}.badge{color:#fff;font-size:11px;padding:2px 8px;border-radius:20px}
.rec{color:var(--mut);font-size:12px}.rec b{color:var(--ink)}.done-pill{color:#22c55e;font-size:12px}
.livetag{color:#fca5a5;font-size:11px;border:1px solid #7f1d1d;padding:1px 7px;border-radius:20px}
.title{font-weight:600;margin:2px 0}.action{color:var(--mut);font-size:13.5px}
.cmd{margin:8px 0 0;padding:10px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:11.5px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow:auto}
.foot{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}
.handsoff{color:var(--mut);font-size:13px;padding:6px 0}
.output{margin:10px 0 0;padding:12px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap;max-height:340px;overflow:auto}
.output.ok{border-color:#16a34a}.output.err{border-color:#dc2626}
#modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;padding:20px;z-index:10}
#modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:760px;width:100%;max-height:88vh;display:flex;flex-direction:column}
#modal .h{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
#modal .c{padding:16px;overflow:auto}#modal pre{margin:0 0 12px;padding:12px;background:#0b1220;border-radius:8px;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
.fld{display:block;margin:8px 0}.fld label{display:block;font-size:12px;color:var(--mut);margin-bottom:3px}.fld input{width:100%}
</style></head><body>
<header><h1>Blocked-Ticket Triage — fire safe · copy prod</h1>
<div class="sub">Green ▶ / blue buttons fire locally & are reversible. <span style="color:#fca5a5">LIVE</span> prod actions show the exact command to <b>copy & paste in your terminal</b> (per-command gated), then <b>Mark fired</b>. Every action logs to its ticket.</div>
<div class="bar"><select id="filter"><option value="">All buckets</option><option value="approval">approvals</option><option value="deploy">deploys</option><option value="paste">commands</option><option value="task">tasks</option><option value="auto">no action</option></select></div></header>
<main>${cards}</main>
<div id="modal"><div class="box"><div class="h"><b id="mtitle"></b><button type="button" class="btn ghost" id="mclose">close</button></div><div class="c" id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
const T=${JSON.stringify(TICKETS.map(t => ({ ref: t.ref, exec: t.exec })))};
function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
$('#filter').onchange=e=>{const v=e.target.value;$$('.card').forEach(c=>c.style.display=(!v||c.dataset.bucket===v)?'':'none')};
function openModal(title,html){$('#mtitle').textContent=title;$('#mbody').innerHTML=html;$('#modal').style.display='flex'}
$('#mclose').onclick=()=>$('#modal').style.display='none';$('#modal').onclick=e=>{if(e.target.id==='modal')$('#modal').style.display='none'};
$$('.memo').forEach(b=>b.onclick=async()=>{openModal(b.dataset.file.split('/').pop(),'<pre>loading…</pre>');const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').innerHTML='<pre></pre>';$('#mbody pre').textContent=await r.text()});
// COPY prod command to clipboard
$$('.btn.copy').forEach(b=>b.onclick=async()=>{const t=T.find(x=>x.ref===b.dataset.ref);try{await navigator.clipboard.writeText(t.exec.pasteCmd);b.classList.add('copied');b.textContent='✓ copied — paste in terminal';setTimeout(()=>{b.classList.remove('copied');b.textContent='📋 Copy: '+t.exec.btn},2500);}catch{const p=document.querySelector('#card-'+b.dataset.ref+' .cmd');if(p){const r=document.createRange();r.selectNode(p);getSelection().removeAllRanges();getSelection().addRange(r);}}});
async function post(ref,body){const r=await fetch('/api/exec',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ref,...body})});return r.json()}
function showOut(ref,j){const out=$('#out-'+ref);out.hidden=false;out.className='output '+(j.ok?'ok':'err');out.textContent=(j.ok?'✓ ':'✗ ')+(j.msg||'')+(j.output?('\\n\\n'+j.output):'');out.scrollIntoView({behavior:'smooth',block:'center'})}
// MARK fired (copy-mode): record that Steve ran the prod command in a terminal
$$('.btn.mark').forEach(b=>b.onclick=async()=>{b.disabled=true;const j=await post(b.dataset.ref,{markFired:true});showOut(b.dataset.ref,j);b.disabled=false;b.textContent=j.ok?'✓ marked':'retry'});
// ACT (fire / decision / input)
$$('.act').forEach(b=>b.onclick=()=>{const t=T.find(x=>x.ref===b.dataset.ref),e=t.exec;
let body='<div>'+esc(e.detail||'')+'</div>';
if(e.mode==='fire'){body+='<div style="margin-top:10px;color:#94a3b8;font-size:12px">Runs locally:</div><pre>'+esc(e.cmd)+'</pre>';}
if(e.mode==='input'){body+=e.fields.map(f=>'<div class="fld"><label>'+esc(f.label)+'</label><input data-f="'+f.name+'" type="'+(f.type||'text')+'" autocomplete="off"></div>').join('');}
body+='<div style="margin-top:12px"><button id="go" class="btn '+(e.mode==='fire'?'fire':e.mode==='input'?'input':'dec')+'">'+(e.mode==='fire'?'▶ Run now':e.mode==='input'?'Save + prep':'Confirm')+'</button></div>';
openModal(b.dataset.ref+' · '+esc(e.btn),body);
$('#go').onclick=async()=>{const payload={};if(e.mode==='input'){payload.inputs={};$$('#mbody input[data-f]').forEach(i=>payload.inputs[i.dataset.f]=i.value);}
$('#go').disabled=true;$('#go').textContent='working…';const j=await post(b.dataset.ref,payload);$('#modal').style.display='none';showOut(b.dataset.ref,j);b.textContent=j.ok?'✓ done':'retry'}});
</script></body></html>`;
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
if (url.pathname === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end('ok'); }
const hdr = req.headers.authorization || '';
const [u, p] = Buffer.from(hdr.split(' ')[1] || '', 'base64').toString().split(':');
if (u !== USER || p !== PASS) { res.writeHead(401, { 'www-authenticate': 'Basic realm="triage"' }); return res.end('auth required'); }
if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page()); }
if (url.pathname === '/api/memo') {
const real = path.resolve(url.searchParams.get('file') || '');
if (!MEMO_ROOTS.some(r => real.startsWith(r)) || !TICKETS.some(t => t.memo && path.resolve(t.memo) === real)) { res.writeHead(403); return res.end('not allowed'); }
return fs.readFile(real, 'utf8', (e, d) => { res.writeHead(e ? 404 : 200, { 'content-type': 'text/plain' }); res.end(e ? 'not found' : d); });
}
if (url.pathname === '/api/exec' && req.method === 'POST') {
let b = ''; req.on('data', c => b += c); req.on('end', () => {
let ref, inputs, markFired;
try { ({ ref, inputs, markFired } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"msg":"bad json"}'); }
const t = byRef(ref); if (!t) { res.writeHead(404); return res.end('{"ok":false,"msg":"unknown ticket"}'); }
const e = t.exec;
const reply = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (e.mode === 'handsoff') return reply({ ok: false, msg: 'Hands-off — owned by the armed idle-watch.' });
// copy-mode: server NEVER runs the prod command. Mark-fired only records intent.
if (e.mode === 'copy') {
if (!markFired) return reply({ ok: false, msg: 'Use Copy to grab the command, run it in a terminal, then Mark fired.' });
tk(['comment', ref, `Steve ran the gated command in a terminal (marked fired via triage page): \`${e.pasteCmd}\``]);
tk(['status', ref, 'doing']);
return reply({ ok: true, msg: 'Recorded: you ran it in the terminal → ticket set to doing.' });
}
if (e.mode === 'decision') {
tk(['comment', ref, `Steve APPROVED via triage page: ${e.detail}`]);
tk(['status', ref, 'doing']);
return reply({ ok: true, msg: 'Decision recorded (APPROVE) → doing.' });
}
if (e.mode === 'input') {
const digest = (e.fields || []).map(f => `${f.name}=${last4(inputs && inputs[f.name])}`).join(', ');
try { fs.writeFileSync(path.join(__dirname, `.secret-${ref}.json`), JSON.stringify(inputs || {}), { mode: 0o600 }); } catch {}
tk(['comment', ref, `Steve provided inputs via triage page (${digest}); saved to gitignored .secret-${ref}.json. nginx auth-drop remains a terminal step.`]);
tk(['status', ref, 'doing']);
return reply({ ok: true, msg: `Recorded ${digest}. Saved for the terminal step.` });
}
// fire-mode: local + reversible only. This is the ONLY path to spawn().
if (e.mode === 'fire') {
tk(['status', ref, 'doing']); tk(['log', ref, `FIRING (local) via triage page: ${e.cmd}`]);
runCmd(e.cmd, (code, output) => {
const ok = code === 0;
tk(['comment', ref, `Triage-page local fire ${ok ? 'SUCCESS' : 'exit=' + code}: \`${e.cmd}\``]);
reply({ ok, msg: `exit ${code}` + (ok ? '' : ' — see output'), output });
});
return;
}
return reply({ ok: false, msg: 'no exec mode' });
});
return;
}
res.writeHead(404); res.end('not found');
});
server.listen(PORT, HOST, () => console.log(`ticket-triage on http://${HOST}:${PORT} (fire-safe/copy-prod, /healthz open)`));