@retrace-dev/core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/schema.d.ts +3 -3
- package/dist/schema.js +1 -1
- package/dist/ui-html.d.ts +1 -1
- package/dist/ui-html.js +1 -1
- package/package.json +6 -3
- package/ui/retrace.html +155 -39
package/dist/schema.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export type Action = z.infer<typeof Action>;
|
|
|
50
50
|
export declare const ArtifactRole: z.ZodEnum<["used", "generated", "both"]>;
|
|
51
51
|
export type ArtifactRole = z.infer<typeof ArtifactRole>;
|
|
52
52
|
export declare const ArtifactRef: z.ZodObject<{
|
|
53
|
-
/** Stable id for the thing being worked on, e.g. "repo:
|
|
53
|
+
/** Stable id for the thing being worked on, e.g. "repo:my-app#src/main.ts" or "doc:abc123" */
|
|
54
54
|
id: z.ZodString;
|
|
55
55
|
kind: z.ZodOptional<z.ZodString>;
|
|
56
56
|
label: z.ZodOptional<z.ZodString>;
|
|
@@ -203,7 +203,7 @@ export declare const EventInput: z.ZodObject<{
|
|
|
203
203
|
action: z.ZodEnum<["created", "edited", "deleted", "read", "executed", "approved", "rejected", "sent", "received", "moved", "renamed", "instructed", "committed", "merged", "other"]>;
|
|
204
204
|
action_detail: z.ZodOptional<z.ZodString>;
|
|
205
205
|
artifacts: z.ZodArray<z.ZodObject<{
|
|
206
|
-
/** Stable id for the thing being worked on, e.g. "repo:
|
|
206
|
+
/** Stable id for the thing being worked on, e.g. "repo:my-app#src/main.ts" or "doc:abc123" */
|
|
207
207
|
id: z.ZodString;
|
|
208
208
|
kind: z.ZodOptional<z.ZodString>;
|
|
209
209
|
label: z.ZodOptional<z.ZodString>;
|
|
@@ -455,7 +455,7 @@ export declare const Event: z.ZodObject<{
|
|
|
455
455
|
action: z.ZodEnum<["created", "edited", "deleted", "read", "executed", "approved", "rejected", "sent", "received", "moved", "renamed", "instructed", "committed", "merged", "other"]>;
|
|
456
456
|
action_detail: z.ZodOptional<z.ZodString>;
|
|
457
457
|
artifacts: z.ZodArray<z.ZodObject<{
|
|
458
|
-
/** Stable id for the thing being worked on, e.g. "repo:
|
|
458
|
+
/** Stable id for the thing being worked on, e.g. "repo:my-app#src/main.ts" or "doc:abc123" */
|
|
459
459
|
id: z.ZodString;
|
|
460
460
|
kind: z.ZodOptional<z.ZodString>;
|
|
461
461
|
label: z.ZodOptional<z.ZodString>;
|
package/dist/schema.js
CHANGED
|
@@ -48,7 +48,7 @@ export const Action = z.enum([
|
|
|
48
48
|
*/
|
|
49
49
|
export const ArtifactRole = z.enum(["used", "generated", "both"]);
|
|
50
50
|
export const ArtifactRef = z.object({
|
|
51
|
-
/** Stable id for the thing being worked on, e.g. "repo:
|
|
51
|
+
/** Stable id for the thing being worked on, e.g. "repo:my-app#src/main.ts" or "doc:abc123" */
|
|
52
52
|
id: z.string().min(1),
|
|
53
53
|
kind: z.string().optional(), // file, doc, dataset, pr, message, decision, ...
|
|
54
54
|
label: z.string().optional(),
|
package/dist/ui-html.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Retrace</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a7;\n --human: #f2a93b; --agent: #5aa9ff; --system: #9aa3b5; --ok: #3ccb7f; --bad: #ff5c6c; --accent: #c7b6ff;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n @media (prefers-color-scheme: light) {\n :root { --bg: #f6f7fa; --panel: #ffffff; --panel-2: #f0f2f6; --line: #dfe3ea; --text: #16181d; --muted: #616a7d;\n --human: #c77800; --agent: #1f6fe0; --system: #6b7385; --ok: #178f4f; --bad: #d1303f; --accent: #6b4fd8; }\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }\n header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 8px 0 0; letter-spacing: .04em; }\n header h1 span { color: var(--accent); }\n select, input, button { font: inherit; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }\n input { min-width: 140px; }\n header button { padding: 6px 9px; }\n button { cursor: pointer; }\n button.primary { background: var(--accent); color: #111; border-color: transparent; font-weight: 600; }\n .badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); background: var(--panel-2); }\n .badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }\n .badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }\n .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }\n main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; height: calc(100vh - 53px); }\n @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; } #detail { border-left: 0; border-top: 1px solid var(--line); } }\n #timeline { overflow: auto; padding: 16px 20px 40px; }\n #detail { border-left: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 16px; }\n .legend { display: flex; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }\n .legend .dot.human { background: var(--human); } .legend .dot.agent { background: var(--agent); } .legend .dot.system { background: var(--system); }\n .day { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 8px 0; }\n .ev { display: grid; grid-template-columns: 64px 18px 1fr; gap: 10px; align-items: start; padding: 8px 8px; border-radius: 8px; cursor: pointer; position: relative; }\n .ev:hover { background: var(--panel-2); }\n .ev.sel { background: var(--panel-2); outline: 1px solid var(--accent); }\n .ev .t { color: var(--muted); font-family: var(--mono); font-size: 12px; padding-top: 2px; }\n .ev .rail { position: relative; height: 100%; }\n .ev .rail::before { content: \"\"; position: absolute; left: 8px; top: -10px; bottom: -14px; width: 2px; background: var(--line); }\n .ev .node { position: absolute; left: 3px; top: 3px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--line); }\n .ev.human .node { background: var(--human); } .ev.agent .node { background: var(--agent); border-radius: 3px; } .ev.system .node { background: var(--system); }\n .ev .who { font-weight: 600; }\n .ev.human .who { color: var(--human); } .ev.agent .who { color: var(--agent); } .ev.system .who { color: var(--system); }\n .ev .verb { color: var(--accent); }\n .ev .art { font-family: var(--mono); font-size: 12.5px; }\n .ev .why { color: var(--muted); font-size: 12.5px; margin-top: 2px; }\n .ev .link { color: var(--muted); font-size: 11px; }\n .chip { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-right: 4px; cursor: pointer; }\n .chip:hover { border-color: var(--accent); }\n .chip.more { color: var(--accent); border-style: dashed; }\n .chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }\n #detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }\n .kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }\n .kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }\n .kv .v { word-break: break-word; }\n .kv .v.mono { font-family: var(--mono); font-size: 12px; }\n pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-family: var(--mono); font-size: 12px; overflow: auto; max-height: 240px; margin: 4px 0 0; white-space: pre-wrap; }\n .why-chain { border-left: 2px solid var(--line); padding-left: 10px; margin: 6px 0 14px 4px; }\n .why-chain .step { margin: 6px 0; font-size: 13px; }\n .why-chain .step .who { font-weight: 600; }\n .why-chain .step.human .who { color: var(--human); } .why-chain .step.agent .who { color: var(--agent); }\n .why-chain .step .arrow { color: var(--muted); font-size: 11px; }\n .empty { color: var(--muted); padding: 40px 0; text-align: center; }\n .stat { display: flex; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }\n .stat b { color: var(--text); font-weight: 600; }\n #conn { display: none; gap: 8px; align-items: center; }\n #conn.open { display: flex; }\n .hash { font-family: var(--mono); font-size: 11px; color: var(--muted); }\n a { color: var(--accent); }\n .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }\n .seg button { border: 0; border-radius: 0; padding: 6px 10px; background: var(--panel-2); color: var(--muted); }\n .seg button.on { background: var(--accent); color: #111; font-weight: 600; }\n #graph { display: none; overflow: auto; position: relative; }\n #graph.on { display: block; } #timeline.off { display: none; }\n #graph svg { display: block; min-width: 100%; }\n .gctl { position: sticky; top: 0; left: 0; z-index: 2; width: 100%; display: flex; gap: 12px; align-items: center; padding: 10px 16px; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(4px); font-size: 12px; color: var(--muted); flex-wrap: wrap; }\n .gctl label { display: inline-flex; gap: 4px; align-items: center; }\n .gnode { cursor: pointer; }\n .gnode rect, .gnode ellipse, .gnode polygon { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.2; }\n .gnode:hover rect, .gnode:hover ellipse, .gnode:hover polygon, .gnode.sel rect, .gnode.sel ellipse, .gnode.sel polygon { stroke: var(--accent); stroke-width: 2; }\n .gnode.k-task rect { stroke: var(--human); } .gnode.k-commit rect { stroke-dasharray: 3 2; } .gnode.k-pr rect { stroke: var(--ok); } .gnode.k-command rect { stroke: var(--system); }\n .gnode.actor.human ellipse { stroke: var(--human); } .gnode.actor.agent polygon { stroke: var(--agent); } .gnode.actor.system ellipse { stroke: var(--system); }\n .gnode text { fill: var(--text); font-size: 11.5px; font-family: var(--mono); pointer-events: none; }\n .gnode text.sub { fill: var(--muted); font-size: 10px; font-family: system-ui, sans-serif; }\n .gedge { fill: none; stroke: var(--muted); stroke-width: 1.2; opacity: .75; }\n .gedge.derived { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }\n .gedge.touched { stroke-dasharray: 3 3; opacity: .45; }\n .gedge.dim { opacity: .12; } .gnode.dim { opacity: .25; }\n .glabel { fill: var(--muted); font-size: 9.5px; }\n</style>\n</head>\n<body>\n<header>\n <h1>RE<span>TRACE</span></h1>\n <select id=\"project\" title=\"Project\"></select>\n <input id=\"q\" placeholder=\"search\u2026\" style=\"width:150px\" />\n <select id=\"ftype\"><option value=\"\">all actors</option><option value=\"human\">humans</option><option value=\"agent\">agents</option><option value=\"system\">system</option></select>\n <select id=\"faction\"><option value=\"\">any action</option></select>\n <span class=\"seg\"><button id=\"vTimeline\" class=\"on\">Timeline</button><button id=\"vGraph\">Graph</button></span>\n <span id=\"verify\" class=\"badge\"><span class=\"dot\"></span><span>verifying\u2026</span></span>\n <span id=\"status\" class=\"badge\" title=\"Capture completeness and causal coverage\">capture\u2026</span>\n <span id=\"sharedBadge\" class=\"badge\" style=\"display:none\" title=\"Read-only shared view\">\uD83D\uDD17 shared view</span>\n <button id=\"report\" title=\"Printable report (save as PDF)\">Report</button>\n <button id=\"export\" title=\"Download signed JSON bundle\">Export</button>\n <button id=\"share\" title=\"Create a read-only share link\">Share</button>\n <button id=\"refresh\" title=\"Reload\">\u21BB</button>\n <button id=\"toggleConn\" title=\"Connection settings\">\u2699</button>\n <span id=\"conn\"><input id=\"apiUrl\" placeholder=\"API URL (blank = same origin)\" /><input id=\"apiTok\" placeholder=\"token\" type=\"password\" /><button id=\"connect\" class=\"primary\">Connect</button><button id=\"loadJson\">Load JSON\u2026</button><input type=\"file\" id=\"file\" accept=\"application/json\" style=\"display:none\" /></span>\n</header>\n<div id=\"shareDlg\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;align-items:center;justify-content:center\">\n <div style=\"background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;width:min(520px,92vw)\">\n <h3 style=\"margin:0 0 10px;font-size:15px\">Create read-only share link</h3>\n <div class=\"link\" style=\"margin-bottom:8px\">Anyone with the link can view (not edit) the events in scope, verify the chain, and download the signed export / report.</div>\n <div style=\"display:grid;gap:8px\">\n <label>Scope <select id=\"shScope\"><option value=\"project\">whole project</option><option value=\"artifact\">current artifact filter</option></select></label>\n <label>Label <input id=\"shLabel\" placeholder=\"e.g. Jab counter \u2014 client review\" style=\"width:100%\"></label>\n <label>Expires <select id=\"shExp\"><option value=\"\">never</option><option value=\"7\">7 days</option><option value=\"30\">30 days</option><option value=\"90\">90 days</option></select></label>\n <div id=\"shResult\" class=\"link\" style=\"word-break:break-all\"></div>\n <div style=\"display:flex;gap:8px;justify-content:flex-end\"><button id=\"shCancel\">Close</button><button id=\"shCreate\" class=\"primary\">Create link</button></div>\n </div>\n </div>\n</div>\n<main>\n <section id=\"timeline\"><div class=\"empty\">Loading\u2026</div></section>\n <section id=\"graph\">\n <div class=\"gctl\">\n <span><b id=\"gStat\"></b></span>\n <label><input type=\"checkbox\" id=\"gActors\"> show people & agents</label>\n <label><input type=\"checkbox\" id=\"gFlow\" checked> causal flow edges</label>\n <span style=\"margin-left:auto\">\u2501 derived_from \u2192 causal flow \u2508 touched \u00B7 click a node to filter the timeline</span>\n <button id=\"gDot\" title=\"Download Graphviz DOT\">DOT</button><button id=\"gMmd\" title=\"Copy Mermaid\">Mermaid</button>\n </div>\n <div id=\"gsvg\"></div>\n </section>\n <aside id=\"detail\"><div class=\"empty\">Select an event to see who / what / when / where / why / how.</div></aside>\n</main>\n<script>\n(() => {\n const $ = (s) => document.querySelector(s);\n const state = { events: [], project: null, projects: [], sel: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true };\n const params = new URLSearchParams(location.search);\n if (params.get(\"order\") === \"asc\") state.newestFirst = false;\n let apiBase = params.get(\"api\") ?? \"\";\n let token = params.get(\"token\") ?? \"\";\n $(\"#apiUrl\").value = apiBase; $(\"#apiTok\").value = token;\n // Shared mode: /s/<id> \u2192 all reads go through /s/<id>/*, scope locked server-side\n const shareMatch = location.pathname.match(/\\/s\\/([^/]+)/);\n const shareId = shareMatch ? shareMatch[1] : null;\n if (shareId) {\n document.body.classList.add(\"shared\");\n $(\"#sharedBadge\").style.display = \"\";\n [\"#share\", \"#toggleConn\", \"#project\"].forEach((s) => ($(s).style.display = \"none\"));\n }\n const P = {\n projects: () => shareId ? api(`/s/${shareId}/meta`).then((m) => [m.project]) : api(\"/projects\"),\n events: (p) => shareId ? api(`/s/${shareId}/events?limit=5000`) : api(`/projects/${encodeURIComponent(p)}/events?limit=5000`),\n verify: (p) => shareId ? api(`/s/${shareId}/verify`) : api(`/projects/${encodeURIComponent(p)}/verify`),\n status: (p) => shareId ? Promise.resolve(null) : api(`/projects/${encodeURIComponent(p)}/status`),\n exportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/export` : `${apiBase}/projects/${encodeURIComponent(p)}/export?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n reportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/report` : `${apiBase}/projects/${encodeURIComponent(p)}/report?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n };\n\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]));\n const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const fmtDay = (iso) => new Date(iso).toLocaleDateString([], { weekday: \"short\", year: \"numeric\", month: \"short\", day: \"numeric\" });\n const actorName = (a) => a.display_name || a.id;\n const fmtDur = (ms) => ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` : ms < 3600000 ? `${Math.round(ms / 60000)} min` : `${(ms / 3600000).toFixed(1)} h`;\n const actorTag = (a) => a.type === \"agent\" ? (a.model ? ` \u00B7 ${a.model}` : \" \u00B7 agent\") : a.type === \"system\" ? \" \u00B7 system\" : \"\";\n const verbOf = (e) => e.action === \"other\" ? (e.action_detail || \"acted on\") : e.action;\n const artLabel = (a) => a.label || a.id;\n // PROV role of a ref within its event: in (used) \u00B7 out (generated) \u00B7 in/out (both); no marker when unspecified.\n const roleMark = (r) => r === \"used\" ? \"in\" : r === \"generated\" ? \"out\" : r === \"both\" ? \"in/out\" : \"\";\n const roleTitle = (r) => r === \"used\" ? \" \u00B7 used (input)\" : r === \"generated\" ? \" \u00B7 generated (output)\" : r === \"both\" ? \" \u00B7 used and generated\" : \"\";\n const roleTag = (a) => roleMark(a.role) ? `<i class=\"role\">${roleMark(a.role)}</i>` : \"\";\n const whereText = (loc) => loc ? [loc.system, loc.environment, loc.path || loc.url, loc.device, loc.client].filter(Boolean).join(\" \u00B7 \") : \"\";\n // Second WHERE line: the run-identity fields. Deliberately NOT in whereText, which the timeline reuses and which\n // must stay a readable one-liner. session/workspace are clickable \u2014 they drop into the search box, which already\n // matches the whole event JSON, so one click shows everything from that agent run or that isolated worktree.\n const findable = (v) => `<span class=\"chip\" data-q=\"${esc(v)}\" title=\"search for ${esc(v)}\">${esc(v)}</span>`;\n const whereExtra = (loc) => {\n const bits = [];\n if (loc.ide) bits.push(`in <b>${esc(loc.ide)}</b>`);\n if (loc.workspace) bits.push(`workspace ${findable(loc.workspace)}`);\n if (loc.session) bits.push(`session ${findable(loc.session)}`);\n if (loc.surface) bits.push(loc.surface === \"tty\" ? \"at a terminal\" : \"no terminal \u00B7 agent-driven\");\n return bits.length ? `<div class=\"link\">${bits.join(\" \u00B7 \")}</div>` : \"\";\n };\n // Latest known label per artifact id: the label on the last event (by seq) that carries one.\n // Used wherever the UI names an artifact; the event detail pane keeps each event's as-at label.\n const latestLabels = (events) => { const m = new Map(); for (const e of [...events].sort((a, b) => a.seq - b.seq)) for (const a of e.artifacts) if (a.label) m.set(a.id, a.label); return m; };\n\n async function api(path) {\n const url = (apiBase || \"\") + path;\n const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);\n return res.json();\n }\n\n async function loadProjects() {\n state.projects = await P.projects();\n const sel = $(\"#project\");\n sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\") || \"<option value=''>(no projects yet)</option>\";\n const want = params.get(\"project\");\n state.project = state.projects.includes(want) ? want : state.projects[0] ?? null;\n if (state.project) sel.value = state.project;\n }\n\n async function loadEvents() {\n if (state.offline) return render();\n if (!state.project) { state.events = []; return render(); }\n const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);\n state.events = events; state.verify = verify; state.status = status;\n render();\n }\n\n function localVerifyBadge() {\n const b = $(\"#verify\"); const v = state.verify;\n if (!v) { b.className = \"badge\"; b.lastElementChild.textContent = state.offline ? \"offline snapshot\" : \"not verified\"; return; }\n b.className = \"badge \" + (v.ok ? \"ok\" : \"bad\");\n b.lastElementChild.textContent = v.ok ? `chain intact \u00B7 ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;\n b.title = v.reason || \"\";\n }\n\n function statusBadge() {\n const b = $(\"#status\"), s = state.status;\n if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? \"none\" : \"\"; b.textContent = \"capture unknown\"; return; }\n b.style.display = \"\";\n const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;\n b.className = \"badge \" + (gaps ? \"bad\" : \"ok\");\n b.textContent = `${s.causality.coverage_pct}% causal \u00B7 ${s.capture.unlinked_commits} unlinked commits`;\n b.title = `${s.capture.instructions_without_followup} instructions without follow-up \u00B7 ${s.capture.agent_events_without_model} agent events missing model \u00B7 ${s.capture.artifact_refs_without_role} artifact refs missing role`;\n }\n\n function filtered(opts = {}) {\n const q = $(\"#q\").value.trim().toLowerCase();\n const t = $(\"#ftype\").value; const a = $(\"#faction\").value;\n return state.events.filter((e) => {\n if (t && e.actor.type !== t) return false;\n if (a && e.action !== a) return false;\n if (!opts.ignoreArtifact && state.filterArtifact && !e.artifacts.some((x) => x.id === state.filterArtifact)) return false;\n if (state.filterActor && e.actor.id !== state.filterActor) return false;\n if (q && !JSON.stringify(e).toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n function render() {\n localVerifyBadge();\n statusBadge();\n const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);\n const acts = [...new Set(state.events.map((e) => e.action))].sort();\n const fa = $(\"#faction\"); const cur = fa.value;\n fa.innerHTML = `<option value=\"\">any action</option>` + acts.map((a) => `<option ${a === cur ? \"selected\" : \"\"}>${esc(a)}</option>`).join(\"\");\n const list = filtered(); if (state.newestFirst) list.reverse();\n const humans = new Set(state.events.filter((e) => e.actor.type === \"human\").map((e) => e.actor.id)).size;\n const agents = new Set(state.events.filter((e) => e.actor.type === \"agent\").map((e) => e.actor.id)).size;\n const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;\n let html = `<div class=\"stat\"><span><b>${state.events.length}</b> events</span><span><b>${humans}</b> humans</span><span><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>` +\n (state.filterArtifact ? `<span>artifact: <span class=\"chip\" data-clear=\"artifact\" title=\"${esc(state.filterArtifact)}\">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} \u2715</span></span>` : \"\") +\n (state.filterActor ? `<span>actor: <span class=\"chip\" data-clear=\"actor\">${esc(state.filterActor)} \u2715</span></span>` : \"\") + `</div>`;\n html += `<div class=\"legend\"><span><i class=\"dot human\" style=\"display:inline-block\"></i> human</span><span><i class=\"dot agent\" style=\"display:inline-block;border-radius:2px\"></i> agent</span><span><i class=\"dot system\" style=\"display:inline-block\"></i> system</span><span style=\"margin-left:auto\"><span class=\"chip\" data-order=\"1\" title=\"flip order\">${state.newestFirst ? \"newest first \u25BE\" : \"oldest first \u25B4\"}</span></span></div>`;\n if (!list.length) html += `<div class=\"empty\">No events${state.events.length ? \" match these filters\" : \" yet \u2014 log one with the MCP tool <code>retrace_log</code> or POST /events\"}.</div>`;\n let lastDay = \"\";\n for (const e of list) {\n const day = fmtDay(e.timestamp);\n if (day !== lastDay) { html += `<div class=\"day\">${esc(day)}</div>`; lastDay = day; }\n const MAX = 6; const open = state.expanded.has(e.id); const shown = open ? e.artifacts : e.artifacts.slice(0, MAX); const extra = e.artifacts.length - shown.length;\n const arts = shown.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(labelOf(a))}</span>`).join(\"\") +\n (extra > 0 ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"show all ${e.artifacts.length} artifacts\">+${extra} more</span>` : open && e.artifacts.length > MAX ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"collapse\">show less</span>` : \"\");\n const where = e.location?.path || e.location?.url || e.location?.system || \"\";\n const how = e.method?.tool ? `via ${esc(e.method.tool)}` : \"\";\n html += `<div class=\"ev ${e.actor.type} ${state.sel === e.id ? \"sel\" : \"\"}\" data-id=\"${esc(e.id)}\">\n <div class=\"t\">${fmtTime(e.timestamp)}<br><span class=\"link\">#${e.seq}</span></div>\n <div class=\"rail\"><div class=\"node\"></div></div>\n <div>\n <div><span class=\"who\" data-actor=\"${esc(e.actor.id)}\">${esc(actorName(e.actor))}</span><span class=\"link\">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? \" \u00B7 for \" + esc(e.actor.on_behalf_of) : \"\"}</span> <span class=\"verb\">${esc(verbOf(e))}</span> <span class=\"art\">${arts}</span> <span class=\"link\">${where ? \"@ \" + esc(where) + \" \" : \"\"}${how}</span></div>\n ${e.intent ? `<div class=\"why\">${esc(e.intent)}</div>` : \"\"}\n ${e.caused_by ? `<div class=\"link\">\u21B3 caused by <a href=\"#\" data-goto=\"${esc(e.caused_by)}\">${esc(e.caused_by.slice(0, 16))}\u2026</a></div>` : \"\"}\n </div></div>`;\n }\n $(\"#timeline\").innerHTML = html;\n if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;\n renderDetail();\n renderGraph();\n }\n\n function whyChain(id) {\n const byId = new Map(state.events.map((e) => [e.id, e]));\n const chain = []; const seen = new Set(); let cur = byId.get(id);\n while (cur && !seen.has(cur.id) && chain.length < 30) { seen.add(cur.id); chain.push(cur); cur = cur.caused_by ? byId.get(cur.caused_by) : null; }\n return chain;\n }\n function effects(id) { return state.events.filter((e) => e.caused_by === id); }\n\n function renderDetail() {\n const e = state.events.find((x) => x.id === state.sel);\n const d = $(\"#detail\");\n if (!e) { d.innerHTML = `<div class=\"empty\">Select an event to see who / what / when / where / why / how.</div>`; return; }\n const kv = (k, v, mono) => v ? `<div class=\"k\">${k}</div><div class=\"v ${mono ? \"mono\" : \"\"}\">${v}</div>` : \"\";\n // Every section always renders: absent data says \"(not recorded)\" instead of vanishing,\n // and a section that fails to render falls back on its own \u2014 it never blanks the rest of the pane.\n const sect = (k, fn, mono) => { let v; try { v = fn(); } catch { v = `<span class=\"link\">(could not render \u2014 see raw JSON)</span>`; } return kv(k, v || `<span class=\"link\">(not recorded)</span>`, mono); };\n let chainHtml, kidsHtml = \"\", kids = [];\n try {\n const chain = whyChain(e.id).slice(1); kids = effects(e.id);\n const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);\n chainHtml = parentMissing ? `<div class=\"link\" style=\"margin-bottom:12px\">caused by <span class=\"mono\">${esc(e.caused_by)}</span> \u2014 outside this view's scope</div>` : chain.length ? `<div class=\"why-chain\">` + chain.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">\u21B3 because</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))}${c.intent ? ` \u2014 <i>${esc(c.intent)}</i>` : \"\"} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : `<div class=\"link\" style=\"margin-bottom:12px\">no causal parent recorded \u2014 this is a root event</div>`;\n kidsHtml = kids.length ? `<div class=\"why-chain\">` + kids.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">\u2192 led to</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : \"\";\n } catch { chainHtml = `<div class=\"link\" style=\"margin-bottom:12px\">(could not render the causal chain \u2014 see raw JSON)</div>`; }\n let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = \"(not serializable)\"; }\n d.innerHTML = `\n <h2>Event #${e.seq}</h2>\n <div class=\"kv\">\n ${sect(\"Who\", () => `<span style=\"color:var(--${e.actor.type});font-weight:600\">${esc(actorName(e.actor))}</span> <span class=\"link\">${esc(e.actor.type)}${e.actor.model ? \" \u00B7 \" + esc(e.actor.model) : \"\"}${e.actor.version ? \" \" + esc(e.actor.version) : \"\"}${e.actor.on_behalf_of ? \"<br>on behalf of \" + esc(e.actor.on_behalf_of) : \"\"}</span>`)}\n ${sect(\"What\", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== \"file\" ? `<span class=\"link\">${esc(a.kind)}</span>` : \"\"}${a.derived_from?.length ? `<div class=\"link\">derived from ${esc(a.derived_from.join(\", \"))}</div>` : \"\"}`).join(\" \")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : \"\"}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : \"\"}${e.change?.after_hash ? `<div class=\"hash\">after ${esc(e.change.after_hash)}</div>` : \"\"}`)}\n ${sect(\"When\", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class=\"link\">\u00B7 took ${fmtDur(e.duration_ms)}</span>` : \"\"}<div class=\"link\">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}\n ${sect(\"Where\", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : \"\")}\n ${sect(\"Why\", () => e.intent ? esc(e.intent) : \"\")}\n ${sect(\"How\", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : \"\"}${e.method.automated != null ? `<span class=\"link\">${e.method.automated ? \"automated\" : \"manual\"}</span>` : \"\"}${e.method.instruction ? `<div class=\"link\">instruction: ${esc(e.method.instruction)}</div>` : \"\"}${e.method.tokens != null ? `<div class=\"link\">${e.method.tokens} tokens${e.method.cost_usd != null ? ` \u00B7 $${e.method.cost_usd}` : \"\"}</div>` : \"\"}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : \"\"}` : \"\")}\n ${sect(\"Tags\", () => e.tags?.length ? e.tags.map((t) => `<span class=\"chip\">${esc(t)}</span>`).join(\"\") : \"\")}\n </div>\n <h2>Causal chain</h2>${chainHtml}\n ${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : \"\"}\n <h2>Integrity</h2>\n <div class=\"kv\"><div class=\"k\">id</div><div class=\"v mono\">${esc(e.id)}</div><div class=\"k\">hash</div><div class=\"v hash\">${esc(e.hash)}</div><div class=\"k\">prev</div><div class=\"v hash\">${esc(e.prev_hash)}</div></div>\n <details><summary class=\"link\">raw JSON</summary><pre>${esc(raw)}</pre></details>`;\n }\n\n // ---------- lineage graph ----------\n function buildLineage(events, opts = {}) {\n const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));\n const sorted = [...events].sort((a, b) => a.seq - b.seq);\n const art = (id, label, kind, seq) => { let n = nodes.get(\"a:\" + id); if (!n) { n = { id, type: \"artifact\", label: label || id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} }; nodes.set(\"a:\" + id, n); } if (label) n.label = label; if (kind && !n.kind) n.kind = kind; return n; };\n const addEdge = (from, to, type, via) => { if (from === to) return; const k = `${type}|${from}|${to}`; let ed = edges.get(k); if (!ed) { ed = { from, to, type, weight: 0, via: [] }; edges.set(k, ed); } ed.weight++; if (ed.via.length < 5) ed.via.push(via); };\n for (const e of sorted) {\n for (const a of e.artifacts) {\n const n = art(a.id, a.label, a.kind, e.seq); n.events++; n.last_seq = e.seq; if (!n.actors.includes(e.actor.id)) n.actors.push(e.actor.id); n.actions[e.action] = (n.actions[e.action] || 0) + 1;\n for (const src of a.derived_from || []) { art(src, undefined, undefined, e.seq); addEdge(src, a.id, \"derived\", e.id); }\n if (opts.includeActors) { let u = nodes.get(\"u:\" + e.actor.id); if (!u) { u = { id: e.actor.id, type: \"actor\", label: actorName(e.actor), kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq }; nodes.set(\"u:\" + e.actor.id, u); } u.events++; addEdge(\"u:\" + e.actor.id, a.id, \"touched\", e.id); }\n }\n if (e.caused_by && opts.flow !== false) { const p = byId.get(e.caused_by); if (p) for (const pa of p.artifacts) for (const ca of e.artifacts) if (pa.id !== ca.id) addEdge(pa.id, ca.id, \"flow\", e.id); }\n }\n return { nodes: [...nodes.values()], edges: [...edges.values()] };\n }\n function layerLineage(l) {\n const ids = l.nodes.filter((n) => n.type === \"artifact\").map((n) => n.id);\n const inc = new Map(ids.map((id) => [id, []])); for (const e of l.edges) if (e.type !== \"touched\" && inc.has(e.to) && inc.has(e.from)) inc.get(e.to).push(e.from);\n const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq])); const layer = new Map(), visiting = new Set();\n const depth = (id) => { if (layer.has(id)) return layer.get(id); if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of inc.get(id) || []) if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0)) d = Math.max(d, depth(p) + 1); visiting.delete(id); layer.set(id, d); return d; };\n for (const id of ids) depth(id); return layer;\n }\n let lastLineage = null;\n function renderGraph() {\n if (!$(\"#graph\").classList.contains(\"on\")) return;\n const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding\n const l = buildLineage(events, { includeActors: $(\"#gActors\").checked, flow: $(\"#gFlow\").checked });\n lastLineage = l;\n const arts = l.nodes.filter((n) => n.type === \"artifact\"), actors = l.nodes.filter((n) => n.type === \"actor\");\n $(\"#gStat\").textContent = `${arts.length} artifacts \u00B7 ${l.edges.filter((e) => e.type !== \"touched\").length} lineage edges${actors.length ? ` \u00B7 ${actors.length} actors` : \"\"}`;\n if (!arts.length) { $(\"#gsvg\").innerHTML = `<div class=\"empty\">No artifacts in view.</div>`; return; }\n const layer = layerLineage(l);\n const W = 190, H = 46, GX = 70, GY = 16, PAD = 24, ACT_W = 140;\n const cw0 = $(\"#graph\").clientWidth || 1000;\n // connected artifacts: columns = layers, rows by first_seq. Isolated artifacts (no lineage edges): packed grid below.\n const linked = new Set(); for (const e of l.edges) if (e.type !== \"touched\") { linked.add(e.from); linked.add(e.to); }\n const conn = arts.filter((n) => linked.has(n.id)), iso = arts.filter((n) => !linked.has(n.id));\n const cols = new Map();\n for (const n of conn) { const c = layer.get(n.id) || 0; if (!cols.has(c)) cols.set(c, []); cols.get(c).push(n); }\n for (const arr of cols.values()) arr.sort((a, b) => a.first_seq - b.first_seq);\n const pos = new Map(); let maxRows = 0;\n const offX = actors.length ? ACT_W + GX : 0;\n for (const [c, arr] of cols) { maxRows = Math.max(maxRows, arr.length); arr.forEach((n, i) => pos.set(n.id, { x: PAD + offX + c * (W + GX), y: PAD + i * (H + GY), w: W, h: H })); }\n actors.sort((a, b) => a.first_seq - b.first_seq).forEach((n, i) => pos.set(\"u:\" + n.id, { x: PAD, y: PAD + i * (H + GY), w: ACT_W, h: H }));\n maxRows = Math.max(maxRows, actors.length);\n let width = PAD * 2 + offX + Math.max(cols.size, 0) * (W + GX) - (cols.size ? GX : 0);\n let height = PAD * 2 + maxRows * (H + GY) - GY;\n let isoLabelY = 0;\n if (iso.length) {\n const perRow = Math.max(1, Math.floor((cw0 - PAD * 2 + 30) / (W + 30)));\n const y0 = (conn.length || actors.length) ? height + 10 : PAD; isoLabelY = y0 + 12;\n iso.sort((a, b) => b.events - a.events).forEach((n, i) => pos.set(n.id, { x: PAD + (i % perRow) * (W + 30), y: y0 + 24 + Math.floor(i / perRow) * (H + GY), w: W, h: H }));\n const rows = Math.ceil(iso.length / perRow);\n height = y0 + 24 + rows * (H + GY) - GY + PAD; width = Math.max(width, PAD * 2 + Math.min(iso.length, perRow) * (W + 30) - 30);\n }\n const trunc = (t, n) => (t.length > n ? \"\u2026\" + t.slice(-(n - 1)) : t);\n const cw = $(\"#graph\").clientWidth || width; const fit = width > cw && width < cw * 1.5; // gently scale to fit when slightly too wide, otherwise scroll\n let svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" ${fit ? `width=\"100%\"` : `width=\"${width}\"`} height=\"${fit ? Math.round(Math.max(height, 120) * cw / width) : Math.max(height, 120)}\" viewBox=\"0 0 ${width} ${Math.max(height, 120)}\" preserveAspectRatio=\"xMinYMin meet\"><defs><marker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"context-stroke\"/></marker></defs>`;\n const selArt = state.filterArtifact;\n const connected = new Set(); if (selArt) for (const e of l.edges) { if (e.from === selArt) connected.add(e.to); if (e.to === selArt) connected.add(e.from); }\n for (const e of l.edges) {\n const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) continue;\n const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2; const dx = Math.max(40, (x2 - x1) / 2);\n const back = x2 <= x1; const d = back ? `M${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` : `M${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n const dim = selArt && e.from !== selArt && e.to !== selArt ? \" dim\" : \"\";\n svg += `<path class=\"gedge ${e.type}${dim}\" d=\"${d}\" marker-end=\"url(#arr)\"><title>${esc(e.type)} \u00D7${e.weight}\\n${esc(e.from)} \u2192 ${esc(e.to)}</title></path>`;\n if (e.type === \"flow\" && e.weight > 1) svg += `<text class=\"glabel\" x=\"${(x1 + x2) / 2}\" y=\"${(y1 + y2) / 2 - 4}\" text-anchor=\"middle\">\u00D7${e.weight}</text>`;\n }\n if (iso.length) svg += `<text class=\"glabel\" x=\"${PAD}\" y=\"${isoLabelY}\" style=\"font-size:11px\">${conn.length ? \"no lineage links yet\" : \"artifacts (no lineage links yet \u2014 add derived_from or caused_by to connect them)\"} \u00B7 ${iso.length}</text>`;\n for (const n of arts) {\n const p = pos.get(n.id); const dim = selArt && n.id !== selArt && !connected.has(n.id) ? \" dim\" : \"\"; const sel = selArt === n.id ? \" sel\" : \"\";\n const sub = `${n.events} ev \u00B7 ${n.actors.length} actor${n.actors.length === 1 ? \"\" : \"s\"}${n.kind ? \" \u00B7 \" + n.kind : \"\"}`;\n svg += `<g class=\"gnode k-${esc(n.kind || \"x\")}${dim}${sel}\" data-art=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\"><rect width=\"${p.w}\" height=\"${p.h}\" rx=\"6\"/><text x=\"10\" y=\"18\">${esc(trunc(n.label, 26))}</text><text class=\"sub\" x=\"10\" y=\"35\">${esc(sub)}</text><title>${esc(n.id)}\\n${esc(Object.entries(n.actions).map(([k, v]) => k + \" \u00D7\" + v).join(\", \"))}\\nby ${esc(n.actors.join(\", \"))}</title></g>`;\n }\n for (const n of actors) {\n const p = pos.get(\"u:\" + n.id); const shape = n.kind === \"agent\" ? `<polygon points=\"10,0 ${p.w - 10},0 ${p.w},${p.h / 2} ${p.w - 10},${p.h} 10,${p.h} 0,${p.h / 2}\"/>` : `<ellipse cx=\"${p.w / 2}\" cy=\"${p.h / 2}\" rx=\"${p.w / 2}\" ry=\"${p.h / 2}\"/>`;\n svg += `<g class=\"gnode actor ${esc(n.kind)}\" data-actor=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\">${shape}<text x=\"${p.w / 2}\" y=\"20\" text-anchor=\"middle\">${esc(trunc(n.label, 18))}</text><text class=\"sub\" x=\"${p.w / 2}\" y=\"35\" text-anchor=\"middle\">${esc(n.kind)} \u00B7 ${n.events} ev</text><title>${esc(n.id)}</title></g>`;\n }\n svg += `</svg>`;\n $(\"#gsvg\").innerHTML = svg;\n }\n function setView(v) { const g = v === \"graph\"; $(\"#graph\").classList.toggle(\"on\", g); $(\"#timeline\").classList.toggle(\"off\", g); $(\"#vGraph\").classList.toggle(\"on\", g); $(\"#vTimeline\").classList.toggle(\"on\", !g); if (g) renderGraph(); }\n $(\"#vTimeline\").addEventListener(\"click\", () => setView(\"timeline\"));\n $(\"#vGraph\").addEventListener(\"click\", () => setView(\"graph\"));\n $(\"#gActors\").addEventListener(\"change\", renderGraph); $(\"#gFlow\").addEventListener(\"change\", renderGraph);\n function lineageDot(l) { const q = JSON.stringify; const out = [\"digraph retrace {\", \" rankdir=LR; node [fontname=Helvetica, fontsize=10];\"]; for (const n of l.nodes) out.push(` ${q((n.type === \"actor\" ? \"u:\" : \"\") + n.id)} [label=${q(n.label + (n.type === \"artifact\" ? \"\\\\n\" + n.events + \" ev\" : \"\"))}, shape=${n.type === \"actor\" ? \"ellipse\" : \"box\"}${n.type === \"actor\" ? \", style=dashed\" : \"\"}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === \"derived\" ? \" [penwidth=2]\" : e.type === \"touched\" ? \" [style=dashed,color=gray]\" : \"\"};`); out.push(\"}\"); return out.join(\"\\n\"); }\n function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, \"n\" + i++); return ids.get(s); }; const out = [\"graph LR\"]; for (const n of l.nodes) { const k = (n.type === \"actor\" ? \"u:\" : \"\") + n.id; const lbl = (n.label + (n.type === \"artifact\" ? ` (${n.events})` : \"\")).replace(/\"/g, \"'\"); out.push(n.type === \"actor\" ? ` ${nid(k)}([\"${lbl}\"])` : ` ${nid(k)}[\"${lbl}\"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === \"derived\" ? \"==>\" : e.type === \"flow\" ? \"-->\" : \"-.->\"} ${nid(e.to)}`); return out.join(\"\\n\"); }\n $(\"#gDot\").addEventListener(\"click\", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: \"text/vnd.graphviz\" }), `retrace-${state.project}-lineage.dot`));\n $(\"#gMmd\").addEventListener(\"click\", () => { if (!lastLineage) return; const t = lineageMermaid(lastLineage); navigator.clipboard?.writeText(t); $(\"#gMmd\").textContent = \"copied\"; setTimeout(() => ($(\"#gMmd\").textContent = \"Mermaid\"), 1200); });\n if (params.get(\"view\") === \"graph\") setTimeout(() => setView(\"graph\"), 0);\n\n // events\n document.addEventListener(\"click\", (ev) => {\n const t = ev.target.closest(\"[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],.ev\");\n if (!t) return;\n if (t.dataset.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }\n if (t.dataset.expand) { ev.stopPropagation(); const id = t.dataset.expand; state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); return render(); }\n if (t.dataset.art) { state.filterArtifact = t.dataset.art; ev.stopPropagation(); return render(); }\n if (t.dataset.actor) { state.filterActor = t.dataset.actor; ev.stopPropagation(); return render(); }\n if (t.dataset.q) { $(\"#q\").value = t.dataset.q; ev.stopPropagation(); return render(); }\n if (t.dataset.goto) { ev.preventDefault(); state.sel = t.dataset.goto; state.filterArtifact = null; state.filterActor = null; render(); document.querySelector(`.ev[data-id=\"${CSS.escape(state.sel)}\"]`)?.scrollIntoView({ block: \"center\", behavior: \"smooth\" }); return; }\n if (t.dataset.clear) { if (t.dataset.clear === \"artifact\") state.filterArtifact = null; else state.filterActor = null; return render(); }\n if (t.classList.contains(\"ev\")) { state.sel = t.dataset.id; render(); }\n });\n $(\"#project\").addEventListener(\"change\", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });\n [\"#q\", \"#ftype\", \"#faction\"].forEach((s) => $(s).addEventListener(\"input\", render));\n $(\"#refresh\").addEventListener(\"click\", () => boot());\n $(\"#report\").addEventListener(\"click\", () => window.open(P.reportUrl(state.project, state.filterArtifact), \"_blank\"));\n $(\"#export\").addEventListener(\"click\", async () => {\n if (state.offline) { const blob = new Blob([JSON.stringify({ format: \"retrace-export/1\", scope: { project: state.project }, events: state.events }, null, 2)], { type: \"application/json\" }); return dl(blob, `${state.project}-events.json`); }\n const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });\n dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? \"-\" + state.filterArtifact.replace(/[^\\w.-]+/g, \"_\") : \"\"}.json`);\n });\n function dl(blob, name) { const a = document.createElement(\"a\"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }\n $(\"#share\").addEventListener(\"click\", () => { $(\"#shScope\").value = state.filterArtifact ? \"artifact\" : \"project\"; $(\"#shScope\").querySelector('[value=artifact]').disabled = !state.filterArtifact; $(\"#shResult\").textContent = \"\"; $(\"#shareDlg\").style.display = \"flex\"; });\n $(\"#shCancel\").addEventListener(\"click\", () => ($(\"#shareDlg\").style.display = \"none\"));\n $(\"#shCreate\").addEventListener(\"click\", async () => {\n const body = { artifact_id: $(\"#shScope\").value === \"artifact\" ? state.filterArtifact : undefined, label: $(\"#shLabel\").value.trim() || undefined, expires_in_days: Number($(\"#shExp\").value) || undefined };\n try {\n const res = await fetch(`${apiBase}/projects/${encodeURIComponent(state.project)}/share`, { method: \"POST\", headers: { \"content-type\": \"application/json\", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });\n const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);\n const u = r.url.startsWith(\"http\") ? r.url : location.origin + r.url;\n $(\"#shResult\").innerHTML = `Link: <a href=\"${esc(u)}\" target=\"_blank\">${esc(u)}</a> <button id=\"shCopy\">copy</button><br>Report: <a href=\"${esc(u)}/report\" target=\"_blank\">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : \"\"}`;\n $(\"#shCopy\").addEventListener(\"click\", () => navigator.clipboard?.writeText(u));\n } catch (e) { $(\"#shResult\").textContent = \"Failed: \" + e.message; }\n });\n $(\"#toggleConn\").addEventListener(\"click\", () => $(\"#conn\").classList.toggle(\"open\"));\n $(\"#connect\").addEventListener(\"click\", () => { apiBase = $(\"#apiUrl\").value.trim().replace(/\\/$/, \"\"); token = $(\"#apiTok\").value.trim(); state.offline = false; boot(); });\n $(\"#loadJson\").addEventListener(\"click\", () => $(\"#file\").click());\n $(\"#file\").addEventListener(\"change\", async (e) => {\n const f = e.target.files[0]; if (!f) return;\n const data = JSON.parse(await f.text());\n const events = Array.isArray(data) ? data : data.events ?? [];\n state.offline = true; state.events = events.sort((a, b) => a.seq - b.seq); state.verify = null; state.status = null;\n state.projects = [...new Set(events.map((x) => x.project))]; state.project = state.projects[0] ?? null;\n $(\"#project\").innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\");\n render();\n });\n function showErr(err) { $(\"#timeline\").innerHTML = `<div class=\"empty\">Could not load: ${esc(err.message)}<br><br>Open \u2699 to set the API URL / token, or load a JSON export.</div>`; $(\"#conn\").classList.add(\"open\"); }\n async function boot() { try { await loadProjects(); await loadEvents(); } catch (err) { showErr(err); } }\n boot();\n setInterval(() => { if (!state.offline && document.visibilityState === \"visible\") loadEvents().catch(() => {}); }, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
|
1
|
+
export declare const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Retrace</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a7;\n --human: #f2a93b; --agent: #5aa9ff; --system: #9aa3b5; --ok: #3ccb7f; --bad: #ff5c6c; --accent: #c7b6ff;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n @media (prefers-color-scheme: light) {\n :root { --bg: #f6f7fa; --panel: #ffffff; --panel-2: #f0f2f6; --line: #dfe3ea; --text: #16181d; --muted: #616a7d;\n --human: #c77800; --agent: #1f6fe0; --system: #6b7385; --ok: #178f4f; --bad: #d1303f; --accent: #6b4fd8; }\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }\n header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 8px 0 0; letter-spacing: .04em; }\n header h1 span { color: var(--accent); }\n select, input, button { font: inherit; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }\n input { min-width: 140px; }\n header button { padding: 6px 9px; }\n button { cursor: pointer; }\n button.primary { background: var(--accent); color: #111; border-color: transparent; font-weight: 600; }\n .badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); background: var(--panel-2); }\n .badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }\n .badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }\n .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }\n main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; height: calc(100vh - 53px); }\n @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; } #detail { border-left: 0; border-top: 1px solid var(--line); } }\n #timeline { overflow: auto; padding: 16px 20px 40px; }\n #detail { border-left: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 16px; }\n .legend { display: flex; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }\n .legend .dot.human { background: var(--human); } .legend .dot.agent { background: var(--agent); } .legend .dot.system { background: var(--system); }\n .day { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 8px 0; }\n .ev { display: grid; grid-template-columns: 64px 18px 1fr; gap: 10px; align-items: start; padding: 8px 8px; border-radius: 8px; cursor: pointer; position: relative; }\n .ev:hover { background: var(--panel-2); }\n .ev.sel { background: var(--panel-2); outline: 1px solid var(--accent); }\n .ev .t { color: var(--muted); font-family: var(--mono); font-size: 12px; padding-top: 2px; }\n .ev .rail { position: relative; height: 100%; }\n .ev .rail::before { content: \"\"; position: absolute; left: 8px; top: -10px; bottom: -14px; width: 2px; background: var(--line); }\n .ev .node { position: absolute; left: 3px; top: 3px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--line); }\n .ev.human .node { background: var(--human); } .ev.agent .node { background: var(--agent); border-radius: 3px; } .ev.system .node { background: var(--system); }\n .ev .who { font-weight: 600; }\n .ev.human .who { color: var(--human); } .ev.agent .who { color: var(--agent); } .ev.system .who { color: var(--system); }\n .ev .verb { color: var(--accent); }\n .ev .art { font-family: var(--mono); font-size: 12.5px; }\n .ev .why { color: var(--muted); font-size: 12.5px; margin-top: 2px; }\n .ev .link { color: var(--muted); font-size: 11px; }\n .chip { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-right: 4px; cursor: pointer; }\n .chip:hover { border-color: var(--accent); }\n .chip.more { color: var(--accent); border-style: dashed; }\n .chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }\n /* Everything that LOOKS clickable must do something: these are the affordances the click handler honours. */\n .badge.click { cursor: pointer; } .badge.click:hover { border-color: var(--accent); }\n .who[data-actor] { cursor: pointer; } .who[data-actor]:hover { text-decoration: underline; }\n [data-ftype] { cursor: pointer; } [data-ftype]:hover { color: var(--text); } .legend [data-ftype].on { color: var(--text); font-weight: 600; }\n .stable { border-collapse: collapse; font-size: 12px; width: 100%; margin-bottom: 14px; }\n .stable td, .stable th { padding: 3px 8px 3px 0; text-align: left; vertical-align: top; }\n .stable th { color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }\n .num.bad { color: var(--bad); font-weight: 600; } .num.ok { color: var(--ok); }\n #toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; font-size: 13px; z-index: 30; box-shadow: 0 4px 16px rgba(0,0,0,.3); display: none; max-width: 90vw; }\n #toast.on { display: block; }\n button:disabled { opacity: .45; cursor: not-allowed; }\n #detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }\n .kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }\n .kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }\n .kv .v { word-break: break-word; }\n .kv .v.mono { font-family: var(--mono); font-size: 12px; }\n pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-family: var(--mono); font-size: 12px; overflow: auto; max-height: 240px; margin: 4px 0 0; white-space: pre-wrap; }\n .why-chain { border-left: 2px solid var(--line); padding-left: 10px; margin: 6px 0 14px 4px; }\n .why-chain .step { margin: 6px 0; font-size: 13px; }\n .why-chain .step .who { font-weight: 600; }\n .why-chain .step.human .who { color: var(--human); } .why-chain .step.agent .who { color: var(--agent); }\n .why-chain .step .arrow { color: var(--muted); font-size: 11px; }\n .empty { color: var(--muted); padding: 40px 0; text-align: center; }\n .stat { display: flex; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }\n .stat b { color: var(--text); font-weight: 600; }\n #conn { display: none; gap: 8px; align-items: center; }\n #conn.open { display: flex; }\n .hash { font-family: var(--mono); font-size: 11px; color: var(--muted); }\n a { color: var(--accent); }\n .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }\n .seg button { border: 0; border-radius: 0; padding: 6px 10px; background: var(--panel-2); color: var(--muted); }\n .seg button.on { background: var(--accent); color: #111; font-weight: 600; }\n #graph { display: none; overflow: auto; position: relative; }\n #graph.on { display: block; } #timeline.off { display: none; }\n #graph svg { display: block; min-width: 100%; }\n .gctl { position: sticky; top: 0; left: 0; z-index: 2; width: 100%; display: flex; gap: 12px; align-items: center; padding: 10px 16px; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(4px); font-size: 12px; color: var(--muted); flex-wrap: wrap; }\n .gctl label { display: inline-flex; gap: 4px; align-items: center; }\n .gnode { cursor: pointer; }\n .gnode rect, .gnode ellipse, .gnode polygon { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.2; }\n .gnode:hover rect, .gnode:hover ellipse, .gnode:hover polygon, .gnode.sel rect, .gnode.sel ellipse, .gnode.sel polygon { stroke: var(--accent); stroke-width: 2; }\n .gnode.k-task rect { stroke: var(--human); } .gnode.k-commit rect { stroke-dasharray: 3 2; } .gnode.k-pr rect { stroke: var(--ok); } .gnode.k-command rect { stroke: var(--system); }\n .gnode.actor.human ellipse { stroke: var(--human); } .gnode.actor.agent polygon { stroke: var(--agent); } .gnode.actor.system ellipse { stroke: var(--system); }\n .gnode text { fill: var(--text); font-size: 11.5px; font-family: var(--mono); pointer-events: none; }\n .gnode text.sub { fill: var(--muted); font-size: 10px; font-family: system-ui, sans-serif; }\n .gedge { fill: none; stroke: var(--muted); stroke-width: 1.2; opacity: .75; }\n .gedge.derived { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }\n .gedge.touched { stroke-dasharray: 3 3; opacity: .45; }\n .gedge.dim { opacity: .12; } .gnode.dim { opacity: .25; }\n .glabel { fill: var(--muted); font-size: 9.5px; }\n</style>\n</head>\n<body>\n<header>\n <h1>RE<span>TRACE</span></h1>\n <select id=\"project\" title=\"Project\"></select>\n <input id=\"q\" placeholder=\"search\u2026\" style=\"width:150px\" />\n <select id=\"ftype\"><option value=\"\">all actors</option><option value=\"human\">humans</option><option value=\"agent\">agents</option><option value=\"system\">system</option></select>\n <select id=\"faction\"><option value=\"\">any action</option></select>\n <span class=\"seg\"><button id=\"vTimeline\" class=\"on\">Timeline</button><button id=\"vGraph\">Graph</button></span>\n <span id=\"verify\" class=\"badge click\" data-pane=\"status\" title=\"Chain integrity \u2014 click for details\"><span class=\"dot\"></span><span>verifying\u2026</span></span>\n <span id=\"status\" class=\"badge click\" data-pane=\"status\" title=\"Capture completeness and causal coverage \u2014 click for details\">capture\u2026</span>\n <span id=\"sharedBadge\" class=\"badge\" style=\"display:none\" title=\"Read-only shared view\">\uD83D\uDD17 shared view</span>\n <button id=\"report\" title=\"Printable report (save as PDF)\">Report</button>\n <button id=\"export\" title=\"Download signed JSON bundle\">Export</button>\n <button id=\"share\" title=\"Create a read-only share link\">Share</button>\n <button id=\"refresh\" title=\"Reload\">\u21BB</button>\n <button id=\"toggleConn\" title=\"Connection settings\">\u2699</button>\n <span id=\"conn\"><input id=\"apiUrl\" placeholder=\"API URL (blank = same origin)\" /><input id=\"apiTok\" placeholder=\"token\" type=\"password\" /><button id=\"connect\" class=\"primary\">Connect</button><button id=\"loadJson\">Load JSON\u2026</button><input type=\"file\" id=\"file\" accept=\"application/json\" style=\"display:none\" /></span>\n</header>\n<div id=\"shareDlg\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;align-items:center;justify-content:center\">\n <div style=\"background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;width:min(520px,92vw)\">\n <h3 style=\"margin:0 0 10px;font-size:15px\">Create read-only share link</h3>\n <div class=\"link\" style=\"margin-bottom:8px\">Anyone with the link can view (not edit) the events in scope, verify the chain, and download the signed export / report.</div>\n <div style=\"display:grid;gap:8px\">\n <label>Scope <select id=\"shScope\"><option value=\"project\">whole project</option><option value=\"artifact\">current artifact filter</option></select></label>\n <label>Label <input id=\"shLabel\" placeholder=\"e.g. Jab counter \u2014 client review\" style=\"width:100%\"></label>\n <label>Expires <select id=\"shExp\"><option value=\"\">never</option><option value=\"7\">7 days</option><option value=\"30\">30 days</option><option value=\"90\">90 days</option></select></label>\n <div id=\"shResult\" class=\"link\" style=\"word-break:break-all\"></div>\n <div style=\"display:flex;gap:8px;justify-content:flex-end\"><button id=\"shCancel\">Close</button><button id=\"shCreate\" class=\"primary\">Create link</button></div>\n </div>\n </div>\n</div>\n<div id=\"toast\" role=\"status\"></div>\n<main>\n <section id=\"timeline\"><div class=\"empty\">Loading\u2026</div></section>\n <section id=\"graph\">\n <div class=\"gctl\">\n <span><b id=\"gStat\"></b></span><span id=\"gFilters\" class=\"stat\" style=\"margin:0\"></span>\n <label><input type=\"checkbox\" id=\"gActors\"> show people & agents</label>\n <label><input type=\"checkbox\" id=\"gFlow\" checked> causal flow edges</label>\n <span style=\"margin-left:auto\">\u2501 derived_from \u2192 causal flow \u2508 touched \u00B7 click a node to highlight it and filter the timeline \u00B7 click it again to clear</span>\n <button id=\"gDot\" title=\"Download Graphviz DOT\">DOT</button><button id=\"gMmd\" title=\"Copy Mermaid\">Mermaid</button>\n </div>\n <div id=\"gsvg\"></div>\n </section>\n <aside id=\"detail\"><div class=\"empty\">Select an event to see who / what / when / where / why / how.</div></aside>\n</main>\n<script>\n(() => {\n const $ = (s) => document.querySelector(s);\n // pane: what the right-hand pane shows when no event is selected \u2014 \"status\" (chain + capture detail) or \"mermaid\" (copy fallback).\n const state = { events: [], project: null, projects: [], sel: null, pane: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true, fp: null };\n const params = new URLSearchParams(location.search);\n if (params.get(\"order\") === \"asc\") state.newestFirst = false;\n let apiBase = params.get(\"api\") ?? \"\";\n let token = params.get(\"token\") ?? \"\";\n $(\"#apiUrl\").value = apiBase; $(\"#apiTok\").value = token;\n // Shared mode: /s/<id> \u2192 all reads go through /s/<id>/*, scope locked server-side\n const shareMatch = location.pathname.match(/\\/s\\/([^/]+)/);\n const shareId = shareMatch ? shareMatch[1] : null;\n if (shareId) {\n document.body.classList.add(\"shared\");\n $(\"#sharedBadge\").style.display = \"\";\n [\"#share\", \"#toggleConn\", \"#project\"].forEach((s) => ($(s).style.display = \"none\"));\n }\n const P = {\n projects: () => shareId ? api(`/s/${shareId}/meta`).then((m) => [m.project]) : api(\"/projects\"),\n events: (p) => shareId ? api(`/s/${shareId}/events?limit=5000`) : api(`/projects/${encodeURIComponent(p)}/events?limit=5000`),\n verify: (p) => shareId ? api(`/s/${shareId}/verify`) : api(`/projects/${encodeURIComponent(p)}/verify`),\n status: (p) => shareId ? Promise.resolve(null) : api(`/projects/${encodeURIComponent(p)}/status`),\n exportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/export` : `${apiBase}/projects/${encodeURIComponent(p)}/export?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n reportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/report` : `${apiBase}/projects/${encodeURIComponent(p)}/report?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n };\n\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]));\n const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const fmtDay = (iso) => new Date(iso).toLocaleDateString([], { weekday: \"short\", year: \"numeric\", month: \"short\", day: \"numeric\" });\n const actorName = (a) => a.display_name || a.id;\n const fmtDur = (ms) => ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` : ms < 3600000 ? `${Math.round(ms / 60000)} min` : `${(ms / 3600000).toFixed(1)} h`;\n const actorTag = (a) => a.type === \"agent\" ? (a.model ? ` \u00B7 ${a.model}` : \" \u00B7 agent\") : a.type === \"system\" ? \" \u00B7 system\" : \"\";\n const verbOf = (e) => e.action === \"other\" ? (e.action_detail || \"acted on\") : e.action;\n const artLabel = (a) => a.label || a.id;\n // PROV role of a ref within its event: in (used) \u00B7 out (generated) \u00B7 in/out (both); no marker when unspecified.\n const roleMark = (r) => r === \"used\" ? \"in\" : r === \"generated\" ? \"out\" : r === \"both\" ? \"in/out\" : \"\";\n const roleTitle = (r) => r === \"used\" ? \" \u00B7 used (input)\" : r === \"generated\" ? \" \u00B7 generated (output)\" : r === \"both\" ? \" \u00B7 used and generated\" : \"\";\n const roleTag = (a) => roleMark(a.role) ? `<i class=\"role\">${roleMark(a.role)}</i>` : \"\";\n const whereText = (loc) => loc ? [loc.system, loc.environment, loc.path || loc.url, loc.device, loc.client].filter(Boolean).join(\" \u00B7 \") : \"\";\n // Second WHERE line: the run-identity fields. Deliberately NOT in whereText, which the timeline reuses and which\n // must stay a readable one-liner. session/workspace are clickable \u2014 they drop into the search box, which already\n // matches the whole event JSON, so one click shows everything from that agent run or that isolated worktree.\n const findable = (v) => `<span class=\"chip\" data-q=\"${esc(v)}\" title=\"search for ${esc(v)}\">${esc(v)}</span>`;\n const whereExtra = (loc) => {\n const bits = [];\n if (loc.ide) bits.push(`in <b>${esc(loc.ide)}</b>`);\n if (loc.workspace) bits.push(`workspace ${findable(loc.workspace)}`);\n if (loc.session) bits.push(`session ${findable(loc.session)}`);\n if (loc.surface) bits.push(loc.surface === \"tty\" ? \"at a terminal\" : \"no terminal \u00B7 agent-driven\");\n return bits.length ? `<div class=\"link\">${bits.join(\" \u00B7 \")}</div>` : \"\";\n };\n // Latest known label per artifact id: the label on the last event (by seq) that carries one.\n // Used wherever the UI names an artifact; the event detail pane keeps each event's as-at label.\n const latestLabels = (events) => { const m = new Map(); for (const e of [...events].sort((a, b) => a.seq - b.seq)) for (const a of e.artifacts) if (a.label) m.set(a.id, a.label); return m; };\n\n async function api(path) {\n const url = (apiBase || \"\") + path;\n const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);\n return res.json();\n }\n\n async function loadProjects() {\n state.projects = await P.projects();\n const sel = $(\"#project\");\n sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\") || \"<option value=''>(no projects yet)</option>\";\n // Keep the project the user switched to: \u21BB and Connect re-run this, and silently jumping back to the first\n // project made refresh look like it had discarded their selection.\n const want = params.get(\"project\"), keep = state.project;\n state.project = state.projects.includes(keep) ? keep : state.projects.includes(want) ? want : state.projects[0] ?? null;\n if (state.project) sel.value = state.project;\n }\n\n async function loadEvents() {\n if (state.offline) return render();\n if (!state.project) { state.events = []; return render(); }\n const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);\n // The 15 s poll must not rebuild the panes when nothing changed: that closed an open \"raw JSON\", reset the\n // timeline scroll and blanked a half-read detail. Same length + same head hash + same verify result = same view.\n const fp = `${state.project}:${events.length}:${events.at(-1)?.hash ?? \"\"}:${verify?.ok}:${verify?.checked}`;\n const same = fp === state.fp;\n state.events = events; state.verify = verify; state.status = status; state.fp = fp;\n if (same) { localVerifyBadge(); statusBadge(); if (state.pane === \"status\") renderDetail(); return; }\n render();\n }\n\n function localVerifyBadge() {\n const b = $(\"#verify\"); const v = state.verify;\n if (!v) { b.className = \"badge click\"; b.lastElementChild.textContent = state.offline ? \"offline snapshot\" : \"not verified\"; b.title = \"Chain not verified \u2014 click for details\"; return; }\n b.className = \"badge click \" + (v.ok ? \"ok\" : \"bad\");\n b.lastElementChild.textContent = v.ok ? `chain intact \u00B7 ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;\n b.title = (v.ok ? \"Every event re-hashed and every prev_hash link checked\" : v.reason || \"Chain broken\") + \" \u2014 click for details\";\n }\n\n function statusBadge() {\n const b = $(\"#status\"), s = state.status;\n if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? \"none\" : \"\"; b.className = \"badge click\"; b.textContent = \"capture unknown\"; b.title = \"No /status answer \u2014 click for details\"; return; }\n b.style.display = \"\";\n const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;\n b.className = \"badge click \" + (gaps ? \"bad\" : \"ok\");\n b.textContent = `${s.causality.coverage_pct}% causal \u00B7 ${s.capture.unlinked_commits} unlinked commits`;\n b.title = `${s.capture.instructions_without_followup} instructions without follow-up \u00B7 ${s.capture.agent_events_without_model} agent events missing model \u00B7 ${s.capture.artifact_refs_without_role} artifact refs missing role \u2014 click for details`;\n }\n\n function filtered(opts = {}) {\n const q = $(\"#q\").value.trim().toLowerCase();\n const t = $(\"#ftype\").value; const a = $(\"#faction\").value;\n return state.events.filter((e) => {\n if (t && e.actor.type !== t) return false;\n if (a && e.action !== a) return false;\n if (!opts.ignoreArtifact && state.filterArtifact && !e.artifacts.some((x) => x.id === state.filterArtifact)) return false;\n if (state.filterActor && e.actor.id !== state.filterActor) return false;\n if (q && !JSON.stringify(e).toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n // The active-filter chips (artifact \u00B7 actor \u00B7 search), each clearing itself on click. Shared by the timeline's\n // stat bar and the graph's control bar \u2014 a filter set from a graph node used to be invisible, and unclearable,\n // until you switched back to the timeline.\n const filterChipsHtml = (labels) => {\n const q = $(\"#q\").value.trim();\n return (state.filterArtifact ? `<span>artifact: <span class=\"chip\" data-clear=\"artifact\" title=\"${esc(state.filterArtifact)} \u2014 click to clear\">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} \u2715</span></span>` : \"\") +\n (state.filterActor ? `<span>actor: <span class=\"chip\" data-clear=\"actor\" title=\"click to clear\">${esc(state.filterActor)} \u2715</span></span>` : \"\") +\n (q ? `<span>search: <span class=\"chip\" data-clear=\"q\" title=\"click to clear\">${esc(q)} \u2715</span></span>` : \"\");\n };\n\n function render() {\n localVerifyBadge();\n statusBadge();\n const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);\n const inView = new Set(state.events.map((e) => e.id));\n const acts = [...new Set(state.events.map((e) => e.action))].sort();\n const fa = $(\"#faction\"); const cur = fa.value;\n fa.innerHTML = `<option value=\"\">any action</option>` + acts.map((a) => `<option ${a === cur ? \"selected\" : \"\"}>${esc(a)}</option>`).join(\"\");\n const list = filtered(); if (state.newestFirst) list.reverse();\n const humans = new Set(state.events.filter((e) => e.actor.type === \"human\").map((e) => e.actor.id)).size;\n const agents = new Set(state.events.filter((e) => e.actor.type === \"agent\").map((e) => e.actor.id)).size;\n const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;\n const ft = $(\"#ftype\").value;\n let html = `<div class=\"stat\"><span><b>${state.events.length}</b> events</span><span data-ftype=\"human\" title=\"show only humans\"><b>${humans}</b> humans</span><span data-ftype=\"agent\" title=\"show only agents\"><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>${filterChipsHtml(labels)}</div>`;\n // Legend entries filter by actor type (and show which one is active); the same select in the header stays in sync.\n const leg = (t, extra) => `<span data-ftype=\"${t}\" class=\"${ft === t ? \"on\" : \"\"}\" title=\"${ft === t ? `showing only ${t} \u2014 click to show all` : `show only ${t}`}\"><i class=\"dot ${t}\" style=\"display:inline-block${extra}\"></i> ${t}</span>`;\n html += `<div class=\"legend\">${leg(\"human\", \"\")}${leg(\"agent\", \";border-radius:2px\")}${leg(\"system\", \"\")}<span style=\"margin-left:auto\"><span class=\"chip\" data-order=\"1\" title=\"flip order\">${state.newestFirst ? \"newest first \u25BE\" : \"oldest first \u25B4\"}</span></span></div>`;\n if (!list.length) html += `<div class=\"empty\">${state.events.length ? `No events match these filters. <span class=\"chip\" data-clear=\"all\" title=\"clear search, actor, action and artifact filters\">clear all filters</span>` : \"No events yet \u2014 log one with the MCP tool <code>retrace_log</code> or POST /events.\"}</div>`;\n let lastDay = \"\";\n for (const e of list) {\n const day = fmtDay(e.timestamp);\n if (day !== lastDay) { html += `<div class=\"day\">${esc(day)}</div>`; lastDay = day; }\n const MAX = 6; const open = state.expanded.has(e.id); const shown = open ? e.artifacts : e.artifacts.slice(0, MAX); const extra = e.artifacts.length - shown.length;\n const arts = shown.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(labelOf(a))}</span>`).join(\"\") +\n (extra > 0 ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"show all ${e.artifacts.length} artifacts\">+${extra} more</span>` : open && e.artifacts.length > MAX ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"collapse\">show less</span>` : \"\");\n const where = e.location?.path || e.location?.url || e.location?.system || \"\";\n const how = e.method?.tool ? `via ${esc(e.method.tool)}` : \"\";\n html += `<div class=\"ev ${e.actor.type} ${state.sel === e.id ? \"sel\" : \"\"}\" data-id=\"${esc(e.id)}\">\n <div class=\"t\">${fmtTime(e.timestamp)}<br><span class=\"link\">#${e.seq}</span></div>\n <div class=\"rail\"><div class=\"node\"></div></div>\n <div>\n <div><span class=\"who\" data-actor=\"${esc(e.actor.id)}\">${esc(actorName(e.actor))}</span><span class=\"link\">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? \" \u00B7 for \" + esc(e.actor.on_behalf_of) : \"\"}</span> <span class=\"verb\">${esc(verbOf(e))}</span> <span class=\"art\">${arts}</span> <span class=\"link\">${where ? \"@ \" + esc(where) + \" \" : \"\"}${how}</span></div>\n ${e.intent ? `<div class=\"why\">${esc(e.intent)}</div>` : \"\"}\n ${e.caused_by ? `<div class=\"link\">\u21B3 caused by ${inView.has(e.caused_by) ? `<a href=\"#\" data-goto=\"${esc(e.caused_by)}\" title=\"jump to this event\">${esc(e.caused_by.slice(0, 16))}\u2026</a>` : `<span class=\"mono\" title=\"${esc(e.caused_by)} \u2014 not among the loaded events (outside this view's scope)\">${esc(e.caused_by.slice(0, 16))}\u2026 <i>(not in view)</i></span>`}</div>` : \"\"}\n </div></div>`;\n }\n $(\"#timeline\").innerHTML = html;\n if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;\n // Report and Share are server features; on a loaded JSON snapshot they can only fail, so say so instead of opening a dead tab.\n $(\"#report\").disabled = state.offline; $(\"#report\").title = state.offline ? \"Report needs a live ledger \u2014 not available for an offline snapshot\" : \"Printable report (save as PDF)\";\n $(\"#share\").disabled = state.offline; $(\"#share\").title = state.offline ? \"Share links need a live ledger \u2014 not available for an offline snapshot\" : \"Create a read-only share link\";\n renderDetail();\n renderGraph();\n }\n\n function whyChain(id) {\n const byId = new Map(state.events.map((e) => [e.id, e]));\n const chain = []; const seen = new Set(); let cur = byId.get(id);\n while (cur && !seen.has(cur.id) && chain.length < 30) { seen.add(cur.id); chain.push(cur); cur = cur.caused_by ? byId.get(cur.caused_by) : null; }\n return chain;\n }\n function effects(id) { return state.events.filter((e) => e.caused_by === id); }\n\n function renderDetail() {\n if (state.pane === \"status\") return renderStatus();\n if (state.pane === \"mermaid\") { $(\"#detail\").innerHTML = `<h2>Mermaid</h2><div class=\"link\" style=\"margin-bottom:6px\">The clipboard is not available here \u2014 copy the text below.</div><pre style=\"max-height:none\">${esc(state.mermaidText || \"\")}</pre>`; return; }\n const e = state.events.find((x) => x.id === state.sel);\n const d = $(\"#detail\");\n if (!e) { d.innerHTML = `<div class=\"empty\">Select an event to see who / what / when / where / why / how.</div>`; return; }\n const labels = latestLabels(state.events);\n const kv = (k, v, mono) => v ? `<div class=\"k\">${k}</div><div class=\"v ${mono ? \"mono\" : \"\"}\">${v}</div>` : \"\";\n // Every section always renders: absent data says \"(not recorded)\" instead of vanishing,\n // and a section that fails to render falls back on its own \u2014 it never blanks the rest of the pane.\n const sect = (k, fn, mono) => { let v; try { v = fn(); } catch { v = `<span class=\"link\">(could not render \u2014 see raw JSON)</span>`; } return kv(k, v || `<span class=\"link\">(not recorded)</span>`, mono); };\n let chainHtml, kidsHtml = \"\", kids = [];\n try {\n const chain = whyChain(e.id).slice(1); kids = effects(e.id);\n const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);\n const who = (c) => `<span class=\"who\" data-actor=\"${esc(c.actor.id)}\" title=\"${esc(c.actor.id)} \u2014 filter the timeline by this actor\">${esc(actorName(c.actor))}</span>`;\n chainHtml = parentMissing ? `<div class=\"link\" style=\"margin-bottom:12px\">caused by <span class=\"mono\">${esc(e.caused_by)}</span> \u2014 outside this view's scope</div>` : chain.length ? `<div class=\"why-chain\">` + chain.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">\u21B3 because</span> ${who(c)} ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))}${c.intent ? ` \u2014 <i>${esc(c.intent)}</i>` : \"\"} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\" title=\"jump to event #${c.seq}\">#${c.seq}</a></div>`).join(\"\") + `</div>` : `<div class=\"link\" style=\"margin-bottom:12px\">no causal parent recorded \u2014 this is a root event</div>`;\n kidsHtml = kids.length ? `<div class=\"why-chain\">` + kids.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">\u2192 led to</span> ${who(c)} ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\" title=\"jump to event #${c.seq}\">#${c.seq}</a></div>`).join(\"\") + `</div>` : \"\";\n } catch { chainHtml = `<div class=\"link\" style=\"margin-bottom:12px\">(could not render the causal chain \u2014 see raw JSON)</div>`; }\n let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = \"(not serializable)\"; }\n d.innerHTML = `\n <h2>Event #${e.seq}</h2>\n <div class=\"kv\">\n ${sect(\"Who\", () => `<span class=\"who\" data-actor=\"${esc(e.actor.id)}\" style=\"color:var(--${esc(e.actor.type)});font-weight:600\" title=\"${esc(e.actor.id)} \u2014 filter the timeline by this actor\">${esc(actorName(e.actor))}</span> <span class=\"link\">${esc(e.actor.type)}${e.actor.model ? \" \u00B7 \" + esc(e.actor.model) : \"\"}${e.actor.version ? \" \" + esc(e.actor.version) : \"\"}${e.actor.on_behalf_of ? \"<br>on behalf of \" + esc(e.actor.on_behalf_of) : \"\"}</span>`)}\n ${sect(\"What\", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== \"file\" ? `<span class=\"link\">${esc(a.kind)}</span>` : \"\"}${a.derived_from?.length ? `<div class=\"link\">derived from ${a.derived_from.map((s) => `<span class=\"chip\" data-art=\"${esc(s)}\" title=\"${esc(s)} \u2014 filter by this artifact\">${esc(labels.get(s) || s)}</span>`).join(\"\")}</div>` : \"\"}`).join(\" \")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : \"\"}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : \"\"}${e.change?.after_hash ? `<div class=\"hash\">after ${esc(e.change.after_hash)}</div>` : \"\"}`)}\n ${sect(\"When\", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class=\"link\">\u00B7 took ${fmtDur(e.duration_ms)}</span>` : \"\"}<div class=\"link\">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}\n ${sect(\"Where\", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : \"\")}\n ${sect(\"Why\", () => e.intent ? esc(e.intent) : \"\")}\n ${sect(\"How\", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : \"\"}${e.method.automated != null ? `<span class=\"link\">${e.method.automated ? \"automated\" : \"manual\"}</span>` : \"\"}${e.method.instruction ? `<div class=\"link\">instruction: ${esc(e.method.instruction)}</div>` : \"\"}${e.method.tokens != null ? `<div class=\"link\">${e.method.tokens} tokens${e.method.cost_usd != null ? ` \u00B7 $${e.method.cost_usd}` : \"\"}</div>` : \"\"}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : \"\"}` : \"\")}\n ${sect(\"Tags\", () => e.tags?.length ? e.tags.map((t) => `<span class=\"chip\" data-q=\"${esc(t)}\" title=\"search for ${esc(t)}\">${esc(t)}</span>`).join(\"\") : \"\")}\n </div>\n <h2>Causal chain</h2>${chainHtml}\n ${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : \"\"}\n <h2>Integrity</h2>\n <div class=\"kv\"><div class=\"k\">id</div><div class=\"v mono\">${esc(e.id)}</div><div class=\"k\">hash</div><div class=\"v hash\">${esc(e.hash)}</div><div class=\"k\">prev</div><div class=\"v hash\">${esc(e.prev_hash)}</div></div>\n <details><summary class=\"link\">raw JSON</summary><pre>${esc(raw)}</pre></details>`;\n }\n\n // The status pane: what the two header badges summarise, in full. Every number is the ledger's own /status and\n // /verify answer (packages/core/src/status.ts); the \"what closes it\" column states the capture rule, not a guess.\n function renderStatus() {\n const d = $(\"#detail\"), s = state.status, v = state.verify;\n const num = (x, bad = (n) => n > 0) => `<span class=\"num ${bad(x) ? \"bad\" : \"ok\"}\">${esc(x)}</span>`;\n const when = (iso) => iso ? esc(new Date(iso).toLocaleString()) : \"\u2014\";\n let chain;\n if (v) chain = v.ok ? `<span class=\"num ok\">intact</span> \u00B7 ${esc(v.checked)} events re-hashed, every prev_hash link checked` : `<span class=\"num bad\">BROKEN at #${esc(v.first_bad_seq)}</span>${v.reason ? `<div class=\"link\">${esc(v.reason)}</div>` : \"\"}`;\n else chain = `<span class=\"link\">${state.offline ? \"not verified \u2014 an offline snapshot cannot be checked against the ledger\" : \"not verified \u2014 /verify did not answer\"}</span>`;\n let html = `<h2>Project status${state.project ? ` \u00B7 ${esc(state.project)}` : \"\"}</h2><div class=\"kv\"><div class=\"k\">Chain</div><div class=\"v\">${chain}</div></div>`;\n if (!s?.capture || !s?.causality) {\n html += `<div class=\"link\">Capture and causal coverage come from the ledger's <span class=\"mono\">/status</span> endpoint, which ${shareId ? \"is not exposed on share links\" : state.offline ? \"an offline snapshot cannot reach\" : \"did not answer\"}.</div>`;\n d.innerHTML = html; return;\n }\n const c = s.capture, z = s.causality;\n html += `<h2>Causal coverage</h2><div class=\"kv\">\n <div class=\"k\">Rooted</div><div class=\"v\">${num(z.coverage_pct, (n) => n < 100)}% \u2014 ${esc(z.rooted_in_human_instruction)} of ${esc(z.eligible_events)} eligible events trace back, caused_by by caused_by, to a human instruction<div class=\"link\">eligible = every agent event plus every commit and merge</div></div>\n <div class=\"k\">Unlinked</div><div class=\"v\">${num(z.unlinked)} <span class=\"link\">chains that stop at an event with no caused_by</span></div>\n <div class=\"k\">Broken</div><div class=\"v\">${num(z.broken_links)} <span class=\"link\">chains whose caused_by names an event that is not in the ledger</span></div>\n </div>`;\n html += `<h2>Capture gaps</h2><table class=\"stable\"><tr><th>gap</th><th>count</th><th>what closes it</th></tr>\n <tr><td>commits without a causal root</td><td>${num(c.unlinked_commits)} / ${esc(c.commits)}</td><td>a <span class=\"mono\">Retrace-Caused-By</span> trailer naming the instruction</td></tr>\n <tr><td>agent events without a model</td><td>${num(c.agent_events_without_model)} / ${esc(c.agent_events)}</td><td><span class=\"mono\">actor.model</span> on every agent event</td></tr>\n <tr><td>instructions without follow-up</td><td>${num(c.instructions_without_followup)} / ${esc(c.instructions)}</td><td>at least one event whose caused_by is the instruction</td></tr>\n <tr><td>artifact refs without a role</td><td>${num(c.artifact_refs_without_role)} / ${esc(c.artifact_refs)}</td><td><span class=\"mono\">role</span>: used \u00B7 generated \u00B7 both on each ref</td></tr>\n </table>`;\n html += `<h2>Actors</h2><table class=\"stable\"><tr><th>who</th><th>events</th><th>models</th><th>last seen</th></tr>` +\n s.actors.map((a) => `<tr><td><span class=\"who\" data-actor=\"${esc(a.id)}\" style=\"color:var(--${esc(a.type)})\" title=\"${esc(a.id)} \u2014 filter the timeline by this actor\">${esc(a.id)}</span> <span class=\"link\">${esc(a.type)}</span></td><td>${esc(a.events)}</td><td class=\"mono\">${a.models?.length ? esc(a.models.join(\", \")) : a.type === \"agent\" ? `<span class=\"num bad\">none recorded</span>` : \"\u2014\"}</td><td>${when(a.last_seen)}</td></tr>`).join(\"\") + `</table>`;\n html += `<h2>Integrations</h2><table class=\"stable\"><tr><th>location.system</th><th>events</th><th>last seen</th></tr>` +\n s.integrations.map((i) => `<tr><td><span class=\"chip\" data-q=\"${esc(i.system)}\" title=\"search for ${esc(i.system)}\">${esc(i.system)}</span></td><td>${esc(i.events)}</td><td>${when(i.last_seen)}</td></tr>`).join(\"\") + `</table>`;\n html += `<div class=\"link\">${esc(s.events?.total ?? state.events.length)} events \u00B7 last event ${when(s.events?.last_event_at)} \u00B7 computed ${when(s.generated_at)}</div>`;\n d.innerHTML = html;\n }\n\n // ---------- lineage graph ----------\n function buildLineage(events, opts = {}) {\n const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));\n const sorted = [...events].sort((a, b) => a.seq - b.seq);\n const art = (id, label, kind, seq) => { let n = nodes.get(\"a:\" + id); if (!n) { n = { id, type: \"artifact\", label: label || id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} }; nodes.set(\"a:\" + id, n); } if (label) n.label = label; if (kind && !n.kind) n.kind = kind; return n; };\n const addEdge = (from, to, type, via) => { if (from === to) return; const k = `${type}|${from}|${to}`; let ed = edges.get(k); if (!ed) { ed = { from, to, type, weight: 0, via: [] }; edges.set(k, ed); } ed.weight++; if (ed.via.length < 5) ed.via.push(via); };\n for (const e of sorted) {\n for (const a of e.artifacts) {\n const n = art(a.id, a.label, a.kind, e.seq); n.events++; n.last_seq = e.seq; if (!n.actors.includes(e.actor.id)) n.actors.push(e.actor.id); n.actions[e.action] = (n.actions[e.action] || 0) + 1;\n for (const src of a.derived_from || []) { art(src, undefined, undefined, e.seq); addEdge(src, a.id, \"derived\", e.id); }\n if (opts.includeActors) { let u = nodes.get(\"u:\" + e.actor.id); if (!u) { u = { id: e.actor.id, type: \"actor\", label: actorName(e.actor), kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq }; nodes.set(\"u:\" + e.actor.id, u); } u.events++; addEdge(\"u:\" + e.actor.id, a.id, \"touched\", e.id); }\n }\n if (e.caused_by && opts.flow !== false) { const p = byId.get(e.caused_by); if (p) for (const pa of p.artifacts) for (const ca of e.artifacts) if (pa.id !== ca.id) addEdge(pa.id, ca.id, \"flow\", e.id); }\n }\n return { nodes: [...nodes.values()], edges: [...edges.values()] };\n }\n function layerLineage(l) {\n const ids = l.nodes.filter((n) => n.type === \"artifact\").map((n) => n.id);\n const inc = new Map(ids.map((id) => [id, []])); for (const e of l.edges) if (e.type !== \"touched\" && inc.has(e.to) && inc.has(e.from)) inc.get(e.to).push(e.from);\n const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq])); const layer = new Map(), visiting = new Set();\n const depth = (id) => { if (layer.has(id)) return layer.get(id); if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of inc.get(id) || []) if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0)) d = Math.max(d, depth(p) + 1); visiting.delete(id); layer.set(id, d); return d; };\n for (const id of ids) depth(id); return layer;\n }\n let lastLineage = null;\n function renderGraph() {\n if (!$(\"#graph\").classList.contains(\"on\")) return;\n const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding\n const l = buildLineage(events, { includeActors: $(\"#gActors\").checked, flow: $(\"#gFlow\").checked });\n lastLineage = l;\n $(\"#gFilters\").innerHTML = filterChipsHtml(latestLabels(state.events));\n const arts = l.nodes.filter((n) => n.type === \"artifact\"), actors = l.nodes.filter((n) => n.type === \"actor\");\n $(\"#gStat\").textContent = `${arts.length} artifacts \u00B7 ${l.edges.filter((e) => e.type !== \"touched\").length} lineage edges${actors.length ? ` \u00B7 ${actors.length} actors` : \"\"}`;\n if (!arts.length) { $(\"#gsvg\").innerHTML = `<div class=\"empty\">No artifacts in view.</div>`; return; }\n const layer = layerLineage(l);\n const W = 190, H = 46, GX = 70, GY = 16, PAD = 24, ACT_W = 140;\n const cw0 = $(\"#graph\").clientWidth || 1000;\n // connected artifacts: columns = layers, rows by first_seq. Isolated artifacts (no lineage edges): packed grid below.\n const linked = new Set(); for (const e of l.edges) if (e.type !== \"touched\") { linked.add(e.from); linked.add(e.to); }\n const conn = arts.filter((n) => linked.has(n.id)), iso = arts.filter((n) => !linked.has(n.id));\n const cols = new Map();\n for (const n of conn) { const c = layer.get(n.id) || 0; if (!cols.has(c)) cols.set(c, []); cols.get(c).push(n); }\n for (const arr of cols.values()) arr.sort((a, b) => a.first_seq - b.first_seq);\n const pos = new Map(); let maxRows = 0;\n const offX = actors.length ? ACT_W + GX : 0;\n for (const [c, arr] of cols) { maxRows = Math.max(maxRows, arr.length); arr.forEach((n, i) => pos.set(n.id, { x: PAD + offX + c * (W + GX), y: PAD + i * (H + GY), w: W, h: H })); }\n actors.sort((a, b) => a.first_seq - b.first_seq).forEach((n, i) => pos.set(\"u:\" + n.id, { x: PAD, y: PAD + i * (H + GY), w: ACT_W, h: H }));\n maxRows = Math.max(maxRows, actors.length);\n let width = PAD * 2 + offX + Math.max(cols.size, 0) * (W + GX) - (cols.size ? GX : 0);\n let height = PAD * 2 + maxRows * (H + GY) - GY;\n let isoLabelY = 0;\n if (iso.length) {\n const perRow = Math.max(1, Math.floor((cw0 - PAD * 2 + 30) / (W + 30)));\n const y0 = (conn.length || actors.length) ? height + 10 : PAD; isoLabelY = y0 + 12;\n iso.sort((a, b) => b.events - a.events).forEach((n, i) => pos.set(n.id, { x: PAD + (i % perRow) * (W + 30), y: y0 + 24 + Math.floor(i / perRow) * (H + GY), w: W, h: H }));\n const rows = Math.ceil(iso.length / perRow);\n height = y0 + 24 + rows * (H + GY) - GY + PAD; width = Math.max(width, PAD * 2 + Math.min(iso.length, perRow) * (W + 30) - 30);\n }\n const trunc = (t, n) => (t.length > n ? \"\u2026\" + t.slice(-(n - 1)) : t);\n const cw = $(\"#graph\").clientWidth || width; const fit = width > cw && width < cw * 1.5; // gently scale to fit when slightly too wide, otherwise scroll\n let svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" ${fit ? `width=\"100%\"` : `width=\"${width}\"`} height=\"${fit ? Math.round(Math.max(height, 120) * cw / width) : Math.max(height, 120)}\" viewBox=\"0 0 ${width} ${Math.max(height, 120)}\" preserveAspectRatio=\"xMinYMin meet\"><defs><marker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"context-stroke\"/></marker></defs>`;\n const selArt = state.filterArtifact;\n const connected = new Set(); if (selArt) for (const e of l.edges) { if (e.from === selArt) connected.add(e.to); if (e.to === selArt) connected.add(e.from); }\n for (const e of l.edges) {\n const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) continue;\n const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2; const dx = Math.max(40, (x2 - x1) / 2);\n const back = x2 <= x1; const d = back ? `M${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` : `M${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n const dim = selArt && e.from !== selArt && e.to !== selArt ? \" dim\" : \"\";\n svg += `<path class=\"gedge ${e.type}${dim}\" d=\"${d}\" marker-end=\"url(#arr)\"><title>${esc(e.type)} \u00D7${e.weight}\\n${esc(e.from)} \u2192 ${esc(e.to)}</title></path>`;\n if (e.type === \"flow\" && e.weight > 1) svg += `<text class=\"glabel\" x=\"${(x1 + x2) / 2}\" y=\"${(y1 + y2) / 2 - 4}\" text-anchor=\"middle\">\u00D7${e.weight}</text>`;\n }\n if (iso.length) svg += `<text class=\"glabel\" x=\"${PAD}\" y=\"${isoLabelY}\" style=\"font-size:11px\">${conn.length ? \"no lineage links yet\" : \"artifacts (no lineage links yet \u2014 add derived_from or caused_by to connect them)\"} \u00B7 ${iso.length}</text>`;\n for (const n of arts) {\n const p = pos.get(n.id); const dim = selArt && n.id !== selArt && !connected.has(n.id) ? \" dim\" : \"\"; const sel = selArt === n.id ? \" sel\" : \"\";\n const sub = `${n.events} ev \u00B7 ${n.actors.length} actor${n.actors.length === 1 ? \"\" : \"s\"}${n.kind ? \" \u00B7 \" + n.kind : \"\"}`;\n svg += `<g class=\"gnode k-${esc(n.kind || \"x\")}${dim}${sel}\" data-art=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\"><rect width=\"${p.w}\" height=\"${p.h}\" rx=\"6\"/><text x=\"10\" y=\"18\">${esc(trunc(n.label, 26))}</text><text class=\"sub\" x=\"10\" y=\"35\">${esc(sub)}</text><title>${esc(n.id)}\\n${esc(Object.entries(n.actions).map(([k, v]) => k + \" \u00D7\" + v).join(\", \"))}\\nby ${esc(n.actors.join(\", \"))}</title></g>`;\n }\n for (const n of actors) {\n const p = pos.get(\"u:\" + n.id); const shape = n.kind === \"agent\" ? `<polygon points=\"10,0 ${p.w - 10},0 ${p.w},${p.h / 2} ${p.w - 10},${p.h} 10,${p.h} 0,${p.h / 2}\"/>` : `<ellipse cx=\"${p.w / 2}\" cy=\"${p.h / 2}\" rx=\"${p.w / 2}\" ry=\"${p.h / 2}\"/>`;\n svg += `<g class=\"gnode actor ${esc(n.kind)}\" data-actor=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\">${shape}<text x=\"${p.w / 2}\" y=\"20\" text-anchor=\"middle\">${esc(trunc(n.label, 18))}</text><text class=\"sub\" x=\"${p.w / 2}\" y=\"35\" text-anchor=\"middle\">${esc(n.kind)} \u00B7 ${n.events} ev</text><title>${esc(n.id)}</title></g>`;\n }\n svg += `</svg>`;\n $(\"#gsvg\").innerHTML = svg;\n }\n function setView(v) { const g = v === \"graph\"; $(\"#graph\").classList.toggle(\"on\", g); $(\"#timeline\").classList.toggle(\"off\", g); $(\"#vGraph\").classList.toggle(\"on\", g); $(\"#vTimeline\").classList.toggle(\"on\", !g); if (g) renderGraph(); }\n $(\"#vTimeline\").addEventListener(\"click\", () => setView(\"timeline\"));\n $(\"#vGraph\").addEventListener(\"click\", () => setView(\"graph\"));\n $(\"#gActors\").addEventListener(\"change\", renderGraph); $(\"#gFlow\").addEventListener(\"change\", renderGraph);\n function lineageDot(l) { const q = JSON.stringify; const out = [\"digraph retrace {\", \" rankdir=LR; node [fontname=Helvetica, fontsize=10];\"]; for (const n of l.nodes) out.push(` ${q((n.type === \"actor\" ? \"u:\" : \"\") + n.id)} [label=${q(n.label + (n.type === \"artifact\" ? \"\\\\n\" + n.events + \" ev\" : \"\"))}, shape=${n.type === \"actor\" ? \"ellipse\" : \"box\"}${n.type === \"actor\" ? \", style=dashed\" : \"\"}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === \"derived\" ? \" [penwidth=2]\" : e.type === \"touched\" ? \" [style=dashed,color=gray]\" : \"\"};`); out.push(\"}\"); return out.join(\"\\n\"); }\n function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, \"n\" + i++); return ids.get(s); }; const out = [\"graph LR\"]; for (const n of l.nodes) { const k = (n.type === \"actor\" ? \"u:\" : \"\") + n.id; const lbl = (n.label + (n.type === \"artifact\" ? ` (${n.events})` : \"\")).replace(/\"/g, \"'\"); out.push(n.type === \"actor\" ? ` ${nid(k)}([\"${lbl}\"])` : ` ${nid(k)}[\"${lbl}\"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === \"derived\" ? \"==>\" : e.type === \"flow\" ? \"-->\" : \"-.->\"} ${nid(e.to)}`); return out.join(\"\\n\"); }\n $(\"#gDot\").addEventListener(\"click\", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: \"text/vnd.graphviz\" }), `retrace-${state.project}-lineage.dot`));\n // \"copied\" is only shown when the copy actually happened; when the clipboard is unavailable (plain-http origins,\n // some embedded browsers) the text is put on screen instead of silently going nowhere.\n $(\"#gMmd\").addEventListener(\"click\", async () => {\n if (!lastLineage) return toast(\"Open the Graph view first\");\n const t = lineageMermaid(lastLineage); const ok = await copyText(t);\n if (ok) { $(\"#gMmd\").textContent = \"copied\"; setTimeout(() => ($(\"#gMmd\").textContent = \"Mermaid\"), 1200); }\n else { state.mermaidText = t; state.pane = \"mermaid\"; state.sel = null; renderDetail(); revealDetail(); }\n });\n async function copyText(t) {\n try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(t); return true; } } catch {}\n try { const ta = document.createElement(\"textarea\"); ta.value = t; ta.style.position = \"fixed\"; ta.style.opacity = \"0\"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand?.(\"copy\"); ta.remove(); return !!ok; } catch { return false; }\n }\n let toastTimer;\n function toast(msg) { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"on\"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove(\"on\"), 3500); }\n if (params.get(\"view\") === \"graph\") setTimeout(() => setView(\"graph\"), 0);\n\n // events\n // One delegated handler. Every data-* attribute the markup uses MUST be in this selector, or the element renders\n // as clickable and does nothing (ui-detail.test.ts drives real clicks through it for that reason).\n document.addEventListener(\"click\", (ev) => {\n const t = ev.target.closest(\"[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],[data-ftype],[data-pane],.ev\");\n if (!t) return;\n const d = t.dataset;\n if (d.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }\n if (d.expand) { ev.stopPropagation(); state.expanded.has(d.expand) ? state.expanded.delete(d.expand) : state.expanded.add(d.expand); return render(); }\n // Filters toggle: clicking the artifact / actor / actor-type you are already filtering on clears that filter.\n if (d.art) { ev.stopPropagation(); state.filterArtifact = state.filterArtifact === d.art ? null : d.art; return render(); }\n if (d.actor) { ev.stopPropagation(); state.filterActor = state.filterActor === d.actor ? null : d.actor; return render(); }\n if (d.ftype) { ev.stopPropagation(); $(\"#ftype\").value = $(\"#ftype\").value === d.ftype ? \"\" : d.ftype; return render(); }\n if (d.q) { ev.stopPropagation(); $(\"#q\").value = d.q; return render(); }\n if (d.pane) { ev.stopPropagation(); state.pane = d.pane; state.sel = null; render(); return revealDetail(); }\n if (d.goto) { ev.preventDefault(); ev.stopPropagation(); return gotoEvent(d.goto); }\n if (d.clear) { ev.stopPropagation(); clearFilters(d.clear); return render(); }\n if (t.classList.contains(\"ev\")) { state.sel = t.dataset.id; state.pane = null; render(); revealDetail(); }\n });\n function clearFilters(which) {\n if (which === \"artifact\" || which === \"all\") state.filterArtifact = null;\n if (which === \"actor\" || which === \"all\") state.filterActor = null;\n if (which === \"q\" || which === \"all\") $(\"#q\").value = \"\";\n if (which === \"all\") { $(\"#ftype\").value = \"\"; $(\"#faction\").value = \"\"; }\n }\n // Jump to an event. It must end up on screen: a filter that would hide it is lifted (and the toast says so), and\n // the graph view yields to the timeline \u2014 a jump that changes nothing visible reads as a dead link.\n function gotoEvent(id) {\n if (!state.events.some((e) => e.id === id)) return toast(`${id} is not among the loaded events`);\n state.sel = id; state.pane = null; state.filterArtifact = null; state.filterActor = null;\n if (!filtered().some((e) => e.id === id)) { clearFilters(\"all\"); toast(\"Cleared the search / actor / action filters to show the linked event\"); }\n if ($(\"#graph\").classList.contains(\"on\")) setView(\"timeline\");\n render();\n document.querySelector(`.ev[data-id=\"${CSS.escape(id)}\"]`)?.scrollIntoView?.({ block: \"center\", behavior: \"smooth\" });\n revealDetail();\n }\n // Below the 900px breakpoint the detail pane sits under the timeline, where an update is off-screen and a click looks inert.\n function revealDetail() { if (window.matchMedia?.(\"(max-width: 900px)\")?.matches) $(\"#detail\").scrollIntoView?.({ block: \"start\", behavior: \"smooth\" }); }\n $(\"#project\").addEventListener(\"change\", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });\n [\"#q\", \"#ftype\", \"#faction\"].forEach((s) => $(s).addEventListener(\"input\", render));\n $(\"#refresh\").addEventListener(\"click\", () => boot());\n $(\"#report\").addEventListener(\"click\", () => { if (!state.project) return toast(\"No project loaded\"); window.open(P.reportUrl(state.project, state.filterArtifact), \"_blank\"); });\n $(\"#export\").addEventListener(\"click\", async () => {\n if (state.offline) { const blob = new Blob([JSON.stringify({ format: \"retrace-export/1\", scope: { project: state.project }, events: state.events }, null, 2)], { type: \"application/json\" }); return dl(blob, `${state.project}-events.json`); }\n if (!state.project) return toast(\"No project loaded\");\n try {\n const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);\n dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? \"-\" + state.filterArtifact.replace(/[^\\w.-]+/g, \"_\") : \"\"}.json`);\n } catch (e) { toast(`Export failed: ${e.message}`); }\n });\n function dl(blob, name) { const a = document.createElement(\"a\"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }\n $(\"#share\").addEventListener(\"click\", () => { $(\"#shScope\").value = state.filterArtifact ? \"artifact\" : \"project\"; $(\"#shScope\").querySelector('[value=artifact]').disabled = !state.filterArtifact; $(\"#shResult\").textContent = \"\"; $(\"#shareDlg\").style.display = \"flex\"; });\n $(\"#shCancel\").addEventListener(\"click\", () => ($(\"#shareDlg\").style.display = \"none\"));\n $(\"#shCreate\").addEventListener(\"click\", async () => {\n const body = { artifact_id: $(\"#shScope\").value === \"artifact\" ? state.filterArtifact : undefined, label: $(\"#shLabel\").value.trim() || undefined, expires_in_days: Number($(\"#shExp\").value) || undefined };\n try {\n const res = await fetch(`${apiBase}/projects/${encodeURIComponent(state.project)}/share`, { method: \"POST\", headers: { \"content-type\": \"application/json\", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });\n const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);\n const u = r.url.startsWith(\"http\") ? r.url : location.origin + r.url;\n $(\"#shResult\").innerHTML = `Link: <a href=\"${esc(u)}\" target=\"_blank\">${esc(u)}</a> <button id=\"shCopy\">copy</button><br>Report: <a href=\"${esc(u)}/report\" target=\"_blank\">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : \"\"}`;\n $(\"#shCopy\").addEventListener(\"click\", async () => { $(\"#shCopy\").textContent = (await copyText(u)) ? \"copied\" : \"clipboard unavailable \u2014 select the link\"; });\n } catch (e) { $(\"#shResult\").textContent = \"Failed: \" + e.message; }\n });\n $(\"#toggleConn\").addEventListener(\"click\", () => $(\"#conn\").classList.toggle(\"open\"));\n $(\"#connect\").addEventListener(\"click\", () => { apiBase = $(\"#apiUrl\").value.trim().replace(/\\/$/, \"\"); token = $(\"#apiTok\").value.trim(); state.offline = false; boot(); });\n $(\"#loadJson\").addEventListener(\"click\", () => $(\"#file\").click());\n $(\"#file\").addEventListener(\"change\", async (e) => {\n const f = e.target.files[0]; if (!f) return;\n const data = JSON.parse(await f.text());\n const events = Array.isArray(data) ? data : data.events ?? [];\n state.offline = true; state.events = events.sort((a, b) => a.seq - b.seq); state.verify = null; state.status = null;\n state.projects = [...new Set(events.map((x) => x.project))]; state.project = state.projects[0] ?? null;\n $(\"#project\").innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\");\n render();\n });\n function showErr(err) { $(\"#timeline\").innerHTML = `<div class=\"empty\">Could not load: ${esc(err.message)}<br><br>Open \u2699 to set the API URL / token, or load a JSON export.</div>`; $(\"#conn\").classList.add(\"open\"); }\n async function boot() { try { await loadProjects(); await loadEvents(); } catch (err) { showErr(err); } }\n boot();\n setInterval(() => { if (!state.offline && document.visibilityState === \"visible\") loadEvents().catch(() => {}); }, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
package/dist/ui-html.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// GENERATED by scripts/embed-ui.mjs — edit ui/retrace.html instead.
|
|
2
|
-
export const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Retrace</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a7;\n --human: #f2a93b; --agent: #5aa9ff; --system: #9aa3b5; --ok: #3ccb7f; --bad: #ff5c6c; --accent: #c7b6ff;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n @media (prefers-color-scheme: light) {\n :root { --bg: #f6f7fa; --panel: #ffffff; --panel-2: #f0f2f6; --line: #dfe3ea; --text: #16181d; --muted: #616a7d;\n --human: #c77800; --agent: #1f6fe0; --system: #6b7385; --ok: #178f4f; --bad: #d1303f; --accent: #6b4fd8; }\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }\n header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 8px 0 0; letter-spacing: .04em; }\n header h1 span { color: var(--accent); }\n select, input, button { font: inherit; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }\n input { min-width: 140px; }\n header button { padding: 6px 9px; }\n button { cursor: pointer; }\n button.primary { background: var(--accent); color: #111; border-color: transparent; font-weight: 600; }\n .badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); background: var(--panel-2); }\n .badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }\n .badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }\n .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }\n main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; height: calc(100vh - 53px); }\n @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; } #detail { border-left: 0; border-top: 1px solid var(--line); } }\n #timeline { overflow: auto; padding: 16px 20px 40px; }\n #detail { border-left: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 16px; }\n .legend { display: flex; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }\n .legend .dot.human { background: var(--human); } .legend .dot.agent { background: var(--agent); } .legend .dot.system { background: var(--system); }\n .day { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 8px 0; }\n .ev { display: grid; grid-template-columns: 64px 18px 1fr; gap: 10px; align-items: start; padding: 8px 8px; border-radius: 8px; cursor: pointer; position: relative; }\n .ev:hover { background: var(--panel-2); }\n .ev.sel { background: var(--panel-2); outline: 1px solid var(--accent); }\n .ev .t { color: var(--muted); font-family: var(--mono); font-size: 12px; padding-top: 2px; }\n .ev .rail { position: relative; height: 100%; }\n .ev .rail::before { content: \"\"; position: absolute; left: 8px; top: -10px; bottom: -14px; width: 2px; background: var(--line); }\n .ev .node { position: absolute; left: 3px; top: 3px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--line); }\n .ev.human .node { background: var(--human); } .ev.agent .node { background: var(--agent); border-radius: 3px; } .ev.system .node { background: var(--system); }\n .ev .who { font-weight: 600; }\n .ev.human .who { color: var(--human); } .ev.agent .who { color: var(--agent); } .ev.system .who { color: var(--system); }\n .ev .verb { color: var(--accent); }\n .ev .art { font-family: var(--mono); font-size: 12.5px; }\n .ev .why { color: var(--muted); font-size: 12.5px; margin-top: 2px; }\n .ev .link { color: var(--muted); font-size: 11px; }\n .chip { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-right: 4px; cursor: pointer; }\n .chip:hover { border-color: var(--accent); }\n .chip.more { color: var(--accent); border-style: dashed; }\n .chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }\n #detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }\n .kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }\n .kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }\n .kv .v { word-break: break-word; }\n .kv .v.mono { font-family: var(--mono); font-size: 12px; }\n pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-family: var(--mono); font-size: 12px; overflow: auto; max-height: 240px; margin: 4px 0 0; white-space: pre-wrap; }\n .why-chain { border-left: 2px solid var(--line); padding-left: 10px; margin: 6px 0 14px 4px; }\n .why-chain .step { margin: 6px 0; font-size: 13px; }\n .why-chain .step .who { font-weight: 600; }\n .why-chain .step.human .who { color: var(--human); } .why-chain .step.agent .who { color: var(--agent); }\n .why-chain .step .arrow { color: var(--muted); font-size: 11px; }\n .empty { color: var(--muted); padding: 40px 0; text-align: center; }\n .stat { display: flex; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }\n .stat b { color: var(--text); font-weight: 600; }\n #conn { display: none; gap: 8px; align-items: center; }\n #conn.open { display: flex; }\n .hash { font-family: var(--mono); font-size: 11px; color: var(--muted); }\n a { color: var(--accent); }\n .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }\n .seg button { border: 0; border-radius: 0; padding: 6px 10px; background: var(--panel-2); color: var(--muted); }\n .seg button.on { background: var(--accent); color: #111; font-weight: 600; }\n #graph { display: none; overflow: auto; position: relative; }\n #graph.on { display: block; } #timeline.off { display: none; }\n #graph svg { display: block; min-width: 100%; }\n .gctl { position: sticky; top: 0; left: 0; z-index: 2; width: 100%; display: flex; gap: 12px; align-items: center; padding: 10px 16px; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(4px); font-size: 12px; color: var(--muted); flex-wrap: wrap; }\n .gctl label { display: inline-flex; gap: 4px; align-items: center; }\n .gnode { cursor: pointer; }\n .gnode rect, .gnode ellipse, .gnode polygon { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.2; }\n .gnode:hover rect, .gnode:hover ellipse, .gnode:hover polygon, .gnode.sel rect, .gnode.sel ellipse, .gnode.sel polygon { stroke: var(--accent); stroke-width: 2; }\n .gnode.k-task rect { stroke: var(--human); } .gnode.k-commit rect { stroke-dasharray: 3 2; } .gnode.k-pr rect { stroke: var(--ok); } .gnode.k-command rect { stroke: var(--system); }\n .gnode.actor.human ellipse { stroke: var(--human); } .gnode.actor.agent polygon { stroke: var(--agent); } .gnode.actor.system ellipse { stroke: var(--system); }\n .gnode text { fill: var(--text); font-size: 11.5px; font-family: var(--mono); pointer-events: none; }\n .gnode text.sub { fill: var(--muted); font-size: 10px; font-family: system-ui, sans-serif; }\n .gedge { fill: none; stroke: var(--muted); stroke-width: 1.2; opacity: .75; }\n .gedge.derived { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }\n .gedge.touched { stroke-dasharray: 3 3; opacity: .45; }\n .gedge.dim { opacity: .12; } .gnode.dim { opacity: .25; }\n .glabel { fill: var(--muted); font-size: 9.5px; }\n</style>\n</head>\n<body>\n<header>\n <h1>RE<span>TRACE</span></h1>\n <select id=\"project\" title=\"Project\"></select>\n <input id=\"q\" placeholder=\"search…\" style=\"width:150px\" />\n <select id=\"ftype\"><option value=\"\">all actors</option><option value=\"human\">humans</option><option value=\"agent\">agents</option><option value=\"system\">system</option></select>\n <select id=\"faction\"><option value=\"\">any action</option></select>\n <span class=\"seg\"><button id=\"vTimeline\" class=\"on\">Timeline</button><button id=\"vGraph\">Graph</button></span>\n <span id=\"verify\" class=\"badge\"><span class=\"dot\"></span><span>verifying…</span></span>\n <span id=\"status\" class=\"badge\" title=\"Capture completeness and causal coverage\">capture…</span>\n <span id=\"sharedBadge\" class=\"badge\" style=\"display:none\" title=\"Read-only shared view\">🔗 shared view</span>\n <button id=\"report\" title=\"Printable report (save as PDF)\">Report</button>\n <button id=\"export\" title=\"Download signed JSON bundle\">Export</button>\n <button id=\"share\" title=\"Create a read-only share link\">Share</button>\n <button id=\"refresh\" title=\"Reload\">↻</button>\n <button id=\"toggleConn\" title=\"Connection settings\">⚙</button>\n <span id=\"conn\"><input id=\"apiUrl\" placeholder=\"API URL (blank = same origin)\" /><input id=\"apiTok\" placeholder=\"token\" type=\"password\" /><button id=\"connect\" class=\"primary\">Connect</button><button id=\"loadJson\">Load JSON…</button><input type=\"file\" id=\"file\" accept=\"application/json\" style=\"display:none\" /></span>\n</header>\n<div id=\"shareDlg\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;align-items:center;justify-content:center\">\n <div style=\"background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;width:min(520px,92vw)\">\n <h3 style=\"margin:0 0 10px;font-size:15px\">Create read-only share link</h3>\n <div class=\"link\" style=\"margin-bottom:8px\">Anyone with the link can view (not edit) the events in scope, verify the chain, and download the signed export / report.</div>\n <div style=\"display:grid;gap:8px\">\n <label>Scope <select id=\"shScope\"><option value=\"project\">whole project</option><option value=\"artifact\">current artifact filter</option></select></label>\n <label>Label <input id=\"shLabel\" placeholder=\"e.g. Jab counter — client review\" style=\"width:100%\"></label>\n <label>Expires <select id=\"shExp\"><option value=\"\">never</option><option value=\"7\">7 days</option><option value=\"30\">30 days</option><option value=\"90\">90 days</option></select></label>\n <div id=\"shResult\" class=\"link\" style=\"word-break:break-all\"></div>\n <div style=\"display:flex;gap:8px;justify-content:flex-end\"><button id=\"shCancel\">Close</button><button id=\"shCreate\" class=\"primary\">Create link</button></div>\n </div>\n </div>\n</div>\n<main>\n <section id=\"timeline\"><div class=\"empty\">Loading…</div></section>\n <section id=\"graph\">\n <div class=\"gctl\">\n <span><b id=\"gStat\"></b></span>\n <label><input type=\"checkbox\" id=\"gActors\"> show people & agents</label>\n <label><input type=\"checkbox\" id=\"gFlow\" checked> causal flow edges</label>\n <span style=\"margin-left:auto\">━ derived_from → causal flow ┈ touched · click a node to filter the timeline</span>\n <button id=\"gDot\" title=\"Download Graphviz DOT\">DOT</button><button id=\"gMmd\" title=\"Copy Mermaid\">Mermaid</button>\n </div>\n <div id=\"gsvg\"></div>\n </section>\n <aside id=\"detail\"><div class=\"empty\">Select an event to see who / what / when / where / why / how.</div></aside>\n</main>\n<script>\n(() => {\n const $ = (s) => document.querySelector(s);\n const state = { events: [], project: null, projects: [], sel: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true };\n const params = new URLSearchParams(location.search);\n if (params.get(\"order\") === \"asc\") state.newestFirst = false;\n let apiBase = params.get(\"api\") ?? \"\";\n let token = params.get(\"token\") ?? \"\";\n $(\"#apiUrl\").value = apiBase; $(\"#apiTok\").value = token;\n // Shared mode: /s/<id> → all reads go through /s/<id>/*, scope locked server-side\n const shareMatch = location.pathname.match(/\\/s\\/([^/]+)/);\n const shareId = shareMatch ? shareMatch[1] : null;\n if (shareId) {\n document.body.classList.add(\"shared\");\n $(\"#sharedBadge\").style.display = \"\";\n [\"#share\", \"#toggleConn\", \"#project\"].forEach((s) => ($(s).style.display = \"none\"));\n }\n const P = {\n projects: () => shareId ? api(`/s/${shareId}/meta`).then((m) => [m.project]) : api(\"/projects\"),\n events: (p) => shareId ? api(`/s/${shareId}/events?limit=5000`) : api(`/projects/${encodeURIComponent(p)}/events?limit=5000`),\n verify: (p) => shareId ? api(`/s/${shareId}/verify`) : api(`/projects/${encodeURIComponent(p)}/verify`),\n status: (p) => shareId ? Promise.resolve(null) : api(`/projects/${encodeURIComponent(p)}/status`),\n exportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/export` : `${apiBase}/projects/${encodeURIComponent(p)}/export?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n reportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/report` : `${apiBase}/projects/${encodeURIComponent(p)}/report?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n };\n\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]));\n const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const fmtDay = (iso) => new Date(iso).toLocaleDateString([], { weekday: \"short\", year: \"numeric\", month: \"short\", day: \"numeric\" });\n const actorName = (a) => a.display_name || a.id;\n const fmtDur = (ms) => ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` : ms < 3600000 ? `${Math.round(ms / 60000)} min` : `${(ms / 3600000).toFixed(1)} h`;\n const actorTag = (a) => a.type === \"agent\" ? (a.model ? ` · ${a.model}` : \" · agent\") : a.type === \"system\" ? \" · system\" : \"\";\n const verbOf = (e) => e.action === \"other\" ? (e.action_detail || \"acted on\") : e.action;\n const artLabel = (a) => a.label || a.id;\n // PROV role of a ref within its event: in (used) · out (generated) · in/out (both); no marker when unspecified.\n const roleMark = (r) => r === \"used\" ? \"in\" : r === \"generated\" ? \"out\" : r === \"both\" ? \"in/out\" : \"\";\n const roleTitle = (r) => r === \"used\" ? \" · used (input)\" : r === \"generated\" ? \" · generated (output)\" : r === \"both\" ? \" · used and generated\" : \"\";\n const roleTag = (a) => roleMark(a.role) ? `<i class=\"role\">${roleMark(a.role)}</i>` : \"\";\n const whereText = (loc) => loc ? [loc.system, loc.environment, loc.path || loc.url, loc.device, loc.client].filter(Boolean).join(\" · \") : \"\";\n // Second WHERE line: the run-identity fields. Deliberately NOT in whereText, which the timeline reuses and which\n // must stay a readable one-liner. session/workspace are clickable — they drop into the search box, which already\n // matches the whole event JSON, so one click shows everything from that agent run or that isolated worktree.\n const findable = (v) => `<span class=\"chip\" data-q=\"${esc(v)}\" title=\"search for ${esc(v)}\">${esc(v)}</span>`;\n const whereExtra = (loc) => {\n const bits = [];\n if (loc.ide) bits.push(`in <b>${esc(loc.ide)}</b>`);\n if (loc.workspace) bits.push(`workspace ${findable(loc.workspace)}`);\n if (loc.session) bits.push(`session ${findable(loc.session)}`);\n if (loc.surface) bits.push(loc.surface === \"tty\" ? \"at a terminal\" : \"no terminal · agent-driven\");\n return bits.length ? `<div class=\"link\">${bits.join(\" · \")}</div>` : \"\";\n };\n // Latest known label per artifact id: the label on the last event (by seq) that carries one.\n // Used wherever the UI names an artifact; the event detail pane keeps each event's as-at label.\n const latestLabels = (events) => { const m = new Map(); for (const e of [...events].sort((a, b) => a.seq - b.seq)) for (const a of e.artifacts) if (a.label) m.set(a.id, a.label); return m; };\n\n async function api(path) {\n const url = (apiBase || \"\") + path;\n const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);\n return res.json();\n }\n\n async function loadProjects() {\n state.projects = await P.projects();\n const sel = $(\"#project\");\n sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\") || \"<option value=''>(no projects yet)</option>\";\n const want = params.get(\"project\");\n state.project = state.projects.includes(want) ? want : state.projects[0] ?? null;\n if (state.project) sel.value = state.project;\n }\n\n async function loadEvents() {\n if (state.offline) return render();\n if (!state.project) { state.events = []; return render(); }\n const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);\n state.events = events; state.verify = verify; state.status = status;\n render();\n }\n\n function localVerifyBadge() {\n const b = $(\"#verify\"); const v = state.verify;\n if (!v) { b.className = \"badge\"; b.lastElementChild.textContent = state.offline ? \"offline snapshot\" : \"not verified\"; return; }\n b.className = \"badge \" + (v.ok ? \"ok\" : \"bad\");\n b.lastElementChild.textContent = v.ok ? `chain intact · ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;\n b.title = v.reason || \"\";\n }\n\n function statusBadge() {\n const b = $(\"#status\"), s = state.status;\n if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? \"none\" : \"\"; b.textContent = \"capture unknown\"; return; }\n b.style.display = \"\";\n const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;\n b.className = \"badge \" + (gaps ? \"bad\" : \"ok\");\n b.textContent = `${s.causality.coverage_pct}% causal · ${s.capture.unlinked_commits} unlinked commits`;\n b.title = `${s.capture.instructions_without_followup} instructions without follow-up · ${s.capture.agent_events_without_model} agent events missing model · ${s.capture.artifact_refs_without_role} artifact refs missing role`;\n }\n\n function filtered(opts = {}) {\n const q = $(\"#q\").value.trim().toLowerCase();\n const t = $(\"#ftype\").value; const a = $(\"#faction\").value;\n return state.events.filter((e) => {\n if (t && e.actor.type !== t) return false;\n if (a && e.action !== a) return false;\n if (!opts.ignoreArtifact && state.filterArtifact && !e.artifacts.some((x) => x.id === state.filterArtifact)) return false;\n if (state.filterActor && e.actor.id !== state.filterActor) return false;\n if (q && !JSON.stringify(e).toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n function render() {\n localVerifyBadge();\n statusBadge();\n const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);\n const acts = [...new Set(state.events.map((e) => e.action))].sort();\n const fa = $(\"#faction\"); const cur = fa.value;\n fa.innerHTML = `<option value=\"\">any action</option>` + acts.map((a) => `<option ${a === cur ? \"selected\" : \"\"}>${esc(a)}</option>`).join(\"\");\n const list = filtered(); if (state.newestFirst) list.reverse();\n const humans = new Set(state.events.filter((e) => e.actor.type === \"human\").map((e) => e.actor.id)).size;\n const agents = new Set(state.events.filter((e) => e.actor.type === \"agent\").map((e) => e.actor.id)).size;\n const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;\n let html = `<div class=\"stat\"><span><b>${state.events.length}</b> events</span><span><b>${humans}</b> humans</span><span><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>` +\n (state.filterArtifact ? `<span>artifact: <span class=\"chip\" data-clear=\"artifact\" title=\"${esc(state.filterArtifact)}\">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} ✕</span></span>` : \"\") +\n (state.filterActor ? `<span>actor: <span class=\"chip\" data-clear=\"actor\">${esc(state.filterActor)} ✕</span></span>` : \"\") + `</div>`;\n html += `<div class=\"legend\"><span><i class=\"dot human\" style=\"display:inline-block\"></i> human</span><span><i class=\"dot agent\" style=\"display:inline-block;border-radius:2px\"></i> agent</span><span><i class=\"dot system\" style=\"display:inline-block\"></i> system</span><span style=\"margin-left:auto\"><span class=\"chip\" data-order=\"1\" title=\"flip order\">${state.newestFirst ? \"newest first ▾\" : \"oldest first ▴\"}</span></span></div>`;\n if (!list.length) html += `<div class=\"empty\">No events${state.events.length ? \" match these filters\" : \" yet — log one with the MCP tool <code>retrace_log</code> or POST /events\"}.</div>`;\n let lastDay = \"\";\n for (const e of list) {\n const day = fmtDay(e.timestamp);\n if (day !== lastDay) { html += `<div class=\"day\">${esc(day)}</div>`; lastDay = day; }\n const MAX = 6; const open = state.expanded.has(e.id); const shown = open ? e.artifacts : e.artifacts.slice(0, MAX); const extra = e.artifacts.length - shown.length;\n const arts = shown.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(labelOf(a))}</span>`).join(\"\") +\n (extra > 0 ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"show all ${e.artifacts.length} artifacts\">+${extra} more</span>` : open && e.artifacts.length > MAX ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"collapse\">show less</span>` : \"\");\n const where = e.location?.path || e.location?.url || e.location?.system || \"\";\n const how = e.method?.tool ? `via ${esc(e.method.tool)}` : \"\";\n html += `<div class=\"ev ${e.actor.type} ${state.sel === e.id ? \"sel\" : \"\"}\" data-id=\"${esc(e.id)}\">\n <div class=\"t\">${fmtTime(e.timestamp)}<br><span class=\"link\">#${e.seq}</span></div>\n <div class=\"rail\"><div class=\"node\"></div></div>\n <div>\n <div><span class=\"who\" data-actor=\"${esc(e.actor.id)}\">${esc(actorName(e.actor))}</span><span class=\"link\">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? \" · for \" + esc(e.actor.on_behalf_of) : \"\"}</span> <span class=\"verb\">${esc(verbOf(e))}</span> <span class=\"art\">${arts}</span> <span class=\"link\">${where ? \"@ \" + esc(where) + \" \" : \"\"}${how}</span></div>\n ${e.intent ? `<div class=\"why\">${esc(e.intent)}</div>` : \"\"}\n ${e.caused_by ? `<div class=\"link\">↳ caused by <a href=\"#\" data-goto=\"${esc(e.caused_by)}\">${esc(e.caused_by.slice(0, 16))}…</a></div>` : \"\"}\n </div></div>`;\n }\n $(\"#timeline\").innerHTML = html;\n if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;\n renderDetail();\n renderGraph();\n }\n\n function whyChain(id) {\n const byId = new Map(state.events.map((e) => [e.id, e]));\n const chain = []; const seen = new Set(); let cur = byId.get(id);\n while (cur && !seen.has(cur.id) && chain.length < 30) { seen.add(cur.id); chain.push(cur); cur = cur.caused_by ? byId.get(cur.caused_by) : null; }\n return chain;\n }\n function effects(id) { return state.events.filter((e) => e.caused_by === id); }\n\n function renderDetail() {\n const e = state.events.find((x) => x.id === state.sel);\n const d = $(\"#detail\");\n if (!e) { d.innerHTML = `<div class=\"empty\">Select an event to see who / what / when / where / why / how.</div>`; return; }\n const kv = (k, v, mono) => v ? `<div class=\"k\">${k}</div><div class=\"v ${mono ? \"mono\" : \"\"}\">${v}</div>` : \"\";\n // Every section always renders: absent data says \"(not recorded)\" instead of vanishing,\n // and a section that fails to render falls back on its own — it never blanks the rest of the pane.\n const sect = (k, fn, mono) => { let v; try { v = fn(); } catch { v = `<span class=\"link\">(could not render — see raw JSON)</span>`; } return kv(k, v || `<span class=\"link\">(not recorded)</span>`, mono); };\n let chainHtml, kidsHtml = \"\", kids = [];\n try {\n const chain = whyChain(e.id).slice(1); kids = effects(e.id);\n const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);\n chainHtml = parentMissing ? `<div class=\"link\" style=\"margin-bottom:12px\">caused by <span class=\"mono\">${esc(e.caused_by)}</span> — outside this view's scope</div>` : chain.length ? `<div class=\"why-chain\">` + chain.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">↳ because</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))}${c.intent ? ` — <i>${esc(c.intent)}</i>` : \"\"} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : `<div class=\"link\" style=\"margin-bottom:12px\">no causal parent recorded — this is a root event</div>`;\n kidsHtml = kids.length ? `<div class=\"why-chain\">` + kids.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">→ led to</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : \"\";\n } catch { chainHtml = `<div class=\"link\" style=\"margin-bottom:12px\">(could not render the causal chain — see raw JSON)</div>`; }\n let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = \"(not serializable)\"; }\n d.innerHTML = `\n <h2>Event #${e.seq}</h2>\n <div class=\"kv\">\n ${sect(\"Who\", () => `<span style=\"color:var(--${e.actor.type});font-weight:600\">${esc(actorName(e.actor))}</span> <span class=\"link\">${esc(e.actor.type)}${e.actor.model ? \" · \" + esc(e.actor.model) : \"\"}${e.actor.version ? \" \" + esc(e.actor.version) : \"\"}${e.actor.on_behalf_of ? \"<br>on behalf of \" + esc(e.actor.on_behalf_of) : \"\"}</span>`)}\n ${sect(\"What\", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== \"file\" ? `<span class=\"link\">${esc(a.kind)}</span>` : \"\"}${a.derived_from?.length ? `<div class=\"link\">derived from ${esc(a.derived_from.join(\", \"))}</div>` : \"\"}`).join(\" \")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : \"\"}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : \"\"}${e.change?.after_hash ? `<div class=\"hash\">after ${esc(e.change.after_hash)}</div>` : \"\"}`)}\n ${sect(\"When\", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class=\"link\">· took ${fmtDur(e.duration_ms)}</span>` : \"\"}<div class=\"link\">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}\n ${sect(\"Where\", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : \"\")}\n ${sect(\"Why\", () => e.intent ? esc(e.intent) : \"\")}\n ${sect(\"How\", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : \"\"}${e.method.automated != null ? `<span class=\"link\">${e.method.automated ? \"automated\" : \"manual\"}</span>` : \"\"}${e.method.instruction ? `<div class=\"link\">instruction: ${esc(e.method.instruction)}</div>` : \"\"}${e.method.tokens != null ? `<div class=\"link\">${e.method.tokens} tokens${e.method.cost_usd != null ? ` · $${e.method.cost_usd}` : \"\"}</div>` : \"\"}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : \"\"}` : \"\")}\n ${sect(\"Tags\", () => e.tags?.length ? e.tags.map((t) => `<span class=\"chip\">${esc(t)}</span>`).join(\"\") : \"\")}\n </div>\n <h2>Causal chain</h2>${chainHtml}\n ${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : \"\"}\n <h2>Integrity</h2>\n <div class=\"kv\"><div class=\"k\">id</div><div class=\"v mono\">${esc(e.id)}</div><div class=\"k\">hash</div><div class=\"v hash\">${esc(e.hash)}</div><div class=\"k\">prev</div><div class=\"v hash\">${esc(e.prev_hash)}</div></div>\n <details><summary class=\"link\">raw JSON</summary><pre>${esc(raw)}</pre></details>`;\n }\n\n // ---------- lineage graph ----------\n function buildLineage(events, opts = {}) {\n const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));\n const sorted = [...events].sort((a, b) => a.seq - b.seq);\n const art = (id, label, kind, seq) => { let n = nodes.get(\"a:\" + id); if (!n) { n = { id, type: \"artifact\", label: label || id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} }; nodes.set(\"a:\" + id, n); } if (label) n.label = label; if (kind && !n.kind) n.kind = kind; return n; };\n const addEdge = (from, to, type, via) => { if (from === to) return; const k = `${type}|${from}|${to}`; let ed = edges.get(k); if (!ed) { ed = { from, to, type, weight: 0, via: [] }; edges.set(k, ed); } ed.weight++; if (ed.via.length < 5) ed.via.push(via); };\n for (const e of sorted) {\n for (const a of e.artifacts) {\n const n = art(a.id, a.label, a.kind, e.seq); n.events++; n.last_seq = e.seq; if (!n.actors.includes(e.actor.id)) n.actors.push(e.actor.id); n.actions[e.action] = (n.actions[e.action] || 0) + 1;\n for (const src of a.derived_from || []) { art(src, undefined, undefined, e.seq); addEdge(src, a.id, \"derived\", e.id); }\n if (opts.includeActors) { let u = nodes.get(\"u:\" + e.actor.id); if (!u) { u = { id: e.actor.id, type: \"actor\", label: actorName(e.actor), kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq }; nodes.set(\"u:\" + e.actor.id, u); } u.events++; addEdge(\"u:\" + e.actor.id, a.id, \"touched\", e.id); }\n }\n if (e.caused_by && opts.flow !== false) { const p = byId.get(e.caused_by); if (p) for (const pa of p.artifacts) for (const ca of e.artifacts) if (pa.id !== ca.id) addEdge(pa.id, ca.id, \"flow\", e.id); }\n }\n return { nodes: [...nodes.values()], edges: [...edges.values()] };\n }\n function layerLineage(l) {\n const ids = l.nodes.filter((n) => n.type === \"artifact\").map((n) => n.id);\n const inc = new Map(ids.map((id) => [id, []])); for (const e of l.edges) if (e.type !== \"touched\" && inc.has(e.to) && inc.has(e.from)) inc.get(e.to).push(e.from);\n const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq])); const layer = new Map(), visiting = new Set();\n const depth = (id) => { if (layer.has(id)) return layer.get(id); if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of inc.get(id) || []) if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0)) d = Math.max(d, depth(p) + 1); visiting.delete(id); layer.set(id, d); return d; };\n for (const id of ids) depth(id); return layer;\n }\n let lastLineage = null;\n function renderGraph() {\n if (!$(\"#graph\").classList.contains(\"on\")) return;\n const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding\n const l = buildLineage(events, { includeActors: $(\"#gActors\").checked, flow: $(\"#gFlow\").checked });\n lastLineage = l;\n const arts = l.nodes.filter((n) => n.type === \"artifact\"), actors = l.nodes.filter((n) => n.type === \"actor\");\n $(\"#gStat\").textContent = `${arts.length} artifacts · ${l.edges.filter((e) => e.type !== \"touched\").length} lineage edges${actors.length ? ` · ${actors.length} actors` : \"\"}`;\n if (!arts.length) { $(\"#gsvg\").innerHTML = `<div class=\"empty\">No artifacts in view.</div>`; return; }\n const layer = layerLineage(l);\n const W = 190, H = 46, GX = 70, GY = 16, PAD = 24, ACT_W = 140;\n const cw0 = $(\"#graph\").clientWidth || 1000;\n // connected artifacts: columns = layers, rows by first_seq. Isolated artifacts (no lineage edges): packed grid below.\n const linked = new Set(); for (const e of l.edges) if (e.type !== \"touched\") { linked.add(e.from); linked.add(e.to); }\n const conn = arts.filter((n) => linked.has(n.id)), iso = arts.filter((n) => !linked.has(n.id));\n const cols = new Map();\n for (const n of conn) { const c = layer.get(n.id) || 0; if (!cols.has(c)) cols.set(c, []); cols.get(c).push(n); }\n for (const arr of cols.values()) arr.sort((a, b) => a.first_seq - b.first_seq);\n const pos = new Map(); let maxRows = 0;\n const offX = actors.length ? ACT_W + GX : 0;\n for (const [c, arr] of cols) { maxRows = Math.max(maxRows, arr.length); arr.forEach((n, i) => pos.set(n.id, { x: PAD + offX + c * (W + GX), y: PAD + i * (H + GY), w: W, h: H })); }\n actors.sort((a, b) => a.first_seq - b.first_seq).forEach((n, i) => pos.set(\"u:\" + n.id, { x: PAD, y: PAD + i * (H + GY), w: ACT_W, h: H }));\n maxRows = Math.max(maxRows, actors.length);\n let width = PAD * 2 + offX + Math.max(cols.size, 0) * (W + GX) - (cols.size ? GX : 0);\n let height = PAD * 2 + maxRows * (H + GY) - GY;\n let isoLabelY = 0;\n if (iso.length) {\n const perRow = Math.max(1, Math.floor((cw0 - PAD * 2 + 30) / (W + 30)));\n const y0 = (conn.length || actors.length) ? height + 10 : PAD; isoLabelY = y0 + 12;\n iso.sort((a, b) => b.events - a.events).forEach((n, i) => pos.set(n.id, { x: PAD + (i % perRow) * (W + 30), y: y0 + 24 + Math.floor(i / perRow) * (H + GY), w: W, h: H }));\n const rows = Math.ceil(iso.length / perRow);\n height = y0 + 24 + rows * (H + GY) - GY + PAD; width = Math.max(width, PAD * 2 + Math.min(iso.length, perRow) * (W + 30) - 30);\n }\n const trunc = (t, n) => (t.length > n ? \"…\" + t.slice(-(n - 1)) : t);\n const cw = $(\"#graph\").clientWidth || width; const fit = width > cw && width < cw * 1.5; // gently scale to fit when slightly too wide, otherwise scroll\n let svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" ${fit ? `width=\"100%\"` : `width=\"${width}\"`} height=\"${fit ? Math.round(Math.max(height, 120) * cw / width) : Math.max(height, 120)}\" viewBox=\"0 0 ${width} ${Math.max(height, 120)}\" preserveAspectRatio=\"xMinYMin meet\"><defs><marker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"context-stroke\"/></marker></defs>`;\n const selArt = state.filterArtifact;\n const connected = new Set(); if (selArt) for (const e of l.edges) { if (e.from === selArt) connected.add(e.to); if (e.to === selArt) connected.add(e.from); }\n for (const e of l.edges) {\n const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) continue;\n const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2; const dx = Math.max(40, (x2 - x1) / 2);\n const back = x2 <= x1; const d = back ? `M${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` : `M${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n const dim = selArt && e.from !== selArt && e.to !== selArt ? \" dim\" : \"\";\n svg += `<path class=\"gedge ${e.type}${dim}\" d=\"${d}\" marker-end=\"url(#arr)\"><title>${esc(e.type)} ×${e.weight}\\n${esc(e.from)} → ${esc(e.to)}</title></path>`;\n if (e.type === \"flow\" && e.weight > 1) svg += `<text class=\"glabel\" x=\"${(x1 + x2) / 2}\" y=\"${(y1 + y2) / 2 - 4}\" text-anchor=\"middle\">×${e.weight}</text>`;\n }\n if (iso.length) svg += `<text class=\"glabel\" x=\"${PAD}\" y=\"${isoLabelY}\" style=\"font-size:11px\">${conn.length ? \"no lineage links yet\" : \"artifacts (no lineage links yet — add derived_from or caused_by to connect them)\"} · ${iso.length}</text>`;\n for (const n of arts) {\n const p = pos.get(n.id); const dim = selArt && n.id !== selArt && !connected.has(n.id) ? \" dim\" : \"\"; const sel = selArt === n.id ? \" sel\" : \"\";\n const sub = `${n.events} ev · ${n.actors.length} actor${n.actors.length === 1 ? \"\" : \"s\"}${n.kind ? \" · \" + n.kind : \"\"}`;\n svg += `<g class=\"gnode k-${esc(n.kind || \"x\")}${dim}${sel}\" data-art=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\"><rect width=\"${p.w}\" height=\"${p.h}\" rx=\"6\"/><text x=\"10\" y=\"18\">${esc(trunc(n.label, 26))}</text><text class=\"sub\" x=\"10\" y=\"35\">${esc(sub)}</text><title>${esc(n.id)}\\n${esc(Object.entries(n.actions).map(([k, v]) => k + \" ×\" + v).join(\", \"))}\\nby ${esc(n.actors.join(\", \"))}</title></g>`;\n }\n for (const n of actors) {\n const p = pos.get(\"u:\" + n.id); const shape = n.kind === \"agent\" ? `<polygon points=\"10,0 ${p.w - 10},0 ${p.w},${p.h / 2} ${p.w - 10},${p.h} 10,${p.h} 0,${p.h / 2}\"/>` : `<ellipse cx=\"${p.w / 2}\" cy=\"${p.h / 2}\" rx=\"${p.w / 2}\" ry=\"${p.h / 2}\"/>`;\n svg += `<g class=\"gnode actor ${esc(n.kind)}\" data-actor=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\">${shape}<text x=\"${p.w / 2}\" y=\"20\" text-anchor=\"middle\">${esc(trunc(n.label, 18))}</text><text class=\"sub\" x=\"${p.w / 2}\" y=\"35\" text-anchor=\"middle\">${esc(n.kind)} · ${n.events} ev</text><title>${esc(n.id)}</title></g>`;\n }\n svg += `</svg>`;\n $(\"#gsvg\").innerHTML = svg;\n }\n function setView(v) { const g = v === \"graph\"; $(\"#graph\").classList.toggle(\"on\", g); $(\"#timeline\").classList.toggle(\"off\", g); $(\"#vGraph\").classList.toggle(\"on\", g); $(\"#vTimeline\").classList.toggle(\"on\", !g); if (g) renderGraph(); }\n $(\"#vTimeline\").addEventListener(\"click\", () => setView(\"timeline\"));\n $(\"#vGraph\").addEventListener(\"click\", () => setView(\"graph\"));\n $(\"#gActors\").addEventListener(\"change\", renderGraph); $(\"#gFlow\").addEventListener(\"change\", renderGraph);\n function lineageDot(l) { const q = JSON.stringify; const out = [\"digraph retrace {\", \" rankdir=LR; node [fontname=Helvetica, fontsize=10];\"]; for (const n of l.nodes) out.push(` ${q((n.type === \"actor\" ? \"u:\" : \"\") + n.id)} [label=${q(n.label + (n.type === \"artifact\" ? \"\\\\n\" + n.events + \" ev\" : \"\"))}, shape=${n.type === \"actor\" ? \"ellipse\" : \"box\"}${n.type === \"actor\" ? \", style=dashed\" : \"\"}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === \"derived\" ? \" [penwidth=2]\" : e.type === \"touched\" ? \" [style=dashed,color=gray]\" : \"\"};`); out.push(\"}\"); return out.join(\"\\n\"); }\n function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, \"n\" + i++); return ids.get(s); }; const out = [\"graph LR\"]; for (const n of l.nodes) { const k = (n.type === \"actor\" ? \"u:\" : \"\") + n.id; const lbl = (n.label + (n.type === \"artifact\" ? ` (${n.events})` : \"\")).replace(/\"/g, \"'\"); out.push(n.type === \"actor\" ? ` ${nid(k)}([\"${lbl}\"])` : ` ${nid(k)}[\"${lbl}\"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === \"derived\" ? \"==>\" : e.type === \"flow\" ? \"-->\" : \"-.->\"} ${nid(e.to)}`); return out.join(\"\\n\"); }\n $(\"#gDot\").addEventListener(\"click\", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: \"text/vnd.graphviz\" }), `retrace-${state.project}-lineage.dot`));\n $(\"#gMmd\").addEventListener(\"click\", () => { if (!lastLineage) return; const t = lineageMermaid(lastLineage); navigator.clipboard?.writeText(t); $(\"#gMmd\").textContent = \"copied\"; setTimeout(() => ($(\"#gMmd\").textContent = \"Mermaid\"), 1200); });\n if (params.get(\"view\") === \"graph\") setTimeout(() => setView(\"graph\"), 0);\n\n // events\n document.addEventListener(\"click\", (ev) => {\n const t = ev.target.closest(\"[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],.ev\");\n if (!t) return;\n if (t.dataset.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }\n if (t.dataset.expand) { ev.stopPropagation(); const id = t.dataset.expand; state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); return render(); }\n if (t.dataset.art) { state.filterArtifact = t.dataset.art; ev.stopPropagation(); return render(); }\n if (t.dataset.actor) { state.filterActor = t.dataset.actor; ev.stopPropagation(); return render(); }\n if (t.dataset.q) { $(\"#q\").value = t.dataset.q; ev.stopPropagation(); return render(); }\n if (t.dataset.goto) { ev.preventDefault(); state.sel = t.dataset.goto; state.filterArtifact = null; state.filterActor = null; render(); document.querySelector(`.ev[data-id=\"${CSS.escape(state.sel)}\"]`)?.scrollIntoView({ block: \"center\", behavior: \"smooth\" }); return; }\n if (t.dataset.clear) { if (t.dataset.clear === \"artifact\") state.filterArtifact = null; else state.filterActor = null; return render(); }\n if (t.classList.contains(\"ev\")) { state.sel = t.dataset.id; render(); }\n });\n $(\"#project\").addEventListener(\"change\", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });\n [\"#q\", \"#ftype\", \"#faction\"].forEach((s) => $(s).addEventListener(\"input\", render));\n $(\"#refresh\").addEventListener(\"click\", () => boot());\n $(\"#report\").addEventListener(\"click\", () => window.open(P.reportUrl(state.project, state.filterArtifact), \"_blank\"));\n $(\"#export\").addEventListener(\"click\", async () => {\n if (state.offline) { const blob = new Blob([JSON.stringify({ format: \"retrace-export/1\", scope: { project: state.project }, events: state.events }, null, 2)], { type: \"application/json\" }); return dl(blob, `${state.project}-events.json`); }\n const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });\n dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? \"-\" + state.filterArtifact.replace(/[^\\w.-]+/g, \"_\") : \"\"}.json`);\n });\n function dl(blob, name) { const a = document.createElement(\"a\"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }\n $(\"#share\").addEventListener(\"click\", () => { $(\"#shScope\").value = state.filterArtifact ? \"artifact\" : \"project\"; $(\"#shScope\").querySelector('[value=artifact]').disabled = !state.filterArtifact; $(\"#shResult\").textContent = \"\"; $(\"#shareDlg\").style.display = \"flex\"; });\n $(\"#shCancel\").addEventListener(\"click\", () => ($(\"#shareDlg\").style.display = \"none\"));\n $(\"#shCreate\").addEventListener(\"click\", async () => {\n const body = { artifact_id: $(\"#shScope\").value === \"artifact\" ? state.filterArtifact : undefined, label: $(\"#shLabel\").value.trim() || undefined, expires_in_days: Number($(\"#shExp\").value) || undefined };\n try {\n const res = await fetch(`${apiBase}/projects/${encodeURIComponent(state.project)}/share`, { method: \"POST\", headers: { \"content-type\": \"application/json\", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });\n const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);\n const u = r.url.startsWith(\"http\") ? r.url : location.origin + r.url;\n $(\"#shResult\").innerHTML = `Link: <a href=\"${esc(u)}\" target=\"_blank\">${esc(u)}</a> <button id=\"shCopy\">copy</button><br>Report: <a href=\"${esc(u)}/report\" target=\"_blank\">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : \"\"}`;\n $(\"#shCopy\").addEventListener(\"click\", () => navigator.clipboard?.writeText(u));\n } catch (e) { $(\"#shResult\").textContent = \"Failed: \" + e.message; }\n });\n $(\"#toggleConn\").addEventListener(\"click\", () => $(\"#conn\").classList.toggle(\"open\"));\n $(\"#connect\").addEventListener(\"click\", () => { apiBase = $(\"#apiUrl\").value.trim().replace(/\\/$/, \"\"); token = $(\"#apiTok\").value.trim(); state.offline = false; boot(); });\n $(\"#loadJson\").addEventListener(\"click\", () => $(\"#file\").click());\n $(\"#file\").addEventListener(\"change\", async (e) => {\n const f = e.target.files[0]; if (!f) return;\n const data = JSON.parse(await f.text());\n const events = Array.isArray(data) ? data : data.events ?? [];\n state.offline = true; state.events = events.sort((a, b) => a.seq - b.seq); state.verify = null; state.status = null;\n state.projects = [...new Set(events.map((x) => x.project))]; state.project = state.projects[0] ?? null;\n $(\"#project\").innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\");\n render();\n });\n function showErr(err) { $(\"#timeline\").innerHTML = `<div class=\"empty\">Could not load: ${esc(err.message)}<br><br>Open ⚙ to set the API URL / token, or load a JSON export.</div>`; $(\"#conn\").classList.add(\"open\"); }\n async function boot() { try { await loadProjects(); await loadEvents(); } catch (err) { showErr(err); } }\n boot();\n setInterval(() => { if (!state.offline && document.visibilityState === \"visible\") loadEvents().catch(() => {}); }, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
|
2
|
+
export const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Retrace</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a7;\n --human: #f2a93b; --agent: #5aa9ff; --system: #9aa3b5; --ok: #3ccb7f; --bad: #ff5c6c; --accent: #c7b6ff;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n @media (prefers-color-scheme: light) {\n :root { --bg: #f6f7fa; --panel: #ffffff; --panel-2: #f0f2f6; --line: #dfe3ea; --text: #16181d; --muted: #616a7d;\n --human: #c77800; --agent: #1f6fe0; --system: #6b7385; --ok: #178f4f; --bad: #d1303f; --accent: #6b4fd8; }\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }\n header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 8px 0 0; letter-spacing: .04em; }\n header h1 span { color: var(--accent); }\n select, input, button { font: inherit; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }\n input { min-width: 140px; }\n header button { padding: 6px 9px; }\n button { cursor: pointer; }\n button.primary { background: var(--accent); color: #111; border-color: transparent; font-weight: 600; }\n .badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); background: var(--panel-2); }\n .badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }\n .badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }\n .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }\n main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; height: calc(100vh - 53px); }\n @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; } #detail { border-left: 0; border-top: 1px solid var(--line); } }\n #timeline { overflow: auto; padding: 16px 20px 40px; }\n #detail { border-left: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 16px; }\n .legend { display: flex; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }\n .legend .dot.human { background: var(--human); } .legend .dot.agent { background: var(--agent); } .legend .dot.system { background: var(--system); }\n .day { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 8px 0; }\n .ev { display: grid; grid-template-columns: 64px 18px 1fr; gap: 10px; align-items: start; padding: 8px 8px; border-radius: 8px; cursor: pointer; position: relative; }\n .ev:hover { background: var(--panel-2); }\n .ev.sel { background: var(--panel-2); outline: 1px solid var(--accent); }\n .ev .t { color: var(--muted); font-family: var(--mono); font-size: 12px; padding-top: 2px; }\n .ev .rail { position: relative; height: 100%; }\n .ev .rail::before { content: \"\"; position: absolute; left: 8px; top: -10px; bottom: -14px; width: 2px; background: var(--line); }\n .ev .node { position: absolute; left: 3px; top: 3px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--line); }\n .ev.human .node { background: var(--human); } .ev.agent .node { background: var(--agent); border-radius: 3px; } .ev.system .node { background: var(--system); }\n .ev .who { font-weight: 600; }\n .ev.human .who { color: var(--human); } .ev.agent .who { color: var(--agent); } .ev.system .who { color: var(--system); }\n .ev .verb { color: var(--accent); }\n .ev .art { font-family: var(--mono); font-size: 12.5px; }\n .ev .why { color: var(--muted); font-size: 12.5px; margin-top: 2px; }\n .ev .link { color: var(--muted); font-size: 11px; }\n .chip { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-right: 4px; cursor: pointer; }\n .chip:hover { border-color: var(--accent); }\n .chip.more { color: var(--accent); border-style: dashed; }\n .chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }\n /* Everything that LOOKS clickable must do something: these are the affordances the click handler honours. */\n .badge.click { cursor: pointer; } .badge.click:hover { border-color: var(--accent); }\n .who[data-actor] { cursor: pointer; } .who[data-actor]:hover { text-decoration: underline; }\n [data-ftype] { cursor: pointer; } [data-ftype]:hover { color: var(--text); } .legend [data-ftype].on { color: var(--text); font-weight: 600; }\n .stable { border-collapse: collapse; font-size: 12px; width: 100%; margin-bottom: 14px; }\n .stable td, .stable th { padding: 3px 8px 3px 0; text-align: left; vertical-align: top; }\n .stable th { color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }\n .num.bad { color: var(--bad); font-weight: 600; } .num.ok { color: var(--ok); }\n #toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; font-size: 13px; z-index: 30; box-shadow: 0 4px 16px rgba(0,0,0,.3); display: none; max-width: 90vw; }\n #toast.on { display: block; }\n button:disabled { opacity: .45; cursor: not-allowed; }\n #detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }\n .kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }\n .kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }\n .kv .v { word-break: break-word; }\n .kv .v.mono { font-family: var(--mono); font-size: 12px; }\n pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-family: var(--mono); font-size: 12px; overflow: auto; max-height: 240px; margin: 4px 0 0; white-space: pre-wrap; }\n .why-chain { border-left: 2px solid var(--line); padding-left: 10px; margin: 6px 0 14px 4px; }\n .why-chain .step { margin: 6px 0; font-size: 13px; }\n .why-chain .step .who { font-weight: 600; }\n .why-chain .step.human .who { color: var(--human); } .why-chain .step.agent .who { color: var(--agent); }\n .why-chain .step .arrow { color: var(--muted); font-size: 11px; }\n .empty { color: var(--muted); padding: 40px 0; text-align: center; }\n .stat { display: flex; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }\n .stat b { color: var(--text); font-weight: 600; }\n #conn { display: none; gap: 8px; align-items: center; }\n #conn.open { display: flex; }\n .hash { font-family: var(--mono); font-size: 11px; color: var(--muted); }\n a { color: var(--accent); }\n .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }\n .seg button { border: 0; border-radius: 0; padding: 6px 10px; background: var(--panel-2); color: var(--muted); }\n .seg button.on { background: var(--accent); color: #111; font-weight: 600; }\n #graph { display: none; overflow: auto; position: relative; }\n #graph.on { display: block; } #timeline.off { display: none; }\n #graph svg { display: block; min-width: 100%; }\n .gctl { position: sticky; top: 0; left: 0; z-index: 2; width: 100%; display: flex; gap: 12px; align-items: center; padding: 10px 16px; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(4px); font-size: 12px; color: var(--muted); flex-wrap: wrap; }\n .gctl label { display: inline-flex; gap: 4px; align-items: center; }\n .gnode { cursor: pointer; }\n .gnode rect, .gnode ellipse, .gnode polygon { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.2; }\n .gnode:hover rect, .gnode:hover ellipse, .gnode:hover polygon, .gnode.sel rect, .gnode.sel ellipse, .gnode.sel polygon { stroke: var(--accent); stroke-width: 2; }\n .gnode.k-task rect { stroke: var(--human); } .gnode.k-commit rect { stroke-dasharray: 3 2; } .gnode.k-pr rect { stroke: var(--ok); } .gnode.k-command rect { stroke: var(--system); }\n .gnode.actor.human ellipse { stroke: var(--human); } .gnode.actor.agent polygon { stroke: var(--agent); } .gnode.actor.system ellipse { stroke: var(--system); }\n .gnode text { fill: var(--text); font-size: 11.5px; font-family: var(--mono); pointer-events: none; }\n .gnode text.sub { fill: var(--muted); font-size: 10px; font-family: system-ui, sans-serif; }\n .gedge { fill: none; stroke: var(--muted); stroke-width: 1.2; opacity: .75; }\n .gedge.derived { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }\n .gedge.touched { stroke-dasharray: 3 3; opacity: .45; }\n .gedge.dim { opacity: .12; } .gnode.dim { opacity: .25; }\n .glabel { fill: var(--muted); font-size: 9.5px; }\n</style>\n</head>\n<body>\n<header>\n <h1>RE<span>TRACE</span></h1>\n <select id=\"project\" title=\"Project\"></select>\n <input id=\"q\" placeholder=\"search…\" style=\"width:150px\" />\n <select id=\"ftype\"><option value=\"\">all actors</option><option value=\"human\">humans</option><option value=\"agent\">agents</option><option value=\"system\">system</option></select>\n <select id=\"faction\"><option value=\"\">any action</option></select>\n <span class=\"seg\"><button id=\"vTimeline\" class=\"on\">Timeline</button><button id=\"vGraph\">Graph</button></span>\n <span id=\"verify\" class=\"badge click\" data-pane=\"status\" title=\"Chain integrity — click for details\"><span class=\"dot\"></span><span>verifying…</span></span>\n <span id=\"status\" class=\"badge click\" data-pane=\"status\" title=\"Capture completeness and causal coverage — click for details\">capture…</span>\n <span id=\"sharedBadge\" class=\"badge\" style=\"display:none\" title=\"Read-only shared view\">🔗 shared view</span>\n <button id=\"report\" title=\"Printable report (save as PDF)\">Report</button>\n <button id=\"export\" title=\"Download signed JSON bundle\">Export</button>\n <button id=\"share\" title=\"Create a read-only share link\">Share</button>\n <button id=\"refresh\" title=\"Reload\">↻</button>\n <button id=\"toggleConn\" title=\"Connection settings\">⚙</button>\n <span id=\"conn\"><input id=\"apiUrl\" placeholder=\"API URL (blank = same origin)\" /><input id=\"apiTok\" placeholder=\"token\" type=\"password\" /><button id=\"connect\" class=\"primary\">Connect</button><button id=\"loadJson\">Load JSON…</button><input type=\"file\" id=\"file\" accept=\"application/json\" style=\"display:none\" /></span>\n</header>\n<div id=\"shareDlg\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;align-items:center;justify-content:center\">\n <div style=\"background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;width:min(520px,92vw)\">\n <h3 style=\"margin:0 0 10px;font-size:15px\">Create read-only share link</h3>\n <div class=\"link\" style=\"margin-bottom:8px\">Anyone with the link can view (not edit) the events in scope, verify the chain, and download the signed export / report.</div>\n <div style=\"display:grid;gap:8px\">\n <label>Scope <select id=\"shScope\"><option value=\"project\">whole project</option><option value=\"artifact\">current artifact filter</option></select></label>\n <label>Label <input id=\"shLabel\" placeholder=\"e.g. Jab counter — client review\" style=\"width:100%\"></label>\n <label>Expires <select id=\"shExp\"><option value=\"\">never</option><option value=\"7\">7 days</option><option value=\"30\">30 days</option><option value=\"90\">90 days</option></select></label>\n <div id=\"shResult\" class=\"link\" style=\"word-break:break-all\"></div>\n <div style=\"display:flex;gap:8px;justify-content:flex-end\"><button id=\"shCancel\">Close</button><button id=\"shCreate\" class=\"primary\">Create link</button></div>\n </div>\n </div>\n</div>\n<div id=\"toast\" role=\"status\"></div>\n<main>\n <section id=\"timeline\"><div class=\"empty\">Loading…</div></section>\n <section id=\"graph\">\n <div class=\"gctl\">\n <span><b id=\"gStat\"></b></span><span id=\"gFilters\" class=\"stat\" style=\"margin:0\"></span>\n <label><input type=\"checkbox\" id=\"gActors\"> show people & agents</label>\n <label><input type=\"checkbox\" id=\"gFlow\" checked> causal flow edges</label>\n <span style=\"margin-left:auto\">━ derived_from → causal flow ┈ touched · click a node to highlight it and filter the timeline · click it again to clear</span>\n <button id=\"gDot\" title=\"Download Graphviz DOT\">DOT</button><button id=\"gMmd\" title=\"Copy Mermaid\">Mermaid</button>\n </div>\n <div id=\"gsvg\"></div>\n </section>\n <aside id=\"detail\"><div class=\"empty\">Select an event to see who / what / when / where / why / how.</div></aside>\n</main>\n<script>\n(() => {\n const $ = (s) => document.querySelector(s);\n // pane: what the right-hand pane shows when no event is selected — \"status\" (chain + capture detail) or \"mermaid\" (copy fallback).\n const state = { events: [], project: null, projects: [], sel: null, pane: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true, fp: null };\n const params = new URLSearchParams(location.search);\n if (params.get(\"order\") === \"asc\") state.newestFirst = false;\n let apiBase = params.get(\"api\") ?? \"\";\n let token = params.get(\"token\") ?? \"\";\n $(\"#apiUrl\").value = apiBase; $(\"#apiTok\").value = token;\n // Shared mode: /s/<id> → all reads go through /s/<id>/*, scope locked server-side\n const shareMatch = location.pathname.match(/\\/s\\/([^/]+)/);\n const shareId = shareMatch ? shareMatch[1] : null;\n if (shareId) {\n document.body.classList.add(\"shared\");\n $(\"#sharedBadge\").style.display = \"\";\n [\"#share\", \"#toggleConn\", \"#project\"].forEach((s) => ($(s).style.display = \"none\"));\n }\n const P = {\n projects: () => shareId ? api(`/s/${shareId}/meta`).then((m) => [m.project]) : api(\"/projects\"),\n events: (p) => shareId ? api(`/s/${shareId}/events?limit=5000`) : api(`/projects/${encodeURIComponent(p)}/events?limit=5000`),\n verify: (p) => shareId ? api(`/s/${shareId}/verify`) : api(`/projects/${encodeURIComponent(p)}/verify`),\n status: (p) => shareId ? Promise.resolve(null) : api(`/projects/${encodeURIComponent(p)}/status`),\n exportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/export` : `${apiBase}/projects/${encodeURIComponent(p)}/export?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n reportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/report` : `${apiBase}/projects/${encodeURIComponent(p)}/report?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n };\n\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]));\n const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const fmtDay = (iso) => new Date(iso).toLocaleDateString([], { weekday: \"short\", year: \"numeric\", month: \"short\", day: \"numeric\" });\n const actorName = (a) => a.display_name || a.id;\n const fmtDur = (ms) => ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` : ms < 3600000 ? `${Math.round(ms / 60000)} min` : `${(ms / 3600000).toFixed(1)} h`;\n const actorTag = (a) => a.type === \"agent\" ? (a.model ? ` · ${a.model}` : \" · agent\") : a.type === \"system\" ? \" · system\" : \"\";\n const verbOf = (e) => e.action === \"other\" ? (e.action_detail || \"acted on\") : e.action;\n const artLabel = (a) => a.label || a.id;\n // PROV role of a ref within its event: in (used) · out (generated) · in/out (both); no marker when unspecified.\n const roleMark = (r) => r === \"used\" ? \"in\" : r === \"generated\" ? \"out\" : r === \"both\" ? \"in/out\" : \"\";\n const roleTitle = (r) => r === \"used\" ? \" · used (input)\" : r === \"generated\" ? \" · generated (output)\" : r === \"both\" ? \" · used and generated\" : \"\";\n const roleTag = (a) => roleMark(a.role) ? `<i class=\"role\">${roleMark(a.role)}</i>` : \"\";\n const whereText = (loc) => loc ? [loc.system, loc.environment, loc.path || loc.url, loc.device, loc.client].filter(Boolean).join(\" · \") : \"\";\n // Second WHERE line: the run-identity fields. Deliberately NOT in whereText, which the timeline reuses and which\n // must stay a readable one-liner. session/workspace are clickable — they drop into the search box, which already\n // matches the whole event JSON, so one click shows everything from that agent run or that isolated worktree.\n const findable = (v) => `<span class=\"chip\" data-q=\"${esc(v)}\" title=\"search for ${esc(v)}\">${esc(v)}</span>`;\n const whereExtra = (loc) => {\n const bits = [];\n if (loc.ide) bits.push(`in <b>${esc(loc.ide)}</b>`);\n if (loc.workspace) bits.push(`workspace ${findable(loc.workspace)}`);\n if (loc.session) bits.push(`session ${findable(loc.session)}`);\n if (loc.surface) bits.push(loc.surface === \"tty\" ? \"at a terminal\" : \"no terminal · agent-driven\");\n return bits.length ? `<div class=\"link\">${bits.join(\" · \")}</div>` : \"\";\n };\n // Latest known label per artifact id: the label on the last event (by seq) that carries one.\n // Used wherever the UI names an artifact; the event detail pane keeps each event's as-at label.\n const latestLabels = (events) => { const m = new Map(); for (const e of [...events].sort((a, b) => a.seq - b.seq)) for (const a of e.artifacts) if (a.label) m.set(a.id, a.label); return m; };\n\n async function api(path) {\n const url = (apiBase || \"\") + path;\n const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);\n return res.json();\n }\n\n async function loadProjects() {\n state.projects = await P.projects();\n const sel = $(\"#project\");\n sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\") || \"<option value=''>(no projects yet)</option>\";\n // Keep the project the user switched to: ↻ and Connect re-run this, and silently jumping back to the first\n // project made refresh look like it had discarded their selection.\n const want = params.get(\"project\"), keep = state.project;\n state.project = state.projects.includes(keep) ? keep : state.projects.includes(want) ? want : state.projects[0] ?? null;\n if (state.project) sel.value = state.project;\n }\n\n async function loadEvents() {\n if (state.offline) return render();\n if (!state.project) { state.events = []; return render(); }\n const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);\n // The 15 s poll must not rebuild the panes when nothing changed: that closed an open \"raw JSON\", reset the\n // timeline scroll and blanked a half-read detail. Same length + same head hash + same verify result = same view.\n const fp = `${state.project}:${events.length}:${events.at(-1)?.hash ?? \"\"}:${verify?.ok}:${verify?.checked}`;\n const same = fp === state.fp;\n state.events = events; state.verify = verify; state.status = status; state.fp = fp;\n if (same) { localVerifyBadge(); statusBadge(); if (state.pane === \"status\") renderDetail(); return; }\n render();\n }\n\n function localVerifyBadge() {\n const b = $(\"#verify\"); const v = state.verify;\n if (!v) { b.className = \"badge click\"; b.lastElementChild.textContent = state.offline ? \"offline snapshot\" : \"not verified\"; b.title = \"Chain not verified — click for details\"; return; }\n b.className = \"badge click \" + (v.ok ? \"ok\" : \"bad\");\n b.lastElementChild.textContent = v.ok ? `chain intact · ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;\n b.title = (v.ok ? \"Every event re-hashed and every prev_hash link checked\" : v.reason || \"Chain broken\") + \" — click for details\";\n }\n\n function statusBadge() {\n const b = $(\"#status\"), s = state.status;\n if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? \"none\" : \"\"; b.className = \"badge click\"; b.textContent = \"capture unknown\"; b.title = \"No /status answer — click for details\"; return; }\n b.style.display = \"\";\n const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;\n b.className = \"badge click \" + (gaps ? \"bad\" : \"ok\");\n b.textContent = `${s.causality.coverage_pct}% causal · ${s.capture.unlinked_commits} unlinked commits`;\n b.title = `${s.capture.instructions_without_followup} instructions without follow-up · ${s.capture.agent_events_without_model} agent events missing model · ${s.capture.artifact_refs_without_role} artifact refs missing role — click for details`;\n }\n\n function filtered(opts = {}) {\n const q = $(\"#q\").value.trim().toLowerCase();\n const t = $(\"#ftype\").value; const a = $(\"#faction\").value;\n return state.events.filter((e) => {\n if (t && e.actor.type !== t) return false;\n if (a && e.action !== a) return false;\n if (!opts.ignoreArtifact && state.filterArtifact && !e.artifacts.some((x) => x.id === state.filterArtifact)) return false;\n if (state.filterActor && e.actor.id !== state.filterActor) return false;\n if (q && !JSON.stringify(e).toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n // The active-filter chips (artifact · actor · search), each clearing itself on click. Shared by the timeline's\n // stat bar and the graph's control bar — a filter set from a graph node used to be invisible, and unclearable,\n // until you switched back to the timeline.\n const filterChipsHtml = (labels) => {\n const q = $(\"#q\").value.trim();\n return (state.filterArtifact ? `<span>artifact: <span class=\"chip\" data-clear=\"artifact\" title=\"${esc(state.filterArtifact)} — click to clear\">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} ✕</span></span>` : \"\") +\n (state.filterActor ? `<span>actor: <span class=\"chip\" data-clear=\"actor\" title=\"click to clear\">${esc(state.filterActor)} ✕</span></span>` : \"\") +\n (q ? `<span>search: <span class=\"chip\" data-clear=\"q\" title=\"click to clear\">${esc(q)} ✕</span></span>` : \"\");\n };\n\n function render() {\n localVerifyBadge();\n statusBadge();\n const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);\n const inView = new Set(state.events.map((e) => e.id));\n const acts = [...new Set(state.events.map((e) => e.action))].sort();\n const fa = $(\"#faction\"); const cur = fa.value;\n fa.innerHTML = `<option value=\"\">any action</option>` + acts.map((a) => `<option ${a === cur ? \"selected\" : \"\"}>${esc(a)}</option>`).join(\"\");\n const list = filtered(); if (state.newestFirst) list.reverse();\n const humans = new Set(state.events.filter((e) => e.actor.type === \"human\").map((e) => e.actor.id)).size;\n const agents = new Set(state.events.filter((e) => e.actor.type === \"agent\").map((e) => e.actor.id)).size;\n const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;\n const ft = $(\"#ftype\").value;\n let html = `<div class=\"stat\"><span><b>${state.events.length}</b> events</span><span data-ftype=\"human\" title=\"show only humans\"><b>${humans}</b> humans</span><span data-ftype=\"agent\" title=\"show only agents\"><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>${filterChipsHtml(labels)}</div>`;\n // Legend entries filter by actor type (and show which one is active); the same select in the header stays in sync.\n const leg = (t, extra) => `<span data-ftype=\"${t}\" class=\"${ft === t ? \"on\" : \"\"}\" title=\"${ft === t ? `showing only ${t} — click to show all` : `show only ${t}`}\"><i class=\"dot ${t}\" style=\"display:inline-block${extra}\"></i> ${t}</span>`;\n html += `<div class=\"legend\">${leg(\"human\", \"\")}${leg(\"agent\", \";border-radius:2px\")}${leg(\"system\", \"\")}<span style=\"margin-left:auto\"><span class=\"chip\" data-order=\"1\" title=\"flip order\">${state.newestFirst ? \"newest first ▾\" : \"oldest first ▴\"}</span></span></div>`;\n if (!list.length) html += `<div class=\"empty\">${state.events.length ? `No events match these filters. <span class=\"chip\" data-clear=\"all\" title=\"clear search, actor, action and artifact filters\">clear all filters</span>` : \"No events yet — log one with the MCP tool <code>retrace_log</code> or POST /events.\"}</div>`;\n let lastDay = \"\";\n for (const e of list) {\n const day = fmtDay(e.timestamp);\n if (day !== lastDay) { html += `<div class=\"day\">${esc(day)}</div>`; lastDay = day; }\n const MAX = 6; const open = state.expanded.has(e.id); const shown = open ? e.artifacts : e.artifacts.slice(0, MAX); const extra = e.artifacts.length - shown.length;\n const arts = shown.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(labelOf(a))}</span>`).join(\"\") +\n (extra > 0 ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"show all ${e.artifacts.length} artifacts\">+${extra} more</span>` : open && e.artifacts.length > MAX ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"collapse\">show less</span>` : \"\");\n const where = e.location?.path || e.location?.url || e.location?.system || \"\";\n const how = e.method?.tool ? `via ${esc(e.method.tool)}` : \"\";\n html += `<div class=\"ev ${e.actor.type} ${state.sel === e.id ? \"sel\" : \"\"}\" data-id=\"${esc(e.id)}\">\n <div class=\"t\">${fmtTime(e.timestamp)}<br><span class=\"link\">#${e.seq}</span></div>\n <div class=\"rail\"><div class=\"node\"></div></div>\n <div>\n <div><span class=\"who\" data-actor=\"${esc(e.actor.id)}\">${esc(actorName(e.actor))}</span><span class=\"link\">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? \" · for \" + esc(e.actor.on_behalf_of) : \"\"}</span> <span class=\"verb\">${esc(verbOf(e))}</span> <span class=\"art\">${arts}</span> <span class=\"link\">${where ? \"@ \" + esc(where) + \" \" : \"\"}${how}</span></div>\n ${e.intent ? `<div class=\"why\">${esc(e.intent)}</div>` : \"\"}\n ${e.caused_by ? `<div class=\"link\">↳ caused by ${inView.has(e.caused_by) ? `<a href=\"#\" data-goto=\"${esc(e.caused_by)}\" title=\"jump to this event\">${esc(e.caused_by.slice(0, 16))}…</a>` : `<span class=\"mono\" title=\"${esc(e.caused_by)} — not among the loaded events (outside this view's scope)\">${esc(e.caused_by.slice(0, 16))}… <i>(not in view)</i></span>`}</div>` : \"\"}\n </div></div>`;\n }\n $(\"#timeline\").innerHTML = html;\n if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;\n // Report and Share are server features; on a loaded JSON snapshot they can only fail, so say so instead of opening a dead tab.\n $(\"#report\").disabled = state.offline; $(\"#report\").title = state.offline ? \"Report needs a live ledger — not available for an offline snapshot\" : \"Printable report (save as PDF)\";\n $(\"#share\").disabled = state.offline; $(\"#share\").title = state.offline ? \"Share links need a live ledger — not available for an offline snapshot\" : \"Create a read-only share link\";\n renderDetail();\n renderGraph();\n }\n\n function whyChain(id) {\n const byId = new Map(state.events.map((e) => [e.id, e]));\n const chain = []; const seen = new Set(); let cur = byId.get(id);\n while (cur && !seen.has(cur.id) && chain.length < 30) { seen.add(cur.id); chain.push(cur); cur = cur.caused_by ? byId.get(cur.caused_by) : null; }\n return chain;\n }\n function effects(id) { return state.events.filter((e) => e.caused_by === id); }\n\n function renderDetail() {\n if (state.pane === \"status\") return renderStatus();\n if (state.pane === \"mermaid\") { $(\"#detail\").innerHTML = `<h2>Mermaid</h2><div class=\"link\" style=\"margin-bottom:6px\">The clipboard is not available here — copy the text below.</div><pre style=\"max-height:none\">${esc(state.mermaidText || \"\")}</pre>`; return; }\n const e = state.events.find((x) => x.id === state.sel);\n const d = $(\"#detail\");\n if (!e) { d.innerHTML = `<div class=\"empty\">Select an event to see who / what / when / where / why / how.</div>`; return; }\n const labels = latestLabels(state.events);\n const kv = (k, v, mono) => v ? `<div class=\"k\">${k}</div><div class=\"v ${mono ? \"mono\" : \"\"}\">${v}</div>` : \"\";\n // Every section always renders: absent data says \"(not recorded)\" instead of vanishing,\n // and a section that fails to render falls back on its own — it never blanks the rest of the pane.\n const sect = (k, fn, mono) => { let v; try { v = fn(); } catch { v = `<span class=\"link\">(could not render — see raw JSON)</span>`; } return kv(k, v || `<span class=\"link\">(not recorded)</span>`, mono); };\n let chainHtml, kidsHtml = \"\", kids = [];\n try {\n const chain = whyChain(e.id).slice(1); kids = effects(e.id);\n const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);\n const who = (c) => `<span class=\"who\" data-actor=\"${esc(c.actor.id)}\" title=\"${esc(c.actor.id)} — filter the timeline by this actor\">${esc(actorName(c.actor))}</span>`;\n chainHtml = parentMissing ? `<div class=\"link\" style=\"margin-bottom:12px\">caused by <span class=\"mono\">${esc(e.caused_by)}</span> — outside this view's scope</div>` : chain.length ? `<div class=\"why-chain\">` + chain.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">↳ because</span> ${who(c)} ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))}${c.intent ? ` — <i>${esc(c.intent)}</i>` : \"\"} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\" title=\"jump to event #${c.seq}\">#${c.seq}</a></div>`).join(\"\") + `</div>` : `<div class=\"link\" style=\"margin-bottom:12px\">no causal parent recorded — this is a root event</div>`;\n kidsHtml = kids.length ? `<div class=\"why-chain\">` + kids.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">→ led to</span> ${who(c)} ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\" title=\"jump to event #${c.seq}\">#${c.seq}</a></div>`).join(\"\") + `</div>` : \"\";\n } catch { chainHtml = `<div class=\"link\" style=\"margin-bottom:12px\">(could not render the causal chain — see raw JSON)</div>`; }\n let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = \"(not serializable)\"; }\n d.innerHTML = `\n <h2>Event #${e.seq}</h2>\n <div class=\"kv\">\n ${sect(\"Who\", () => `<span class=\"who\" data-actor=\"${esc(e.actor.id)}\" style=\"color:var(--${esc(e.actor.type)});font-weight:600\" title=\"${esc(e.actor.id)} — filter the timeline by this actor\">${esc(actorName(e.actor))}</span> <span class=\"link\">${esc(e.actor.type)}${e.actor.model ? \" · \" + esc(e.actor.model) : \"\"}${e.actor.version ? \" \" + esc(e.actor.version) : \"\"}${e.actor.on_behalf_of ? \"<br>on behalf of \" + esc(e.actor.on_behalf_of) : \"\"}</span>`)}\n ${sect(\"What\", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== \"file\" ? `<span class=\"link\">${esc(a.kind)}</span>` : \"\"}${a.derived_from?.length ? `<div class=\"link\">derived from ${a.derived_from.map((s) => `<span class=\"chip\" data-art=\"${esc(s)}\" title=\"${esc(s)} — filter by this artifact\">${esc(labels.get(s) || s)}</span>`).join(\"\")}</div>` : \"\"}`).join(\" \")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : \"\"}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : \"\"}${e.change?.after_hash ? `<div class=\"hash\">after ${esc(e.change.after_hash)}</div>` : \"\"}`)}\n ${sect(\"When\", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class=\"link\">· took ${fmtDur(e.duration_ms)}</span>` : \"\"}<div class=\"link\">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}\n ${sect(\"Where\", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : \"\")}\n ${sect(\"Why\", () => e.intent ? esc(e.intent) : \"\")}\n ${sect(\"How\", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : \"\"}${e.method.automated != null ? `<span class=\"link\">${e.method.automated ? \"automated\" : \"manual\"}</span>` : \"\"}${e.method.instruction ? `<div class=\"link\">instruction: ${esc(e.method.instruction)}</div>` : \"\"}${e.method.tokens != null ? `<div class=\"link\">${e.method.tokens} tokens${e.method.cost_usd != null ? ` · $${e.method.cost_usd}` : \"\"}</div>` : \"\"}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : \"\"}` : \"\")}\n ${sect(\"Tags\", () => e.tags?.length ? e.tags.map((t) => `<span class=\"chip\" data-q=\"${esc(t)}\" title=\"search for ${esc(t)}\">${esc(t)}</span>`).join(\"\") : \"\")}\n </div>\n <h2>Causal chain</h2>${chainHtml}\n ${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : \"\"}\n <h2>Integrity</h2>\n <div class=\"kv\"><div class=\"k\">id</div><div class=\"v mono\">${esc(e.id)}</div><div class=\"k\">hash</div><div class=\"v hash\">${esc(e.hash)}</div><div class=\"k\">prev</div><div class=\"v hash\">${esc(e.prev_hash)}</div></div>\n <details><summary class=\"link\">raw JSON</summary><pre>${esc(raw)}</pre></details>`;\n }\n\n // The status pane: what the two header badges summarise, in full. Every number is the ledger's own /status and\n // /verify answer (packages/core/src/status.ts); the \"what closes it\" column states the capture rule, not a guess.\n function renderStatus() {\n const d = $(\"#detail\"), s = state.status, v = state.verify;\n const num = (x, bad = (n) => n > 0) => `<span class=\"num ${bad(x) ? \"bad\" : \"ok\"}\">${esc(x)}</span>`;\n const when = (iso) => iso ? esc(new Date(iso).toLocaleString()) : \"—\";\n let chain;\n if (v) chain = v.ok ? `<span class=\"num ok\">intact</span> · ${esc(v.checked)} events re-hashed, every prev_hash link checked` : `<span class=\"num bad\">BROKEN at #${esc(v.first_bad_seq)}</span>${v.reason ? `<div class=\"link\">${esc(v.reason)}</div>` : \"\"}`;\n else chain = `<span class=\"link\">${state.offline ? \"not verified — an offline snapshot cannot be checked against the ledger\" : \"not verified — /verify did not answer\"}</span>`;\n let html = `<h2>Project status${state.project ? ` · ${esc(state.project)}` : \"\"}</h2><div class=\"kv\"><div class=\"k\">Chain</div><div class=\"v\">${chain}</div></div>`;\n if (!s?.capture || !s?.causality) {\n html += `<div class=\"link\">Capture and causal coverage come from the ledger's <span class=\"mono\">/status</span> endpoint, which ${shareId ? \"is not exposed on share links\" : state.offline ? \"an offline snapshot cannot reach\" : \"did not answer\"}.</div>`;\n d.innerHTML = html; return;\n }\n const c = s.capture, z = s.causality;\n html += `<h2>Causal coverage</h2><div class=\"kv\">\n <div class=\"k\">Rooted</div><div class=\"v\">${num(z.coverage_pct, (n) => n < 100)}% — ${esc(z.rooted_in_human_instruction)} of ${esc(z.eligible_events)} eligible events trace back, caused_by by caused_by, to a human instruction<div class=\"link\">eligible = every agent event plus every commit and merge</div></div>\n <div class=\"k\">Unlinked</div><div class=\"v\">${num(z.unlinked)} <span class=\"link\">chains that stop at an event with no caused_by</span></div>\n <div class=\"k\">Broken</div><div class=\"v\">${num(z.broken_links)} <span class=\"link\">chains whose caused_by names an event that is not in the ledger</span></div>\n </div>`;\n html += `<h2>Capture gaps</h2><table class=\"stable\"><tr><th>gap</th><th>count</th><th>what closes it</th></tr>\n <tr><td>commits without a causal root</td><td>${num(c.unlinked_commits)} / ${esc(c.commits)}</td><td>a <span class=\"mono\">Retrace-Caused-By</span> trailer naming the instruction</td></tr>\n <tr><td>agent events without a model</td><td>${num(c.agent_events_without_model)} / ${esc(c.agent_events)}</td><td><span class=\"mono\">actor.model</span> on every agent event</td></tr>\n <tr><td>instructions without follow-up</td><td>${num(c.instructions_without_followup)} / ${esc(c.instructions)}</td><td>at least one event whose caused_by is the instruction</td></tr>\n <tr><td>artifact refs without a role</td><td>${num(c.artifact_refs_without_role)} / ${esc(c.artifact_refs)}</td><td><span class=\"mono\">role</span>: used · generated · both on each ref</td></tr>\n </table>`;\n html += `<h2>Actors</h2><table class=\"stable\"><tr><th>who</th><th>events</th><th>models</th><th>last seen</th></tr>` +\n s.actors.map((a) => `<tr><td><span class=\"who\" data-actor=\"${esc(a.id)}\" style=\"color:var(--${esc(a.type)})\" title=\"${esc(a.id)} — filter the timeline by this actor\">${esc(a.id)}</span> <span class=\"link\">${esc(a.type)}</span></td><td>${esc(a.events)}</td><td class=\"mono\">${a.models?.length ? esc(a.models.join(\", \")) : a.type === \"agent\" ? `<span class=\"num bad\">none recorded</span>` : \"—\"}</td><td>${when(a.last_seen)}</td></tr>`).join(\"\") + `</table>`;\n html += `<h2>Integrations</h2><table class=\"stable\"><tr><th>location.system</th><th>events</th><th>last seen</th></tr>` +\n s.integrations.map((i) => `<tr><td><span class=\"chip\" data-q=\"${esc(i.system)}\" title=\"search for ${esc(i.system)}\">${esc(i.system)}</span></td><td>${esc(i.events)}</td><td>${when(i.last_seen)}</td></tr>`).join(\"\") + `</table>`;\n html += `<div class=\"link\">${esc(s.events?.total ?? state.events.length)} events · last event ${when(s.events?.last_event_at)} · computed ${when(s.generated_at)}</div>`;\n d.innerHTML = html;\n }\n\n // ---------- lineage graph ----------\n function buildLineage(events, opts = {}) {\n const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));\n const sorted = [...events].sort((a, b) => a.seq - b.seq);\n const art = (id, label, kind, seq) => { let n = nodes.get(\"a:\" + id); if (!n) { n = { id, type: \"artifact\", label: label || id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} }; nodes.set(\"a:\" + id, n); } if (label) n.label = label; if (kind && !n.kind) n.kind = kind; return n; };\n const addEdge = (from, to, type, via) => { if (from === to) return; const k = `${type}|${from}|${to}`; let ed = edges.get(k); if (!ed) { ed = { from, to, type, weight: 0, via: [] }; edges.set(k, ed); } ed.weight++; if (ed.via.length < 5) ed.via.push(via); };\n for (const e of sorted) {\n for (const a of e.artifacts) {\n const n = art(a.id, a.label, a.kind, e.seq); n.events++; n.last_seq = e.seq; if (!n.actors.includes(e.actor.id)) n.actors.push(e.actor.id); n.actions[e.action] = (n.actions[e.action] || 0) + 1;\n for (const src of a.derived_from || []) { art(src, undefined, undefined, e.seq); addEdge(src, a.id, \"derived\", e.id); }\n if (opts.includeActors) { let u = nodes.get(\"u:\" + e.actor.id); if (!u) { u = { id: e.actor.id, type: \"actor\", label: actorName(e.actor), kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq }; nodes.set(\"u:\" + e.actor.id, u); } u.events++; addEdge(\"u:\" + e.actor.id, a.id, \"touched\", e.id); }\n }\n if (e.caused_by && opts.flow !== false) { const p = byId.get(e.caused_by); if (p) for (const pa of p.artifacts) for (const ca of e.artifacts) if (pa.id !== ca.id) addEdge(pa.id, ca.id, \"flow\", e.id); }\n }\n return { nodes: [...nodes.values()], edges: [...edges.values()] };\n }\n function layerLineage(l) {\n const ids = l.nodes.filter((n) => n.type === \"artifact\").map((n) => n.id);\n const inc = new Map(ids.map((id) => [id, []])); for (const e of l.edges) if (e.type !== \"touched\" && inc.has(e.to) && inc.has(e.from)) inc.get(e.to).push(e.from);\n const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq])); const layer = new Map(), visiting = new Set();\n const depth = (id) => { if (layer.has(id)) return layer.get(id); if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of inc.get(id) || []) if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0)) d = Math.max(d, depth(p) + 1); visiting.delete(id); layer.set(id, d); return d; };\n for (const id of ids) depth(id); return layer;\n }\n let lastLineage = null;\n function renderGraph() {\n if (!$(\"#graph\").classList.contains(\"on\")) return;\n const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding\n const l = buildLineage(events, { includeActors: $(\"#gActors\").checked, flow: $(\"#gFlow\").checked });\n lastLineage = l;\n $(\"#gFilters\").innerHTML = filterChipsHtml(latestLabels(state.events));\n const arts = l.nodes.filter((n) => n.type === \"artifact\"), actors = l.nodes.filter((n) => n.type === \"actor\");\n $(\"#gStat\").textContent = `${arts.length} artifacts · ${l.edges.filter((e) => e.type !== \"touched\").length} lineage edges${actors.length ? ` · ${actors.length} actors` : \"\"}`;\n if (!arts.length) { $(\"#gsvg\").innerHTML = `<div class=\"empty\">No artifacts in view.</div>`; return; }\n const layer = layerLineage(l);\n const W = 190, H = 46, GX = 70, GY = 16, PAD = 24, ACT_W = 140;\n const cw0 = $(\"#graph\").clientWidth || 1000;\n // connected artifacts: columns = layers, rows by first_seq. Isolated artifacts (no lineage edges): packed grid below.\n const linked = new Set(); for (const e of l.edges) if (e.type !== \"touched\") { linked.add(e.from); linked.add(e.to); }\n const conn = arts.filter((n) => linked.has(n.id)), iso = arts.filter((n) => !linked.has(n.id));\n const cols = new Map();\n for (const n of conn) { const c = layer.get(n.id) || 0; if (!cols.has(c)) cols.set(c, []); cols.get(c).push(n); }\n for (const arr of cols.values()) arr.sort((a, b) => a.first_seq - b.first_seq);\n const pos = new Map(); let maxRows = 0;\n const offX = actors.length ? ACT_W + GX : 0;\n for (const [c, arr] of cols) { maxRows = Math.max(maxRows, arr.length); arr.forEach((n, i) => pos.set(n.id, { x: PAD + offX + c * (W + GX), y: PAD + i * (H + GY), w: W, h: H })); }\n actors.sort((a, b) => a.first_seq - b.first_seq).forEach((n, i) => pos.set(\"u:\" + n.id, { x: PAD, y: PAD + i * (H + GY), w: ACT_W, h: H }));\n maxRows = Math.max(maxRows, actors.length);\n let width = PAD * 2 + offX + Math.max(cols.size, 0) * (W + GX) - (cols.size ? GX : 0);\n let height = PAD * 2 + maxRows * (H + GY) - GY;\n let isoLabelY = 0;\n if (iso.length) {\n const perRow = Math.max(1, Math.floor((cw0 - PAD * 2 + 30) / (W + 30)));\n const y0 = (conn.length || actors.length) ? height + 10 : PAD; isoLabelY = y0 + 12;\n iso.sort((a, b) => b.events - a.events).forEach((n, i) => pos.set(n.id, { x: PAD + (i % perRow) * (W + 30), y: y0 + 24 + Math.floor(i / perRow) * (H + GY), w: W, h: H }));\n const rows = Math.ceil(iso.length / perRow);\n height = y0 + 24 + rows * (H + GY) - GY + PAD; width = Math.max(width, PAD * 2 + Math.min(iso.length, perRow) * (W + 30) - 30);\n }\n const trunc = (t, n) => (t.length > n ? \"…\" + t.slice(-(n - 1)) : t);\n const cw = $(\"#graph\").clientWidth || width; const fit = width > cw && width < cw * 1.5; // gently scale to fit when slightly too wide, otherwise scroll\n let svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" ${fit ? `width=\"100%\"` : `width=\"${width}\"`} height=\"${fit ? Math.round(Math.max(height, 120) * cw / width) : Math.max(height, 120)}\" viewBox=\"0 0 ${width} ${Math.max(height, 120)}\" preserveAspectRatio=\"xMinYMin meet\"><defs><marker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"context-stroke\"/></marker></defs>`;\n const selArt = state.filterArtifact;\n const connected = new Set(); if (selArt) for (const e of l.edges) { if (e.from === selArt) connected.add(e.to); if (e.to === selArt) connected.add(e.from); }\n for (const e of l.edges) {\n const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) continue;\n const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2; const dx = Math.max(40, (x2 - x1) / 2);\n const back = x2 <= x1; const d = back ? `M${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` : `M${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n const dim = selArt && e.from !== selArt && e.to !== selArt ? \" dim\" : \"\";\n svg += `<path class=\"gedge ${e.type}${dim}\" d=\"${d}\" marker-end=\"url(#arr)\"><title>${esc(e.type)} ×${e.weight}\\n${esc(e.from)} → ${esc(e.to)}</title></path>`;\n if (e.type === \"flow\" && e.weight > 1) svg += `<text class=\"glabel\" x=\"${(x1 + x2) / 2}\" y=\"${(y1 + y2) / 2 - 4}\" text-anchor=\"middle\">×${e.weight}</text>`;\n }\n if (iso.length) svg += `<text class=\"glabel\" x=\"${PAD}\" y=\"${isoLabelY}\" style=\"font-size:11px\">${conn.length ? \"no lineage links yet\" : \"artifacts (no lineage links yet — add derived_from or caused_by to connect them)\"} · ${iso.length}</text>`;\n for (const n of arts) {\n const p = pos.get(n.id); const dim = selArt && n.id !== selArt && !connected.has(n.id) ? \" dim\" : \"\"; const sel = selArt === n.id ? \" sel\" : \"\";\n const sub = `${n.events} ev · ${n.actors.length} actor${n.actors.length === 1 ? \"\" : \"s\"}${n.kind ? \" · \" + n.kind : \"\"}`;\n svg += `<g class=\"gnode k-${esc(n.kind || \"x\")}${dim}${sel}\" data-art=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\"><rect width=\"${p.w}\" height=\"${p.h}\" rx=\"6\"/><text x=\"10\" y=\"18\">${esc(trunc(n.label, 26))}</text><text class=\"sub\" x=\"10\" y=\"35\">${esc(sub)}</text><title>${esc(n.id)}\\n${esc(Object.entries(n.actions).map(([k, v]) => k + \" ×\" + v).join(\", \"))}\\nby ${esc(n.actors.join(\", \"))}</title></g>`;\n }\n for (const n of actors) {\n const p = pos.get(\"u:\" + n.id); const shape = n.kind === \"agent\" ? `<polygon points=\"10,0 ${p.w - 10},0 ${p.w},${p.h / 2} ${p.w - 10},${p.h} 10,${p.h} 0,${p.h / 2}\"/>` : `<ellipse cx=\"${p.w / 2}\" cy=\"${p.h / 2}\" rx=\"${p.w / 2}\" ry=\"${p.h / 2}\"/>`;\n svg += `<g class=\"gnode actor ${esc(n.kind)}\" data-actor=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\">${shape}<text x=\"${p.w / 2}\" y=\"20\" text-anchor=\"middle\">${esc(trunc(n.label, 18))}</text><text class=\"sub\" x=\"${p.w / 2}\" y=\"35\" text-anchor=\"middle\">${esc(n.kind)} · ${n.events} ev</text><title>${esc(n.id)}</title></g>`;\n }\n svg += `</svg>`;\n $(\"#gsvg\").innerHTML = svg;\n }\n function setView(v) { const g = v === \"graph\"; $(\"#graph\").classList.toggle(\"on\", g); $(\"#timeline\").classList.toggle(\"off\", g); $(\"#vGraph\").classList.toggle(\"on\", g); $(\"#vTimeline\").classList.toggle(\"on\", !g); if (g) renderGraph(); }\n $(\"#vTimeline\").addEventListener(\"click\", () => setView(\"timeline\"));\n $(\"#vGraph\").addEventListener(\"click\", () => setView(\"graph\"));\n $(\"#gActors\").addEventListener(\"change\", renderGraph); $(\"#gFlow\").addEventListener(\"change\", renderGraph);\n function lineageDot(l) { const q = JSON.stringify; const out = [\"digraph retrace {\", \" rankdir=LR; node [fontname=Helvetica, fontsize=10];\"]; for (const n of l.nodes) out.push(` ${q((n.type === \"actor\" ? \"u:\" : \"\") + n.id)} [label=${q(n.label + (n.type === \"artifact\" ? \"\\\\n\" + n.events + \" ev\" : \"\"))}, shape=${n.type === \"actor\" ? \"ellipse\" : \"box\"}${n.type === \"actor\" ? \", style=dashed\" : \"\"}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === \"derived\" ? \" [penwidth=2]\" : e.type === \"touched\" ? \" [style=dashed,color=gray]\" : \"\"};`); out.push(\"}\"); return out.join(\"\\n\"); }\n function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, \"n\" + i++); return ids.get(s); }; const out = [\"graph LR\"]; for (const n of l.nodes) { const k = (n.type === \"actor\" ? \"u:\" : \"\") + n.id; const lbl = (n.label + (n.type === \"artifact\" ? ` (${n.events})` : \"\")).replace(/\"/g, \"'\"); out.push(n.type === \"actor\" ? ` ${nid(k)}([\"${lbl}\"])` : ` ${nid(k)}[\"${lbl}\"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === \"derived\" ? \"==>\" : e.type === \"flow\" ? \"-->\" : \"-.->\"} ${nid(e.to)}`); return out.join(\"\\n\"); }\n $(\"#gDot\").addEventListener(\"click\", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: \"text/vnd.graphviz\" }), `retrace-${state.project}-lineage.dot`));\n // \"copied\" is only shown when the copy actually happened; when the clipboard is unavailable (plain-http origins,\n // some embedded browsers) the text is put on screen instead of silently going nowhere.\n $(\"#gMmd\").addEventListener(\"click\", async () => {\n if (!lastLineage) return toast(\"Open the Graph view first\");\n const t = lineageMermaid(lastLineage); const ok = await copyText(t);\n if (ok) { $(\"#gMmd\").textContent = \"copied\"; setTimeout(() => ($(\"#gMmd\").textContent = \"Mermaid\"), 1200); }\n else { state.mermaidText = t; state.pane = \"mermaid\"; state.sel = null; renderDetail(); revealDetail(); }\n });\n async function copyText(t) {\n try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(t); return true; } } catch {}\n try { const ta = document.createElement(\"textarea\"); ta.value = t; ta.style.position = \"fixed\"; ta.style.opacity = \"0\"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand?.(\"copy\"); ta.remove(); return !!ok; } catch { return false; }\n }\n let toastTimer;\n function toast(msg) { const t = $(\"#toast\"); t.textContent = msg; t.classList.add(\"on\"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove(\"on\"), 3500); }\n if (params.get(\"view\") === \"graph\") setTimeout(() => setView(\"graph\"), 0);\n\n // events\n // One delegated handler. Every data-* attribute the markup uses MUST be in this selector, or the element renders\n // as clickable and does nothing (ui-detail.test.ts drives real clicks through it for that reason).\n document.addEventListener(\"click\", (ev) => {\n const t = ev.target.closest(\"[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],[data-ftype],[data-pane],.ev\");\n if (!t) return;\n const d = t.dataset;\n if (d.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }\n if (d.expand) { ev.stopPropagation(); state.expanded.has(d.expand) ? state.expanded.delete(d.expand) : state.expanded.add(d.expand); return render(); }\n // Filters toggle: clicking the artifact / actor / actor-type you are already filtering on clears that filter.\n if (d.art) { ev.stopPropagation(); state.filterArtifact = state.filterArtifact === d.art ? null : d.art; return render(); }\n if (d.actor) { ev.stopPropagation(); state.filterActor = state.filterActor === d.actor ? null : d.actor; return render(); }\n if (d.ftype) { ev.stopPropagation(); $(\"#ftype\").value = $(\"#ftype\").value === d.ftype ? \"\" : d.ftype; return render(); }\n if (d.q) { ev.stopPropagation(); $(\"#q\").value = d.q; return render(); }\n if (d.pane) { ev.stopPropagation(); state.pane = d.pane; state.sel = null; render(); return revealDetail(); }\n if (d.goto) { ev.preventDefault(); ev.stopPropagation(); return gotoEvent(d.goto); }\n if (d.clear) { ev.stopPropagation(); clearFilters(d.clear); return render(); }\n if (t.classList.contains(\"ev\")) { state.sel = t.dataset.id; state.pane = null; render(); revealDetail(); }\n });\n function clearFilters(which) {\n if (which === \"artifact\" || which === \"all\") state.filterArtifact = null;\n if (which === \"actor\" || which === \"all\") state.filterActor = null;\n if (which === \"q\" || which === \"all\") $(\"#q\").value = \"\";\n if (which === \"all\") { $(\"#ftype\").value = \"\"; $(\"#faction\").value = \"\"; }\n }\n // Jump to an event. It must end up on screen: a filter that would hide it is lifted (and the toast says so), and\n // the graph view yields to the timeline — a jump that changes nothing visible reads as a dead link.\n function gotoEvent(id) {\n if (!state.events.some((e) => e.id === id)) return toast(`${id} is not among the loaded events`);\n state.sel = id; state.pane = null; state.filterArtifact = null; state.filterActor = null;\n if (!filtered().some((e) => e.id === id)) { clearFilters(\"all\"); toast(\"Cleared the search / actor / action filters to show the linked event\"); }\n if ($(\"#graph\").classList.contains(\"on\")) setView(\"timeline\");\n render();\n document.querySelector(`.ev[data-id=\"${CSS.escape(id)}\"]`)?.scrollIntoView?.({ block: \"center\", behavior: \"smooth\" });\n revealDetail();\n }\n // Below the 900px breakpoint the detail pane sits under the timeline, where an update is off-screen and a click looks inert.\n function revealDetail() { if (window.matchMedia?.(\"(max-width: 900px)\")?.matches) $(\"#detail\").scrollIntoView?.({ block: \"start\", behavior: \"smooth\" }); }\n $(\"#project\").addEventListener(\"change\", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });\n [\"#q\", \"#ftype\", \"#faction\"].forEach((s) => $(s).addEventListener(\"input\", render));\n $(\"#refresh\").addEventListener(\"click\", () => boot());\n $(\"#report\").addEventListener(\"click\", () => { if (!state.project) return toast(\"No project loaded\"); window.open(P.reportUrl(state.project, state.filterArtifact), \"_blank\"); });\n $(\"#export\").addEventListener(\"click\", async () => {\n if (state.offline) { const blob = new Blob([JSON.stringify({ format: \"retrace-export/1\", scope: { project: state.project }, events: state.events }, null, 2)], { type: \"application/json\" }); return dl(blob, `${state.project}-events.json`); }\n if (!state.project) return toast(\"No project loaded\");\n try {\n const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);\n dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? \"-\" + state.filterArtifact.replace(/[^\\w.-]+/g, \"_\") : \"\"}.json`);\n } catch (e) { toast(`Export failed: ${e.message}`); }\n });\n function dl(blob, name) { const a = document.createElement(\"a\"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }\n $(\"#share\").addEventListener(\"click\", () => { $(\"#shScope\").value = state.filterArtifact ? \"artifact\" : \"project\"; $(\"#shScope\").querySelector('[value=artifact]').disabled = !state.filterArtifact; $(\"#shResult\").textContent = \"\"; $(\"#shareDlg\").style.display = \"flex\"; });\n $(\"#shCancel\").addEventListener(\"click\", () => ($(\"#shareDlg\").style.display = \"none\"));\n $(\"#shCreate\").addEventListener(\"click\", async () => {\n const body = { artifact_id: $(\"#shScope\").value === \"artifact\" ? state.filterArtifact : undefined, label: $(\"#shLabel\").value.trim() || undefined, expires_in_days: Number($(\"#shExp\").value) || undefined };\n try {\n const res = await fetch(`${apiBase}/projects/${encodeURIComponent(state.project)}/share`, { method: \"POST\", headers: { \"content-type\": \"application/json\", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });\n const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);\n const u = r.url.startsWith(\"http\") ? r.url : location.origin + r.url;\n $(\"#shResult\").innerHTML = `Link: <a href=\"${esc(u)}\" target=\"_blank\">${esc(u)}</a> <button id=\"shCopy\">copy</button><br>Report: <a href=\"${esc(u)}/report\" target=\"_blank\">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : \"\"}`;\n $(\"#shCopy\").addEventListener(\"click\", async () => { $(\"#shCopy\").textContent = (await copyText(u)) ? \"copied\" : \"clipboard unavailable — select the link\"; });\n } catch (e) { $(\"#shResult\").textContent = \"Failed: \" + e.message; }\n });\n $(\"#toggleConn\").addEventListener(\"click\", () => $(\"#conn\").classList.toggle(\"open\"));\n $(\"#connect\").addEventListener(\"click\", () => { apiBase = $(\"#apiUrl\").value.trim().replace(/\\/$/, \"\"); token = $(\"#apiTok\").value.trim(); state.offline = false; boot(); });\n $(\"#loadJson\").addEventListener(\"click\", () => $(\"#file\").click());\n $(\"#file\").addEventListener(\"change\", async (e) => {\n const f = e.target.files[0]; if (!f) return;\n const data = JSON.parse(await f.text());\n const events = Array.isArray(data) ? data : data.events ?? [];\n state.offline = true; state.events = events.sort((a, b) => a.seq - b.seq); state.verify = null; state.status = null;\n state.projects = [...new Set(events.map((x) => x.project))]; state.project = state.projects[0] ?? null;\n $(\"#project\").innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\");\n render();\n });\n function showErr(err) { $(\"#timeline\").innerHTML = `<div class=\"empty\">Could not load: ${esc(err.message)}<br><br>Open ⚙ to set the API URL / token, or load a JSON export.</div>`; $(\"#conn\").classList.add(\"open\"); }\n async function boot() { try { await loadProjects(); await loadEvents(); } catch (err) { showErr(err); } }\n boot();\n setInterval(() => { if (!state.offline && document.visibilityState === \"visible\") loadEvents().catch(() => {}); }, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retrace-dev/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Schemas, hash chains, causal status, exports, and renderers for Retrace provenance",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"bugs": { "url": "https://github.com/jordandru/retrace/issues" },
|
|
13
13
|
"publishConfig": { "access": "public" },
|
|
14
14
|
"exports": {
|
|
15
|
-
".":
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
16
19
|
},
|
|
17
20
|
"scripts": {
|
|
18
21
|
"build": "node scripts/embed-ui.mjs && tsc -p tsconfig.json",
|
|
@@ -22,5 +25,5 @@
|
|
|
22
25
|
"dependencies": {
|
|
23
26
|
"zod": "^3.23.8"
|
|
24
27
|
},
|
|
25
|
-
"files": ["dist", "!dist/*.test.js", "!dist/*.test.d.ts", "ui", "README.md"]
|
|
28
|
+
"files": ["dist", "!dist/*.test.js", "!dist/*.test.d.ts", "ui", "README.md", "LICENSE"]
|
|
26
29
|
}
|
package/ui/retrace.html
CHANGED
|
@@ -53,6 +53,17 @@
|
|
|
53
53
|
.chip:hover { border-color: var(--accent); }
|
|
54
54
|
.chip.more { color: var(--accent); border-style: dashed; }
|
|
55
55
|
.chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }
|
|
56
|
+
/* Everything that LOOKS clickable must do something: these are the affordances the click handler honours. */
|
|
57
|
+
.badge.click { cursor: pointer; } .badge.click:hover { border-color: var(--accent); }
|
|
58
|
+
.who[data-actor] { cursor: pointer; } .who[data-actor]:hover { text-decoration: underline; }
|
|
59
|
+
[data-ftype] { cursor: pointer; } [data-ftype]:hover { color: var(--text); } .legend [data-ftype].on { color: var(--text); font-weight: 600; }
|
|
60
|
+
.stable { border-collapse: collapse; font-size: 12px; width: 100%; margin-bottom: 14px; }
|
|
61
|
+
.stable td, .stable th { padding: 3px 8px 3px 0; text-align: left; vertical-align: top; }
|
|
62
|
+
.stable th { color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
|
|
63
|
+
.num.bad { color: var(--bad); font-weight: 600; } .num.ok { color: var(--ok); }
|
|
64
|
+
#toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; font-size: 13px; z-index: 30; box-shadow: 0 4px 16px rgba(0,0,0,.3); display: none; max-width: 90vw; }
|
|
65
|
+
#toast.on { display: block; }
|
|
66
|
+
button:disabled { opacity: .45; cursor: not-allowed; }
|
|
56
67
|
#detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }
|
|
57
68
|
.kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }
|
|
58
69
|
.kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }
|
|
@@ -101,8 +112,8 @@
|
|
|
101
112
|
<select id="ftype"><option value="">all actors</option><option value="human">humans</option><option value="agent">agents</option><option value="system">system</option></select>
|
|
102
113
|
<select id="faction"><option value="">any action</option></select>
|
|
103
114
|
<span class="seg"><button id="vTimeline" class="on">Timeline</button><button id="vGraph">Graph</button></span>
|
|
104
|
-
<span id="verify" class="badge"><span class="dot"></span><span>verifying…</span></span>
|
|
105
|
-
<span id="status" class="badge" title="Capture completeness and causal coverage">capture…</span>
|
|
115
|
+
<span id="verify" class="badge click" data-pane="status" title="Chain integrity — click for details"><span class="dot"></span><span>verifying…</span></span>
|
|
116
|
+
<span id="status" class="badge click" data-pane="status" title="Capture completeness and causal coverage — click for details">capture…</span>
|
|
106
117
|
<span id="sharedBadge" class="badge" style="display:none" title="Read-only shared view">🔗 shared view</span>
|
|
107
118
|
<button id="report" title="Printable report (save as PDF)">Report</button>
|
|
108
119
|
<button id="export" title="Download signed JSON bundle">Export</button>
|
|
@@ -124,14 +135,15 @@
|
|
|
124
135
|
</div>
|
|
125
136
|
</div>
|
|
126
137
|
</div>
|
|
138
|
+
<div id="toast" role="status"></div>
|
|
127
139
|
<main>
|
|
128
140
|
<section id="timeline"><div class="empty">Loading…</div></section>
|
|
129
141
|
<section id="graph">
|
|
130
142
|
<div class="gctl">
|
|
131
|
-
<span><b id="gStat"></b></span>
|
|
143
|
+
<span><b id="gStat"></b></span><span id="gFilters" class="stat" style="margin:0"></span>
|
|
132
144
|
<label><input type="checkbox" id="gActors"> show people & agents</label>
|
|
133
145
|
<label><input type="checkbox" id="gFlow" checked> causal flow edges</label>
|
|
134
|
-
<span style="margin-left:auto">━ derived_from → causal flow ┈ touched · click a node to filter the timeline</span>
|
|
146
|
+
<span style="margin-left:auto">━ derived_from → causal flow ┈ touched · click a node to highlight it and filter the timeline · click it again to clear</span>
|
|
135
147
|
<button id="gDot" title="Download Graphviz DOT">DOT</button><button id="gMmd" title="Copy Mermaid">Mermaid</button>
|
|
136
148
|
</div>
|
|
137
149
|
<div id="gsvg"></div>
|
|
@@ -141,7 +153,8 @@
|
|
|
141
153
|
<script>
|
|
142
154
|
(() => {
|
|
143
155
|
const $ = (s) => document.querySelector(s);
|
|
144
|
-
|
|
156
|
+
// pane: what the right-hand pane shows when no event is selected — "status" (chain + capture detail) or "mermaid" (copy fallback).
|
|
157
|
+
const state = { events: [], project: null, projects: [], sel: null, pane: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true, fp: null };
|
|
145
158
|
const params = new URLSearchParams(location.search);
|
|
146
159
|
if (params.get("order") === "asc") state.newestFirst = false;
|
|
147
160
|
let apiBase = params.get("api") ?? "";
|
|
@@ -204,8 +217,10 @@
|
|
|
204
217
|
state.projects = await P.projects();
|
|
205
218
|
const sel = $("#project");
|
|
206
219
|
sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join("") || "<option value=''>(no projects yet)</option>";
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
// Keep the project the user switched to: ↻ and Connect re-run this, and silently jumping back to the first
|
|
221
|
+
// project made refresh look like it had discarded their selection.
|
|
222
|
+
const want = params.get("project"), keep = state.project;
|
|
223
|
+
state.project = state.projects.includes(keep) ? keep : state.projects.includes(want) ? want : state.projects[0] ?? null;
|
|
209
224
|
if (state.project) sel.value = state.project;
|
|
210
225
|
}
|
|
211
226
|
|
|
@@ -213,26 +228,31 @@
|
|
|
213
228
|
if (state.offline) return render();
|
|
214
229
|
if (!state.project) { state.events = []; return render(); }
|
|
215
230
|
const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);
|
|
216
|
-
|
|
231
|
+
// The 15 s poll must not rebuild the panes when nothing changed: that closed an open "raw JSON", reset the
|
|
232
|
+
// timeline scroll and blanked a half-read detail. Same length + same head hash + same verify result = same view.
|
|
233
|
+
const fp = `${state.project}:${events.length}:${events.at(-1)?.hash ?? ""}:${verify?.ok}:${verify?.checked}`;
|
|
234
|
+
const same = fp === state.fp;
|
|
235
|
+
state.events = events; state.verify = verify; state.status = status; state.fp = fp;
|
|
236
|
+
if (same) { localVerifyBadge(); statusBadge(); if (state.pane === "status") renderDetail(); return; }
|
|
217
237
|
render();
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
function localVerifyBadge() {
|
|
221
241
|
const b = $("#verify"); const v = state.verify;
|
|
222
|
-
if (!v) { b.className = "badge"; b.lastElementChild.textContent = state.offline ? "offline snapshot" : "not verified"; return; }
|
|
223
|
-
b.className = "badge " + (v.ok ? "ok" : "bad");
|
|
242
|
+
if (!v) { b.className = "badge click"; b.lastElementChild.textContent = state.offline ? "offline snapshot" : "not verified"; b.title = "Chain not verified — click for details"; return; }
|
|
243
|
+
b.className = "badge click " + (v.ok ? "ok" : "bad");
|
|
224
244
|
b.lastElementChild.textContent = v.ok ? `chain intact · ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;
|
|
225
|
-
b.title = v.reason || "";
|
|
245
|
+
b.title = (v.ok ? "Every event re-hashed and every prev_hash link checked" : v.reason || "Chain broken") + " — click for details";
|
|
226
246
|
}
|
|
227
247
|
|
|
228
248
|
function statusBadge() {
|
|
229
249
|
const b = $("#status"), s = state.status;
|
|
230
|
-
if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? "none" : ""; b.textContent = "capture unknown"; return; }
|
|
250
|
+
if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? "none" : ""; b.className = "badge click"; b.textContent = "capture unknown"; b.title = "No /status answer — click for details"; return; }
|
|
231
251
|
b.style.display = "";
|
|
232
252
|
const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;
|
|
233
|
-
b.className = "badge " + (gaps ? "bad" : "ok");
|
|
253
|
+
b.className = "badge click " + (gaps ? "bad" : "ok");
|
|
234
254
|
b.textContent = `${s.causality.coverage_pct}% causal · ${s.capture.unlinked_commits} unlinked commits`;
|
|
235
|
-
b.title = `${s.capture.instructions_without_followup} instructions without follow-up · ${s.capture.agent_events_without_model} agent events missing model · ${s.capture.artifact_refs_without_role} artifact refs missing role`;
|
|
255
|
+
b.title = `${s.capture.instructions_without_followup} instructions without follow-up · ${s.capture.agent_events_without_model} agent events missing model · ${s.capture.artifact_refs_without_role} artifact refs missing role — click for details`;
|
|
236
256
|
}
|
|
237
257
|
|
|
238
258
|
function filtered(opts = {}) {
|
|
@@ -248,10 +268,21 @@
|
|
|
248
268
|
});
|
|
249
269
|
}
|
|
250
270
|
|
|
271
|
+
// The active-filter chips (artifact · actor · search), each clearing itself on click. Shared by the timeline's
|
|
272
|
+
// stat bar and the graph's control bar — a filter set from a graph node used to be invisible, and unclearable,
|
|
273
|
+
// until you switched back to the timeline.
|
|
274
|
+
const filterChipsHtml = (labels) => {
|
|
275
|
+
const q = $("#q").value.trim();
|
|
276
|
+
return (state.filterArtifact ? `<span>artifact: <span class="chip" data-clear="artifact" title="${esc(state.filterArtifact)} — click to clear">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} ✕</span></span>` : "") +
|
|
277
|
+
(state.filterActor ? `<span>actor: <span class="chip" data-clear="actor" title="click to clear">${esc(state.filterActor)} ✕</span></span>` : "") +
|
|
278
|
+
(q ? `<span>search: <span class="chip" data-clear="q" title="click to clear">${esc(q)} ✕</span></span>` : "");
|
|
279
|
+
};
|
|
280
|
+
|
|
251
281
|
function render() {
|
|
252
282
|
localVerifyBadge();
|
|
253
283
|
statusBadge();
|
|
254
284
|
const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);
|
|
285
|
+
const inView = new Set(state.events.map((e) => e.id));
|
|
255
286
|
const acts = [...new Set(state.events.map((e) => e.action))].sort();
|
|
256
287
|
const fa = $("#faction"); const cur = fa.value;
|
|
257
288
|
fa.innerHTML = `<option value="">any action</option>` + acts.map((a) => `<option ${a === cur ? "selected" : ""}>${esc(a)}</option>`).join("");
|
|
@@ -259,11 +290,12 @@
|
|
|
259
290
|
const humans = new Set(state.events.filter((e) => e.actor.type === "human").map((e) => e.actor.id)).size;
|
|
260
291
|
const agents = new Set(state.events.filter((e) => e.actor.type === "agent").map((e) => e.actor.id)).size;
|
|
261
292
|
const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
293
|
+
const ft = $("#ftype").value;
|
|
294
|
+
let html = `<div class="stat"><span><b>${state.events.length}</b> events</span><span data-ftype="human" title="show only humans"><b>${humans}</b> humans</span><span data-ftype="agent" title="show only agents"><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>${filterChipsHtml(labels)}</div>`;
|
|
295
|
+
// Legend entries filter by actor type (and show which one is active); the same select in the header stays in sync.
|
|
296
|
+
const leg = (t, extra) => `<span data-ftype="${t}" class="${ft === t ? "on" : ""}" title="${ft === t ? `showing only ${t} — click to show all` : `show only ${t}`}"><i class="dot ${t}" style="display:inline-block${extra}"></i> ${t}</span>`;
|
|
297
|
+
html += `<div class="legend">${leg("human", "")}${leg("agent", ";border-radius:2px")}${leg("system", "")}<span style="margin-left:auto"><span class="chip" data-order="1" title="flip order">${state.newestFirst ? "newest first ▾" : "oldest first ▴"}</span></span></div>`;
|
|
298
|
+
if (!list.length) html += `<div class="empty">${state.events.length ? `No events match these filters. <span class="chip" data-clear="all" title="clear search, actor, action and artifact filters">clear all filters</span>` : "No events yet — log one with the MCP tool <code>retrace_log</code> or POST /events."}</div>`;
|
|
267
299
|
let lastDay = "";
|
|
268
300
|
for (const e of list) {
|
|
269
301
|
const day = fmtDay(e.timestamp);
|
|
@@ -279,11 +311,14 @@
|
|
|
279
311
|
<div>
|
|
280
312
|
<div><span class="who" data-actor="${esc(e.actor.id)}">${esc(actorName(e.actor))}</span><span class="link">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? " · for " + esc(e.actor.on_behalf_of) : ""}</span> <span class="verb">${esc(verbOf(e))}</span> <span class="art">${arts}</span> <span class="link">${where ? "@ " + esc(where) + " " : ""}${how}</span></div>
|
|
281
313
|
${e.intent ? `<div class="why">${esc(e.intent)}</div>` : ""}
|
|
282
|
-
${e.caused_by ? `<div class="link">↳ caused by
|
|
314
|
+
${e.caused_by ? `<div class="link">↳ caused by ${inView.has(e.caused_by) ? `<a href="#" data-goto="${esc(e.caused_by)}" title="jump to this event">${esc(e.caused_by.slice(0, 16))}…</a>` : `<span class="mono" title="${esc(e.caused_by)} — not among the loaded events (outside this view's scope)">${esc(e.caused_by.slice(0, 16))}… <i>(not in view)</i></span>`}</div>` : ""}
|
|
283
315
|
</div></div>`;
|
|
284
316
|
}
|
|
285
317
|
$("#timeline").innerHTML = html;
|
|
286
318
|
if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;
|
|
319
|
+
// Report and Share are server features; on a loaded JSON snapshot they can only fail, so say so instead of opening a dead tab.
|
|
320
|
+
$("#report").disabled = state.offline; $("#report").title = state.offline ? "Report needs a live ledger — not available for an offline snapshot" : "Printable report (save as PDF)";
|
|
321
|
+
$("#share").disabled = state.offline; $("#share").title = state.offline ? "Share links need a live ledger — not available for an offline snapshot" : "Create a read-only share link";
|
|
287
322
|
renderDetail();
|
|
288
323
|
renderGraph();
|
|
289
324
|
}
|
|
@@ -297,9 +332,12 @@
|
|
|
297
332
|
function effects(id) { return state.events.filter((e) => e.caused_by === id); }
|
|
298
333
|
|
|
299
334
|
function renderDetail() {
|
|
335
|
+
if (state.pane === "status") return renderStatus();
|
|
336
|
+
if (state.pane === "mermaid") { $("#detail").innerHTML = `<h2>Mermaid</h2><div class="link" style="margin-bottom:6px">The clipboard is not available here — copy the text below.</div><pre style="max-height:none">${esc(state.mermaidText || "")}</pre>`; return; }
|
|
300
337
|
const e = state.events.find((x) => x.id === state.sel);
|
|
301
338
|
const d = $("#detail");
|
|
302
339
|
if (!e) { d.innerHTML = `<div class="empty">Select an event to see who / what / when / where / why / how.</div>`; return; }
|
|
340
|
+
const labels = latestLabels(state.events);
|
|
303
341
|
const kv = (k, v, mono) => v ? `<div class="k">${k}</div><div class="v ${mono ? "mono" : ""}">${v}</div>` : "";
|
|
304
342
|
// Every section always renders: absent data says "(not recorded)" instead of vanishing,
|
|
305
343
|
// and a section that fails to render falls back on its own — it never blanks the rest of the pane.
|
|
@@ -308,20 +346,21 @@
|
|
|
308
346
|
try {
|
|
309
347
|
const chain = whyChain(e.id).slice(1); kids = effects(e.id);
|
|
310
348
|
const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);
|
|
311
|
-
|
|
312
|
-
|
|
349
|
+
const who = (c) => `<span class="who" data-actor="${esc(c.actor.id)}" title="${esc(c.actor.id)} — filter the timeline by this actor">${esc(actorName(c.actor))}</span>`;
|
|
350
|
+
chainHtml = parentMissing ? `<div class="link" style="margin-bottom:12px">caused by <span class="mono">${esc(e.caused_by)}</span> — outside this view's scope</div>` : chain.length ? `<div class="why-chain">` + chain.map((c) => `<div class="step ${c.actor.type}"><span class="arrow">↳ because</span> ${who(c)} ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(", "))}${c.intent ? ` — <i>${esc(c.intent)}</i>` : ""} <a href="#" data-goto="${esc(c.id)}" class="link" title="jump to event #${c.seq}">#${c.seq}</a></div>`).join("") + `</div>` : `<div class="link" style="margin-bottom:12px">no causal parent recorded — this is a root event</div>`;
|
|
351
|
+
kidsHtml = kids.length ? `<div class="why-chain">` + kids.map((c) => `<div class="step ${c.actor.type}"><span class="arrow">→ led to</span> ${who(c)} ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(", "))} <a href="#" data-goto="${esc(c.id)}" class="link" title="jump to event #${c.seq}">#${c.seq}</a></div>`).join("") + `</div>` : "";
|
|
313
352
|
} catch { chainHtml = `<div class="link" style="margin-bottom:12px">(could not render the causal chain — see raw JSON)</div>`; }
|
|
314
353
|
let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = "(not serializable)"; }
|
|
315
354
|
d.innerHTML = `
|
|
316
355
|
<h2>Event #${e.seq}</h2>
|
|
317
356
|
<div class="kv">
|
|
318
|
-
${sect("Who", () => `<span style="color:var(--${e.actor.type});font-weight:600">${esc(actorName(e.actor))}</span> <span class="link">${esc(e.actor.type)}${e.actor.model ? " · " + esc(e.actor.model) : ""}${e.actor.version ? " " + esc(e.actor.version) : ""}${e.actor.on_behalf_of ? "<br>on behalf of " + esc(e.actor.on_behalf_of) : ""}</span>`)}
|
|
319
|
-
${sect("What", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class="chip" data-art="${esc(a.id)}" title="${esc(a.id + roleTitle(a.role))}">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== "file" ? `<span class="link">${esc(a.kind)}</span>` : ""}${a.derived_from?.length ? `<div class="link">derived from ${
|
|
357
|
+
${sect("Who", () => `<span class="who" data-actor="${esc(e.actor.id)}" style="color:var(--${esc(e.actor.type)});font-weight:600" title="${esc(e.actor.id)} — filter the timeline by this actor">${esc(actorName(e.actor))}</span> <span class="link">${esc(e.actor.type)}${e.actor.model ? " · " + esc(e.actor.model) : ""}${e.actor.version ? " " + esc(e.actor.version) : ""}${e.actor.on_behalf_of ? "<br>on behalf of " + esc(e.actor.on_behalf_of) : ""}</span>`)}
|
|
358
|
+
${sect("What", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class="chip" data-art="${esc(a.id)}" title="${esc(a.id + roleTitle(a.role))}">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== "file" ? `<span class="link">${esc(a.kind)}</span>` : ""}${a.derived_from?.length ? `<div class="link">derived from ${a.derived_from.map((s) => `<span class="chip" data-art="${esc(s)}" title="${esc(s)} — filter by this artifact">${esc(labels.get(s) || s)}</span>`).join("")}</div>` : ""}`).join(" ")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : ""}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : ""}${e.change?.after_hash ? `<div class="hash">after ${esc(e.change.after_hash)}</div>` : ""}`)}
|
|
320
359
|
${sect("When", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class="link">· took ${fmtDur(e.duration_ms)}</span>` : ""}<div class="link">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}
|
|
321
360
|
${sect("Where", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : "")}
|
|
322
361
|
${sect("Why", () => e.intent ? esc(e.intent) : "")}
|
|
323
362
|
${sect("How", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : ""}${e.method.automated != null ? `<span class="link">${e.method.automated ? "automated" : "manual"}</span>` : ""}${e.method.instruction ? `<div class="link">instruction: ${esc(e.method.instruction)}</div>` : ""}${e.method.tokens != null ? `<div class="link">${e.method.tokens} tokens${e.method.cost_usd != null ? ` · $${e.method.cost_usd}` : ""}</div>` : ""}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : ""}` : "")}
|
|
324
|
-
${sect("Tags", () => e.tags?.length ? e.tags.map((t) => `<span class="chip">${esc(t)}</span>`).join("") : "")}
|
|
363
|
+
${sect("Tags", () => e.tags?.length ? e.tags.map((t) => `<span class="chip" data-q="${esc(t)}" title="search for ${esc(t)}">${esc(t)}</span>`).join("") : "")}
|
|
325
364
|
</div>
|
|
326
365
|
<h2>Causal chain</h2>${chainHtml}
|
|
327
366
|
${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : ""}
|
|
@@ -330,6 +369,40 @@
|
|
|
330
369
|
<details><summary class="link">raw JSON</summary><pre>${esc(raw)}</pre></details>`;
|
|
331
370
|
}
|
|
332
371
|
|
|
372
|
+
// The status pane: what the two header badges summarise, in full. Every number is the ledger's own /status and
|
|
373
|
+
// /verify answer (packages/core/src/status.ts); the "what closes it" column states the capture rule, not a guess.
|
|
374
|
+
function renderStatus() {
|
|
375
|
+
const d = $("#detail"), s = state.status, v = state.verify;
|
|
376
|
+
const num = (x, bad = (n) => n > 0) => `<span class="num ${bad(x) ? "bad" : "ok"}">${esc(x)}</span>`;
|
|
377
|
+
const when = (iso) => iso ? esc(new Date(iso).toLocaleString()) : "—";
|
|
378
|
+
let chain;
|
|
379
|
+
if (v) chain = v.ok ? `<span class="num ok">intact</span> · ${esc(v.checked)} events re-hashed, every prev_hash link checked` : `<span class="num bad">BROKEN at #${esc(v.first_bad_seq)}</span>${v.reason ? `<div class="link">${esc(v.reason)}</div>` : ""}`;
|
|
380
|
+
else chain = `<span class="link">${state.offline ? "not verified — an offline snapshot cannot be checked against the ledger" : "not verified — /verify did not answer"}</span>`;
|
|
381
|
+
let html = `<h2>Project status${state.project ? ` · ${esc(state.project)}` : ""}</h2><div class="kv"><div class="k">Chain</div><div class="v">${chain}</div></div>`;
|
|
382
|
+
if (!s?.capture || !s?.causality) {
|
|
383
|
+
html += `<div class="link">Capture and causal coverage come from the ledger's <span class="mono">/status</span> endpoint, which ${shareId ? "is not exposed on share links" : state.offline ? "an offline snapshot cannot reach" : "did not answer"}.</div>`;
|
|
384
|
+
d.innerHTML = html; return;
|
|
385
|
+
}
|
|
386
|
+
const c = s.capture, z = s.causality;
|
|
387
|
+
html += `<h2>Causal coverage</h2><div class="kv">
|
|
388
|
+
<div class="k">Rooted</div><div class="v">${num(z.coverage_pct, (n) => n < 100)}% — ${esc(z.rooted_in_human_instruction)} of ${esc(z.eligible_events)} eligible events trace back, caused_by by caused_by, to a human instruction<div class="link">eligible = every agent event plus every commit and merge</div></div>
|
|
389
|
+
<div class="k">Unlinked</div><div class="v">${num(z.unlinked)} <span class="link">chains that stop at an event with no caused_by</span></div>
|
|
390
|
+
<div class="k">Broken</div><div class="v">${num(z.broken_links)} <span class="link">chains whose caused_by names an event that is not in the ledger</span></div>
|
|
391
|
+
</div>`;
|
|
392
|
+
html += `<h2>Capture gaps</h2><table class="stable"><tr><th>gap</th><th>count</th><th>what closes it</th></tr>
|
|
393
|
+
<tr><td>commits without a causal root</td><td>${num(c.unlinked_commits)} / ${esc(c.commits)}</td><td>a <span class="mono">Retrace-Caused-By</span> trailer naming the instruction</td></tr>
|
|
394
|
+
<tr><td>agent events without a model</td><td>${num(c.agent_events_without_model)} / ${esc(c.agent_events)}</td><td><span class="mono">actor.model</span> on every agent event</td></tr>
|
|
395
|
+
<tr><td>instructions without follow-up</td><td>${num(c.instructions_without_followup)} / ${esc(c.instructions)}</td><td>at least one event whose caused_by is the instruction</td></tr>
|
|
396
|
+
<tr><td>artifact refs without a role</td><td>${num(c.artifact_refs_without_role)} / ${esc(c.artifact_refs)}</td><td><span class="mono">role</span>: used · generated · both on each ref</td></tr>
|
|
397
|
+
</table>`;
|
|
398
|
+
html += `<h2>Actors</h2><table class="stable"><tr><th>who</th><th>events</th><th>models</th><th>last seen</th></tr>` +
|
|
399
|
+
s.actors.map((a) => `<tr><td><span class="who" data-actor="${esc(a.id)}" style="color:var(--${esc(a.type)})" title="${esc(a.id)} — filter the timeline by this actor">${esc(a.id)}</span> <span class="link">${esc(a.type)}</span></td><td>${esc(a.events)}</td><td class="mono">${a.models?.length ? esc(a.models.join(", ")) : a.type === "agent" ? `<span class="num bad">none recorded</span>` : "—"}</td><td>${when(a.last_seen)}</td></tr>`).join("") + `</table>`;
|
|
400
|
+
html += `<h2>Integrations</h2><table class="stable"><tr><th>location.system</th><th>events</th><th>last seen</th></tr>` +
|
|
401
|
+
s.integrations.map((i) => `<tr><td><span class="chip" data-q="${esc(i.system)}" title="search for ${esc(i.system)}">${esc(i.system)}</span></td><td>${esc(i.events)}</td><td>${when(i.last_seen)}</td></tr>`).join("") + `</table>`;
|
|
402
|
+
html += `<div class="link">${esc(s.events?.total ?? state.events.length)} events · last event ${when(s.events?.last_event_at)} · computed ${when(s.generated_at)}</div>`;
|
|
403
|
+
d.innerHTML = html;
|
|
404
|
+
}
|
|
405
|
+
|
|
333
406
|
// ---------- lineage graph ----------
|
|
334
407
|
function buildLineage(events, opts = {}) {
|
|
335
408
|
const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));
|
|
@@ -359,6 +432,7 @@
|
|
|
359
432
|
const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding
|
|
360
433
|
const l = buildLineage(events, { includeActors: $("#gActors").checked, flow: $("#gFlow").checked });
|
|
361
434
|
lastLineage = l;
|
|
435
|
+
$("#gFilters").innerHTML = filterChipsHtml(latestLabels(state.events));
|
|
362
436
|
const arts = l.nodes.filter((n) => n.type === "artifact"), actors = l.nodes.filter((n) => n.type === "actor");
|
|
363
437
|
$("#gStat").textContent = `${arts.length} artifacts · ${l.edges.filter((e) => e.type !== "touched").length} lineage edges${actors.length ? ` · ${actors.length} actors` : ""}`;
|
|
364
438
|
if (!arts.length) { $("#gsvg").innerHTML = `<div class="empty">No artifacts in view.</div>`; return; }
|
|
@@ -419,30 +493,72 @@
|
|
|
419
493
|
function lineageDot(l) { const q = JSON.stringify; const out = ["digraph retrace {", " rankdir=LR; node [fontname=Helvetica, fontsize=10];"]; for (const n of l.nodes) out.push(` ${q((n.type === "actor" ? "u:" : "") + n.id)} [label=${q(n.label + (n.type === "artifact" ? "\\n" + n.events + " ev" : ""))}, shape=${n.type === "actor" ? "ellipse" : "box"}${n.type === "actor" ? ", style=dashed" : ""}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === "derived" ? " [penwidth=2]" : e.type === "touched" ? " [style=dashed,color=gray]" : ""};`); out.push("}"); return out.join("\n"); }
|
|
420
494
|
function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, "n" + i++); return ids.get(s); }; const out = ["graph LR"]; for (const n of l.nodes) { const k = (n.type === "actor" ? "u:" : "") + n.id; const lbl = (n.label + (n.type === "artifact" ? ` (${n.events})` : "")).replace(/"/g, "'"); out.push(n.type === "actor" ? ` ${nid(k)}(["${lbl}"])` : ` ${nid(k)}["${lbl}"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === "derived" ? "==>" : e.type === "flow" ? "-->" : "-.->"} ${nid(e.to)}`); return out.join("\n"); }
|
|
421
495
|
$("#gDot").addEventListener("click", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: "text/vnd.graphviz" }), `retrace-${state.project}-lineage.dot`));
|
|
422
|
-
|
|
496
|
+
// "copied" is only shown when the copy actually happened; when the clipboard is unavailable (plain-http origins,
|
|
497
|
+
// some embedded browsers) the text is put on screen instead of silently going nowhere.
|
|
498
|
+
$("#gMmd").addEventListener("click", async () => {
|
|
499
|
+
if (!lastLineage) return toast("Open the Graph view first");
|
|
500
|
+
const t = lineageMermaid(lastLineage); const ok = await copyText(t);
|
|
501
|
+
if (ok) { $("#gMmd").textContent = "copied"; setTimeout(() => ($("#gMmd").textContent = "Mermaid"), 1200); }
|
|
502
|
+
else { state.mermaidText = t; state.pane = "mermaid"; state.sel = null; renderDetail(); revealDetail(); }
|
|
503
|
+
});
|
|
504
|
+
async function copyText(t) {
|
|
505
|
+
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(t); return true; } } catch {}
|
|
506
|
+
try { const ta = document.createElement("textarea"); ta.value = t; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand?.("copy"); ta.remove(); return !!ok; } catch { return false; }
|
|
507
|
+
}
|
|
508
|
+
let toastTimer;
|
|
509
|
+
function toast(msg) { const t = $("#toast"); t.textContent = msg; t.classList.add("on"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("on"), 3500); }
|
|
423
510
|
if (params.get("view") === "graph") setTimeout(() => setView("graph"), 0);
|
|
424
511
|
|
|
425
512
|
// events
|
|
513
|
+
// One delegated handler. Every data-* attribute the markup uses MUST be in this selector, or the element renders
|
|
514
|
+
// as clickable and does nothing (ui-detail.test.ts drives real clicks through it for that reason).
|
|
426
515
|
document.addEventListener("click", (ev) => {
|
|
427
|
-
const t = ev.target.closest("[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],.ev");
|
|
516
|
+
const t = ev.target.closest("[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],[data-ftype],[data-pane],.ev");
|
|
428
517
|
if (!t) return;
|
|
429
|
-
|
|
430
|
-
if (
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
if (
|
|
434
|
-
if (
|
|
435
|
-
if (
|
|
436
|
-
if (
|
|
518
|
+
const d = t.dataset;
|
|
519
|
+
if (d.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }
|
|
520
|
+
if (d.expand) { ev.stopPropagation(); state.expanded.has(d.expand) ? state.expanded.delete(d.expand) : state.expanded.add(d.expand); return render(); }
|
|
521
|
+
// Filters toggle: clicking the artifact / actor / actor-type you are already filtering on clears that filter.
|
|
522
|
+
if (d.art) { ev.stopPropagation(); state.filterArtifact = state.filterArtifact === d.art ? null : d.art; return render(); }
|
|
523
|
+
if (d.actor) { ev.stopPropagation(); state.filterActor = state.filterActor === d.actor ? null : d.actor; return render(); }
|
|
524
|
+
if (d.ftype) { ev.stopPropagation(); $("#ftype").value = $("#ftype").value === d.ftype ? "" : d.ftype; return render(); }
|
|
525
|
+
if (d.q) { ev.stopPropagation(); $("#q").value = d.q; return render(); }
|
|
526
|
+
if (d.pane) { ev.stopPropagation(); state.pane = d.pane; state.sel = null; render(); return revealDetail(); }
|
|
527
|
+
if (d.goto) { ev.preventDefault(); ev.stopPropagation(); return gotoEvent(d.goto); }
|
|
528
|
+
if (d.clear) { ev.stopPropagation(); clearFilters(d.clear); return render(); }
|
|
529
|
+
if (t.classList.contains("ev")) { state.sel = t.dataset.id; state.pane = null; render(); revealDetail(); }
|
|
437
530
|
});
|
|
531
|
+
function clearFilters(which) {
|
|
532
|
+
if (which === "artifact" || which === "all") state.filterArtifact = null;
|
|
533
|
+
if (which === "actor" || which === "all") state.filterActor = null;
|
|
534
|
+
if (which === "q" || which === "all") $("#q").value = "";
|
|
535
|
+
if (which === "all") { $("#ftype").value = ""; $("#faction").value = ""; }
|
|
536
|
+
}
|
|
537
|
+
// Jump to an event. It must end up on screen: a filter that would hide it is lifted (and the toast says so), and
|
|
538
|
+
// the graph view yields to the timeline — a jump that changes nothing visible reads as a dead link.
|
|
539
|
+
function gotoEvent(id) {
|
|
540
|
+
if (!state.events.some((e) => e.id === id)) return toast(`${id} is not among the loaded events`);
|
|
541
|
+
state.sel = id; state.pane = null; state.filterArtifact = null; state.filterActor = null;
|
|
542
|
+
if (!filtered().some((e) => e.id === id)) { clearFilters("all"); toast("Cleared the search / actor / action filters to show the linked event"); }
|
|
543
|
+
if ($("#graph").classList.contains("on")) setView("timeline");
|
|
544
|
+
render();
|
|
545
|
+
document.querySelector(`.ev[data-id="${CSS.escape(id)}"]`)?.scrollIntoView?.({ block: "center", behavior: "smooth" });
|
|
546
|
+
revealDetail();
|
|
547
|
+
}
|
|
548
|
+
// Below the 900px breakpoint the detail pane sits under the timeline, where an update is off-screen and a click looks inert.
|
|
549
|
+
function revealDetail() { if (window.matchMedia?.("(max-width: 900px)")?.matches) $("#detail").scrollIntoView?.({ block: "start", behavior: "smooth" }); }
|
|
438
550
|
$("#project").addEventListener("change", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });
|
|
439
551
|
["#q", "#ftype", "#faction"].forEach((s) => $(s).addEventListener("input", render));
|
|
440
552
|
$("#refresh").addEventListener("click", () => boot());
|
|
441
|
-
$("#report").addEventListener("click", () => window.open(P.reportUrl(state.project, state.filterArtifact), "_blank"));
|
|
553
|
+
$("#report").addEventListener("click", () => { if (!state.project) return toast("No project loaded"); window.open(P.reportUrl(state.project, state.filterArtifact), "_blank"); });
|
|
442
554
|
$("#export").addEventListener("click", async () => {
|
|
443
555
|
if (state.offline) { const blob = new Blob([JSON.stringify({ format: "retrace-export/1", scope: { project: state.project }, events: state.events }, null, 2)], { type: "application/json" }); return dl(blob, `${state.project}-events.json`); }
|
|
444
|
-
|
|
445
|
-
|
|
556
|
+
if (!state.project) return toast("No project loaded");
|
|
557
|
+
try {
|
|
558
|
+
const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
559
|
+
if (!res.ok) throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
560
|
+
dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? "-" + state.filterArtifact.replace(/[^\w.-]+/g, "_") : ""}.json`);
|
|
561
|
+
} catch (e) { toast(`Export failed: ${e.message}`); }
|
|
446
562
|
});
|
|
447
563
|
function dl(blob, name) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }
|
|
448
564
|
$("#share").addEventListener("click", () => { $("#shScope").value = state.filterArtifact ? "artifact" : "project"; $("#shScope").querySelector('[value=artifact]').disabled = !state.filterArtifact; $("#shResult").textContent = ""; $("#shareDlg").style.display = "flex"; });
|
|
@@ -454,7 +570,7 @@
|
|
|
454
570
|
const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);
|
|
455
571
|
const u = r.url.startsWith("http") ? r.url : location.origin + r.url;
|
|
456
572
|
$("#shResult").innerHTML = `Link: <a href="${esc(u)}" target="_blank">${esc(u)}</a> <button id="shCopy">copy</button><br>Report: <a href="${esc(u)}/report" target="_blank">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : ""}`;
|
|
457
|
-
$("#shCopy").addEventListener("click", () =>
|
|
573
|
+
$("#shCopy").addEventListener("click", async () => { $("#shCopy").textContent = (await copyText(u)) ? "copied" : "clipboard unavailable — select the link"; });
|
|
458
574
|
} catch (e) { $("#shResult").textContent = "Failed: " + e.message; }
|
|
459
575
|
});
|
|
460
576
|
$("#toggleConn").addEventListener("click", () => $("#conn").classList.toggle("open"));
|