diff --git a/package.json b/package.json index b7eae6c..d63b31f 100644 --- a/package.json +++ b/package.json @@ -1 +1,17 @@ -{"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","format":"npx --yes prettier@3.9.5 --write \"src/*.mjs\" \"test/*.mjs\"","format:check":"npx --yes prettier@3.9.5 --check \"src/*.mjs\" \"test/*.mjs\""},"engines":{"node":">=24"}} +{ + "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", + "format": "npx --yes prettier@3.9.5 --write \"src/**/*.mjs\" \"test/*.mjs\"", + "format:check": "npx --yes prettier@3.9.5 --check \"src/**/*.mjs\" \"test/*.mjs\"" + }, + "engines": { + "node": ">=24" + } +} \ No newline at end of file diff --git a/src/db.mjs b/src/db.mjs new file mode 100644 index 0000000..4482f87 --- /dev/null +++ b/src/db.mjs @@ -0,0 +1,79 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; + +// Schema for the private incident notebook and the Git fix-candidate inbox. +// Deployment-receipt tables live in receipts.mjs, next to the code that +// actually owns that data — this file only owns the notebook's own shape. +const SCHEMA = ` +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) +);`; + +// Additive, best-effort migrations for columns that shipped after the +// initial schema. SQLite has no "ADD COLUMN IF NOT EXISTS", so each one is +// tried and a "duplicate column" failure is the expected steady state once +// it's already applied everywhere. +const MIGRATIONS = [ + "ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''", + "ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''", +]; + +export function openDb(path = 'data/trace.db') { + mkdirSync('data', { recursive: true }); + mkdirSync('data/exports', { recursive: true }); + const db = new DatabaseSync(path); + db.exec(SCHEMA); + for (const sql of MIGRATIONS) { + try { + db.exec(sql); + } catch (error) { + if (!String(error.message).includes('duplicate column')) throw error; + } + } + return db; +} diff --git a/src/http-utils.mjs b/src/http-utils.mjs new file mode 100644 index 0000000..909e8ac --- /dev/null +++ b/src/http-utils.mjs @@ -0,0 +1,9 @@ +export const form = async (req) => { + let b = ''; + for await (const c of req) b += c; + return Object.fromEntries(new URLSearchParams(b)); +}; + +export function redirect(res, to) { + res.writeHead(303, { Location: to }).end(); +} diff --git a/src/receipts.mjs b/src/receipts.mjs index ce86fbb..4e85036 100644 --- a/src/receipts.mjs +++ b/src/receipts.mjs @@ -147,7 +147,7 @@ export function renderEventsPage(db) { 'SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result', ) .all(); - return `operational events · trace
trace

Operational events

Builds say what CI produced. Deployments say what a host actually ran.

${counts.map((x) => `${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}`).join('')}
${ + return `operational events · trace
trace

Operational events

Builds say what CI produced. Deployments say what a host actually ran.

${counts.map((x) => `${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}`).join('')}
${ rows .map((row) => { const payload = JSON.parse(row.payload), diff --git a/src/routes/incidents.mjs b/src/routes/incidents.mjs new file mode 100644 index 0000000..5b6c450 --- /dev/null +++ b/src/routes/incidents.mjs @@ -0,0 +1,283 @@ +// The private incident notebook and the Git fix-candidate inbox that feeds +// it. Everything here is gated by the shared `auth` check up front — the +// caller passes the same Basic Auth gate used everywhere else in trace so +// there's exactly one place that decides who gets in, not one per route. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import crypto from 'node:crypto'; +import { scanGiteaCandidates } from '../gitea.mjs'; +import { form, redirect } from '../http-utils.mjs'; +import { esc, fields, layout, incidentForm, slugify } from '../views.mjs'; + +const exec = promisify(execFile); +const FIX_KEYWORDS = /fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i; + +export function createIncidentRoutes({ + db, + auth, + repos = new Map(), + giteaUrl = 'https://repo.explewd.com', + giteaRepos = [], + giteaToken = '', +}) { + async function scan() { + for (const [id, path] of repos) { + let stdout; + try { + stdout = ( + await exec( + 'git', + [ + '-C', + path, + 'log', + '--all', + '--no-merges', + '-n', + '300', + '--pretty=format:%H%x1f%ad%x1f%s', + '--date=iso-strict', + ], + { maxBuffer: 2e6 }, + ) + ).stdout; + } catch (error) { + console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`); + continue; + } + for (const line of stdout.split('\n')) { + const [sha, date, subject] = line.split('\x1f'); + if (!FIX_KEYWORDS.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, 'local git keyword'); + } + } + const remote = await scanGiteaCandidates({ + baseUrl: giteaUrl, + repositories: giteaRepos, + token: giteaToken, + }); + for (const candidate of remote.candidates) + db.prepare( + 'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)', + ).run( + candidate.repo, + candidate.sha, + candidate.committedAt, + candidate.subject, + candidate.paths, + candidate.reason, + ); + for (const error of remote.errors) + console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`); + } + + function renderHome() { + 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(); + return 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('')}`, + ); + } + + function renderIncident(x) { + 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); + return 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.

'}
`, + ); + } + + // Reviewed-draft export, written to disk for hand-editing before it ever + // becomes a real /failures entry. Deliberately not the same template as + // the machine-served public Markdown (views.mjs's publicMarkdown) — this + // one keeps raw severity/status values and an explicit placeholder + // section, because it's meant to be edited by a human, not served as-is. + function exportDraft(x) { + const slug = slugify(x.title); + 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); + return { slug, md }; + } + + async function handlePublication(req, res, path) { + const match = path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/); + if (!match || req.method !== 'POST') return false; + const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]); + if (!x) { + res.writeHead(404).end(); + return true; + } + if (match[2] === 'unpublish') { + db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run( + new Date().toISOString(), + x.id, + ); + } else { + let slug = x.public_slug || slugify(x.title); + const collision = db + .prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?') + .get(slug, x.id); + if (collision) slug = `${slug}-${x.id.slice(0, 8)}`; + const now = new Date().toISOString(); + db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run( + slug, + now, + now, + x.id, + ); + } + redirect(res, `/incidents/${x.id}`); + return true; + } + + // The last stop in the dispatch chain (see server.mjs) — everything not + // claimed by the public API, the publish/unpublish action, /events, or + // the receipts API ends up here, auth-gated as a whole rather than + // per-route, then either matched to a real route or rendered as 404. + async function handle(req, res, path) { + if (!auth(req, res)) return true; + + if (await handlePublication(req, res, path)) return true; + + if (path === '/' && req.method === 'GET') { + res.end(renderHome()); + return true; + } + + if (path === '/incidents/new' && req.method === 'GET') { + res.end(layout('new incident', '

Record what happened

' + incidentForm())); + return true; + } + if (path === '/incidents/new' && req.method === 'POST') { + 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 true; + } + + const match = path.match(/^\/incidents\/([^/]+)$/); + if (match) { + const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]); + if (!x) { + res.writeHead(404).end(); + return true; + } + 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, path); + return true; + } + res.end(renderIncident(x)); + return true; + } + + const sub = path.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, md } = exportDraft(x); + res.end( + layout( + 'export', + `

Public draft exported

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

${esc(md)}
`, + ), + ); + return true; + } + redirect(res, '/incidents/' + id); + return true; + } + + if (path === '/candidates' && req.method === 'POST') { + await scan(); + redirect(res, '/candidates'); + return true; + } + if (path === '/candidates' && req.method === 'GET') { + 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 true; + } + + const link = path.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 true; + } + + return false; + } + + return { handle }; +} diff --git a/src/routes/public-failures.mjs b/src/routes/public-failures.mjs new file mode 100644 index 0000000..1091539 --- /dev/null +++ b/src/routes/public-failures.mjs @@ -0,0 +1,71 @@ +// The read-only, unauthenticated projection of published incidents — +// consumed by goonk's /failures page at build and runtime. Never touches +// an incident until it has been explicitly published (see +// routes/incidents.mjs's publish/unpublish action); an unpublished +// incident is invisible here regardless of how much of it is filled in. +import { publicIncident, publicDetail, publicMarkdown } from '../views.mjs'; + +const jsonHeaders = { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'cache-control': 'public, max-age=60', +}; + +export function createPublicFailuresRoutes(db) { + async function handle(req, res, path) { + if (req.method !== 'GET') return false; + + if (path === '/api/v1/public/failures') { + const rows = db + .prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC") + .all(); + res.writeHead(200, jsonHeaders).end( + JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + failures: rows.map(publicIncident), + }), + ); + return true; + } + + const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/); + if (md) { + const x = db + .prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''") + .get(decodeURIComponent(md[1])); + if (!x) { + res.writeHead(404).end('Not found'); + return true; + } + res + .writeHead(200, { ...jsonHeaders, 'content-type': 'text/markdown; charset=utf-8' }) + .end(publicMarkdown(x)); + return true; + } + + const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/); + if (detail) { + const x = db + .prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''") + .get(decodeURIComponent(detail[1])); + if (!x) { + res + .writeHead(404, { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }) + .end('{"error":"not_found"}'); + return true; + } + res + .writeHead(200, jsonHeaders) + .end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) })); + return true; + } + + return false; + } + + return { handle }; +} diff --git a/src/server.mjs b/src/server.mjs index 3764bf8..579e4e1 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -1,62 +1,14 @@ 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'; +import { openDb } from './db.mjs'; +import { STYLE_CSS } from './style.mjs'; +import { layout } from './views.mjs'; import { createReceiptStore, renderEventsPage } from './receipts.mjs'; -import { scanGiteaCandidates } from './gitea.mjs'; +import { createIncidentRoutes } from './routes/incidents.mjs'; +import { createPublicFailuresRoutes } from './routes/public-failures.mjs'; + +const port = Number(process.env.PORT || 3082); +const password = process.env.TRACE_PASSWORD || ''; -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));`); -for (const sql of [ - "ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''", - "ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''", -]) { - try { - db.exec(sql); - } catch (error) { - if (!String(error.message).includes('duplicate column')) throw error; - } -} -const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || ''); -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 giteaRepos = (process.env.TRACE_GITEA_REPOS || '') - .split(',') - .map((x) => x.trim()) - .filter(Boolean), - giteaUrl = process.env.TRACE_GITEA_URL || 'https://repo.explewd.com', - giteaToken = process.env.TRACE_GITEA_TOKEN || ''; -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'); @@ -64,409 +16,60 @@ function auth(req, res) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="trace"' }).end('Authentication required'); return false; } -function layout(title, body) { - return `${esc(title)} · trace
trace
${body}
Private by default. Git discovers; a human explains.
`; -} -const fields = [ - 'impact', - 'root_cause', - 'fix', - 'verification', - 'prevention', - 'remaining_risk', - 'lesson', - 'public_summary', -]; -const slugify = (s) => - String(s || 'failure') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') || 'failure'; -const publicSeverity = { - annoyance: 'paper-cut', - degraded: 'minor', - unavailable: 'significant', - 'data-risk': 'significant', - 'security-risk': 'significant', -}; -const publicStatus = { - investigating: 'open', - mitigated: 'worked-around', - resolved: 'resolved', - abandoned: 'abandoned', -}; -function publicMarkdown(x) { - return `---\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: ${publicSeverity[x.severity] || 'minor'}\nstatus: ${publicStatus[x.status] || 'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\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`; -} -function publicIncident(x) { - return { - id: x.id, - slug: x.public_slug, - title: x.title, - summary: x.public_summary || x.symptom, - date: x.detected_at.slice(0, 10), - projectSlug: x.project || '', - kind: 'bug', - severity: publicSeverity[x.severity] || 'minor', - status: publicStatus[x.status] || 'open', - publishedAt: x.published_at, - updatedAt: x.updated_at, - markdownUrl: `/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`, - }; -} -function publicDetail(x) { - return { - ...publicIncident(x), - sections: [ - ['Symptom', x.symptom], - ['What was actually happening', x.root_cause], - ['How we proved it', x.verification], - ['The fix', x.fix], - ['What changed afterward', x.prevention], - ['Remaining risk', x.remaining_risk], - ['Lesson', x.lesson], - ].filter(([, body]) => body), - }; -} -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) { - let stdout; - try { - stdout = ( - await exec( - 'git', - [ - '-C', - path, - 'log', - '--all', - '--no-merges', - '-n', - '300', - '--pretty=format:%H%x1f%ad%x1f%s', - '--date=iso-strict', - ], - { maxBuffer: 2e6 }, - ) - ).stdout; - } catch (error) { - console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`); - continue; - } - 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, 'local git keyword'); - } - } - const remote = await scanGiteaCandidates({ - baseUrl: giteaUrl, - repositories: giteaRepos, - token: giteaToken, - }); - for (const candidate of remote.candidates) - db.prepare( - 'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)', - ).run( - candidate.repo, - candidate.sha, - candidate.committedAt, - candidate.subject, - candidate.paths, - candidate.reason, - ); - for (const error of remote.errors) - console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`); -} -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

')); -} -async function publicContent(req, res, path) { - if (req.method === 'GET' && path === '/api/v1/public/failures') { - const rows = db - .prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC") - .all(); - res - .writeHead(200, { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'cache-control': 'public, max-age=60', - }) - .end( - JSON.stringify({ - schemaVersion: 1, - generatedAt: new Date().toISOString(), - failures: rows.map(publicIncident), - }), - ); - return true; - } - const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/); - if (req.method === 'GET' && md) { - const x = db - .prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''") - .get(decodeURIComponent(md[1])); - if (!x) { - res.writeHead(404).end('Not found'); - return true; - } - res - .writeHead(200, { - 'content-type': 'text/markdown; charset=utf-8', - 'access-control-allow-origin': '*', - 'cache-control': 'public, max-age=60', - }) - .end(publicMarkdown(x)); - return true; - } - const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/); - if (req.method === 'GET' && detail) { - const x = db - .prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''") - .get(decodeURIComponent(detail[1])); - if (!x) { - res - .writeHead(404, { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - }) - .end('{"error":"not_found"}'); - return true; - } - res - .writeHead(200, { - 'content-type': 'application/json; charset=utf-8', - 'access-control-allow-origin': '*', - 'cache-control': 'public, max-age=60', - }) - .end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) })); - return true; - } - return false; -} - -async function publicationAction(req, res, path) { - const match = path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/); - if (!match || req.method !== 'POST') return false; - if (!auth(req, res)) return true; - const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]); - if (!x) { - res.writeHead(404).end(); - return true; - } - if (match[2] === 'unpublish') - db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run( - new Date().toISOString(), - x.id, - ); - else { - let slug = x.public_slug || slugify(x.title); - const collision = db - .prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?') - .get(slug, x.id); - if (collision) slug = `${slug}-${x.id.slice(0, 8)}`; - const now = new Date().toISOString(); - db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run( - slug, - now, - now, - x.id, - ); - } - redirect(res, `/incidents/${x.id}`); - return true; -} +const db = openDb(); +const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || ''); +const publicFailures = createPublicFailuresRoutes(db); +const incidents = createIncidentRoutes({ + db, + auth, + 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)]; + }), + ), + giteaUrl: process.env.TRACE_GITEA_URL || 'https://repo.explewd.com', + giteaRepos: (process.env.TRACE_GITEA_REPOS || '') + .split(',') + .map((x) => x.trim()) + .filter(Boolean), + giteaToken: process.env.TRACE_GITEA_TOKEN || '', +}); +// Request dispatch order matters: public API and receipt ingestion are +// checked first since neither uses Basic Auth (the public API is meant to +// be open, receipts carry their own bearer token) — putting them behind +// incidents.handle's auth() gate would needlessly 401 a legitimate CI +// runner or a public /failures reader. incidents.handle is the true +// fallback: it auth-gates and owns everything else, including the final +// 404. http .createServer(async (req, res) => { try { const path = new URL(req.url, 'http://x').pathname; - if (await publicContent(req, res, path)) return; - if (await publicationAction(req, res, path)) return; + + if (path === '/style.css') { + if (!auth(req, res)) return; + res.writeHead(200, { 'content-type': 'text/css' }).end(STYLE_CSS); + return; + } + + if (await publicFailures.handle(req, res, path)) return; + if (path === '/events') { if (auth(req, res)) res.end(renderEventsPage(db)); return; } - const handled = await receiptStore.handle(req, res, path); - if (handled === false) await handler(req, res); + + if ((await receiptStore.handle(req, res, path)) !== false) return; + + if (await incidents.handle(req, res, path)) return; + + res.writeHead(404).end(layout('not found', '

Not found

')); } catch (e) { console.error(e); if (!res.headersSent) res.writeHead(500); diff --git a/src/style.mjs b/src/style.mjs new file mode 100644 index 0000000..c2d5ef0 --- /dev/null +++ b/src/style.mjs @@ -0,0 +1,7 @@ +// Single shared stylesheet for every HTML page trace serves (the private +// notebook and the operational-events feed used to carry their own nearly +// identical copies — that duplication is exactly what let their