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 `
${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',
+ `
`).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