From 97ac266cd0ea6370cec0b52cde789562af6b4453 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 20 Jul 2026 20:25:17 +0200 Subject: [PATCH] Build private incident notebook MVP --- .env.example | 4 ++++ .gitignore | 3 +++ Dockerfile | 8 ++++++++ README.md | 10 ++++++++++ docker-compose.yml | 11 +++++++++++ package.json | 1 + src/server.mjs | 37 +++++++++++++++++++++++++++++++++++++ 7 files changed, 74 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/server.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba6c7a8 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORT=3082 +TRACE_PASSWORD=replace-me +# Comma-separated id:absolute-path pairs. Mount paths read-only in Docker. +TRACE_REPOS=goonk:/repos/goonk,flit:/repos/flit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3277c6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/*.db* +data/exports/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75f255e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM node:24-alpine +RUN apk add --no-cache git +WORKDIR /app +COPY package.json ./ +COPY src ./src +RUN mkdir -p data/exports +EXPOSE 3082 +CMD ["node","src/server.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6a836f --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# trace + +Private incident notebook, Git fix-candidate inbox, deployment-receipt evidence store, and reviewed Astro Markdown exporter for goonk's `/failures` page. + +```bash +cp .env.example .env +npm start +``` + +Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`; configure read-only repositories through `TRACE_REPOS`. Nothing publishes automatically: export creates a draft under `data/exports/` for manual review. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cff5da --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + trace: + build: . + restart: unless-stopped + env_file: .env + ports: ["${HOST_PORT:-3082}:3082"] + volumes: + - trace-data:/app/data + - ../goonk:/repos/goonk:ro +volumes: + trace-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba18f19 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"name":"trace","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"node --watch --env-file-if-exists=.env src/server.mjs","start":"node --env-file-if-exists=.env src/server.mjs","test":"node --test","build":"node --check src/server.mjs"},"engines":{"node":">=24"}} diff --git a/src/server.mjs b/src/server.mjs new file mode 100644 index 0000000..4de93c9 --- /dev/null +++ b/src/server.mjs @@ -0,0 +1,37 @@ +import http from 'node:http'; +import { DatabaseSync } from 'node:sqlite'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import crypto from 'node:crypto'; + +const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || ''; +mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true }); +const db = new DatabaseSync('data/trace.db'); +db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; +CREATE TABLE IF NOT EXISTS incidents(id TEXT PRIMARY KEY,title TEXT NOT NULL,project TEXT,status TEXT NOT NULL DEFAULT 'investigating',severity TEXT NOT NULL DEFAULT 'annoyance',detected_at TEXT NOT NULL,symptom TEXT NOT NULL,impact TEXT DEFAULT '',root_cause TEXT DEFAULT '',confidence TEXT DEFAULT 'unknown',fix TEXT DEFAULT '',verification TEXT DEFAULT '',prevention TEXT DEFAULT '',remaining_risk TEXT DEFAULT '',lesson TEXT DEFAULT '',public_summary TEXT DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS timeline(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,at TEXT NOT NULL,kind TEXT NOT NULL,body TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS hypotheses(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,statement TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'untested',evidence TEXT DEFAULT ''); +CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo TEXT NOT NULL,sha TEXT NOT NULL,committed_at TEXT NOT NULL,subject TEXT NOT NULL,paths TEXT DEFAULT '',reason TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'new',incident_id TEXT,UNIQUE(repo,sha)); +CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY AUTOINCREMENT,project TEXT NOT NULL,commit_sha TEXT NOT NULL,deployed_at TEXT NOT NULL,environment TEXT DEFAULT '',health TEXT DEFAULT '',payload TEXT NOT NULL,UNIQUE(project,commit_sha,deployed_at));`); +const repos = new Map((process.env.TRACE_REPOS || '').split(',').filter(Boolean).map(x => { const i=x.indexOf(':'); return [x.slice(0,i),x.slice(i+1)]; })); +const esc=s=>String(s??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); +const form=async req=>{let b='';for await(const c of req)b+=c;return Object.fromEntries(new URLSearchParams(b));}; +function auth(req,res){if(!password)return true;const expected='Basic '+Buffer.from('trace:'+password).toString('base64');if(req.headers.authorization===expected)return true;res.writeHead(401,{'WWW-Authenticate':'Basic realm="trace"'}).end('Authentication required');return false;} +function layout(title,body){return `${esc(title)} · trace
trace
${body}
`;} +const fields=['impact','root_cause','fix','verification','prevention','remaining_risk','lesson','public_summary']; +function incidentForm(v={}){return `
${fields.map(x=>``).join('')}
`;} +function redirect(res,to){res.writeHead(303,{Location:to}).end();} +async function scan(){for(const [id,path] of repos){const {stdout}=await exec('git',['-C',path,'log','--all','--no-merges','-n','300','--pretty=format:%H%x1f%ad%x1f%s','--date=iso-strict'],{maxBuffer:2e6});for(const line of stdout.split('\n')){const [sha,date,subject]=line.split('\x1f');if(!/fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i.test(subject||''))continue;let paths='';try{paths=(await exec('git',['-C',path,'show','--name-only','--pretty=format:',sha],{maxBuffer:2e5})).stdout.trim();}catch{}db.prepare('INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)').run(id,sha,date,subject,paths,'keyword');}}} +async function handler(req,res){if(!auth(req,res))return;const u=new URL(req.url,'http://x');if(u.pathname==='/style.css'){res.writeHead(200,{'content-type':'text/css'}).end(`:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}@media(max-width:650px){.cols{grid-template-columns:1fr}}`);return;} +if(req.method==='GET'&&u.pathname==='/'){const rows=db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all();const counts=db.prepare("SELECT status,count(*) n FROM incidents GROUP BY status").all();res.end(layout('incidents',`

Incident notebook

${counts.map(x=>`${x.status}: ${x.n}`).join(' · ')||'No incidents yet.'}

${rows.map(x=>`${esc(x.title)}

${esc(x.symptom)}

${x.status} ${esc(x.project)} · ${esc(x.detected_at)}
`).join('')}`));return;} +if(req.method==='GET'&&u.pathname==='/incidents/new'){res.end(layout('new incident','

Record what happened

'+incidentForm()));return;} +if(req.method==='POST'&&u.pathname==='/incidents/new'){const f=await form(req),id=crypto.randomUUID(),now=new Date().toISOString();db.prepare(`INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`).run(id,f.title,f.project||'',f.status,f.severity,f.detected_at,f.symptom,...fields.slice(0,2).map(x=>f[x]||''),f.confidence||'unknown',...fields.slice(2).map(x=>f[x]||''),now,now);redirect(res,'/incidents/'+id);return;} +const match=u.pathname.match(/^\/incidents\/([^/]+)$/);if(match){const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);if(!x){res.writeHead(404).end();return;}if(req.method==='POST'){const f=await form(req);db.prepare(`UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map(x=>x+'=?').join(',')},confidence=?,updated_at=? WHERE id=?`).run(f.title,f.project||'',f.status,f.severity,f.detected_at,f.symptom,...fields.map(x=>f[x]||''),f.confidence||'unknown',new Date().toISOString(),x.id);redirect(res,u.pathname);return;}const timeline=db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id), hyps=db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id), commits=db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id);res.end(layout(x.title,`

${esc(x.title)}

${incidentForm(x)}

Timeline

${timeline.map(t=>`

${esc(t.kind)} ${esc(t.at)}
${esc(t.body)}

`).join('')}

Hypotheses

${hyps.map(h=>`

${esc(h.statement)} ${h.status}
${esc(h.evidence)}

`).join('')}

Linked commits

${commits.map(c=>`

${c.sha.slice(0,7)} ${esc(c.subject)}

`).join('')||'

None.

'}
`));return;} +const sub=u.pathname.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/);if(req.method==='POST'&&sub){const f=await form(req),id=sub[1];if(sub[2]==='timeline')db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run(id,f.at,f.kind,f.body);if(sub[2]==='hypotheses')db.prepare('INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)').run(id,f.statement,f.status,f.evidence);if(sub[2]==='export'){const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(id);const slug=x.title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');const md=`---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary||x.symptom)}\ndate: ${x.detected_at.slice(0,10)}\nprojectSlug: ${JSON.stringify(x.project||'')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;writeFileSync(join('data/exports',slug+'.md'),md);res.end(layout('export',`

Public draft exported

data/exports/${esc(slug)}.md

${esc(md)}
`));return;}redirect(res,'/incidents/'+id);return;} +if(u.pathname==='/candidates'&&req.method==='POST'){await scan();redirect(res,'/candidates');return;}if(u.pathname==='/candidates'){const rows=db.prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC").all();res.end(layout('git inbox',`

Git inbox

${rows.map(c=>`
${esc(c.subject)}

${esc(c.repo)} · ${c.sha.slice(0,10)} · ${esc(c.committed_at)}

changed paths
${esc(c.paths)}
`).join('')}`));return;} +const link=u.pathname.match(/^\/candidates\/(\d+)\/link$/);if(link&&req.method==='POST'){const f=await form(req);db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(f.incident_id,link[1]);redirect(res,'/candidates');return;} +if(u.pathname==='/api/receipts'&&req.method==='POST'){let raw='';for await(const c of req)raw+=c;const x=JSON.parse(raw);db.prepare('INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)').run(x.project,x.commit,x.deployedAt,x.environment||'',x.health||'',raw);res.writeHead(201,{'content-type':'application/json'}).end('{"ok":true}');return;} +res.writeHead(404).end(layout('not found','

Not found

'));} +http.createServer((req,res)=>handler(req,res).catch(e=>{console.error(e);res.writeHead(500).end('Internal error');})).listen(port,()=>console.log(`trace listening on ${port}`));