diff --git a/spec/ui.md b/spec/ui.md index 5f923e3..d71204c 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -31,6 +31,10 @@ Every list and detail view has a fragment route under the authenticated inspecto Feed and Sources expose filtering as an app control, not an expanded form or a separate title/toolbar row embedded in the document. The active navigation already names the view, so the Filter control lives at the far right of the global navigation line on medium/desktop widths and becomes the next horizontally scrollable phone control. It opens a modal sheet with full-width, touch-sized fields. On phones the sheet is anchored to the bottom safe area; wider screens use a centered modal. Active filters return to the main view as individually removable chips and a numeric count; zero active filters render no count. The feed runs edge to edge inside safe-area padding. Detail selection replaces the feed in the same viewport and preserves the existing back affordance. The page publishes standalone-app metadata and an exact private manifest/icon route; it does not cache private HTML, JSON, media, or Jazz-derived content offline. +The visual system uses spacing, contained cards, and local control borders instead of full-width horizontal separator rules. Feed entries, the page header, footers, course sections, forms, and modal sections do not draw decorative top or bottom rules. The selected desktop navigation underline and borders that define an actual card, input, button, or status edge remain meaningful controls rather than document separators. + +An intentionally hidden browser-local font preview lets Cameron compare Google Fonts against the complete interface. Triple-clicking the `STREAM` wordmark or pressing `Alt+Shift+F` reveals the panel. It accepts an exact Google Fonts family name, applies it only to the body-font variable, keeps Around on display headings, and stores the selected family in same-origin local storage until reset. The feature creates no server record, cookie, event, or remote mutation. Loading a selected family makes an on-demand browser request to Google Fonts; the default page makes no Google request. The gesture is an obscured debug affordance, not an authorization boundary. + Bluesky avatars and embed thumbnails are rendered through one authenticated same-origin media route because the private web boundary intentionally denies arbitrary third-party image loads. The route accepts only HTTPS `cdn.bsky.app/img/*` URLs, follows no redirects, applies strict byte, timeout, content-type, and image-magic bounds, and returns no upstream detail on failure. Full-size post links remain ordinary external navigation. A failed image is removed from the card rather than leaving a large broken placeholder. The inspector exposes a read-only adapter inventory with public-safe release metadata, canonical lifecycle status/generation, a separately rendered active deployment binding when one exists, selecting consumers, bound runs, and output event ids. It must distinguish the execution adapter from the learned model adapter, and release status from deployment binding, and must never render checkpoint paths or resolved environment values. @@ -48,7 +52,9 @@ The Sources view is a control-plane inventory, not a list inferred from whicheve No activity yet is a valid state, not source absence. Database evidence cannot prove process liveness or upstream registration, and an upstream registration cannot prove event delivery. The interface labels each receipt boundary instead of compressing them into one generic health badge. -Inspector bootstrap requests that scan shared Jazz state run serially rather than contending in the browser. Root activity renders as soon as its bounded summary is available; slower secondary tabs continue loading independently. Every tab and detail request has an explicit loading, empty, and failed state, and the client verifies HTTP status and JSON content type before parsing. A reverse-proxy HTML error page must never appear as a raw `response.json()` exception or leave another pane permanently labeled `Loading`. +Inspector bootstrap requests that scan shared Jazz state run serially rather than contending in the browser. Root activity has a dedicated bounded endpoint and renders before run-evidence auditing, adapter inventory, and source-health scans begin. The heavier System projection is a separate endpoint and loads after the first activity paint unless System is the selected destination. A live event refreshes the bounded activity projection; it refreshes System evidence only while a System list is selected. Every tab and detail request has an explicit loading, empty, and failed state, and the client verifies HTTP status and JSON content type before parsing. A reverse-proxy HTML error page must never appear as a raw `response.json()` exception or leave another pane permanently labeled `Loading`. + +The bounded Feed projection scans one deterministic window of at most 2,000 recent source records to select up to 100 root observations, so ordinary child records such as reactions and corrections do not displace unrelated roots. If that hard scan cap cannot recover a complete root window, the response and UI say so instead of falling back to a global scan. It then hydrates at most 1,000 events in those root lineages, 1,000 recent runs, and 1,000 runs directly triggered by the hydrated events. Same-timestamp query limits use the record key as a deterministic second ordering term. When either the recent-run or root-lineage hydration cap is reached, each observation marks processing history incomplete; the UI may show found processors but cannot classify an empty bounded result as “not processed.” The Feed path never enumerates complete run history; complete run evidence remains a System concern. ## Detail view diff --git a/src/jazz/store.ts b/src/jazz/store.ts index 96f6881..9e989b2 100644 --- a/src/jazz/store.ts +++ b/src/jazz/store.ts @@ -377,11 +377,62 @@ export class JazzThoughtStore { ...(options.source ? { source: options.source } : {}), ...(options.types && options.types.length > 0 ? { type: { in: [...new Set(options.types)] } } : {}), }; - const rows = await this.db.all(thoughtstreamApp.events.where(where).orderBy("observedAt", "asc")); - const events = rows + const query = thoughtstreamApp.events.where(where); + const rows = options.limit + ? await this.db.all(query + .orderBy("observedAt", "desc") + .orderBy("key", "desc") + .limit(options.limit)) + : await this.db.all(query.orderBy("observedAt", "asc")); + return rows + .map(eventFromJazz) + .sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.id.localeCompare(right.id)); + } + + async listRecentRootEvents(options: { types: string[]; limit: number; maxScanned?: number }): Promise<{ + events: ThoughtEvent[]; + complete: boolean; + scannedEvents: number; + }> { + if (options.limit <= 0) return { events: [], complete: true, scannedEvents: 0 }; + const types = [...new Set(options.types)].filter(Boolean); + if (types.length === 0) return { events: [], complete: true, scannedEvents: 0 }; + const maxScanned = Math.max(options.limit, options.maxScanned ?? 2_000); + const rows = await this.db.all(thoughtstreamApp.events.where({ type: { in: types } }) + .orderBy("observedAt", "desc") + .orderBy("key", "desc") + .limit(maxScanned)); + const roots = new Map(); + for (const row of rows) { + if (String(row.key) !== String(row.rootEventKey)) continue; + const event = eventFromJazz(row); + roots.set(event.id, event); + if (roots.size === options.limit) break; + } + const events = [...roots.values()].sort((left, right) => ( + left.observedAt.localeCompare(right.observedAt) || left.id.localeCompare(right.id) + )); + return { + events, + complete: events.length === options.limit || rows.length < maxScanned, + scannedEvents: rows.length, + }; + } + + async listEventsForRoots(rootEventIds: string[], options: { limit?: number } = {}): Promise { + const keys = [...new Set(rootEventIds)].filter(Boolean); + if (keys.length === 0) return []; + if (keys.length > 10_000) throw new Error("Root event lookup exceeds 10000 ids"); + const query = thoughtstreamApp.events.where({ rootEventKey: { in: keys } }); + const rows = options.limit + ? await this.db.all(query + .orderBy("observedAt", "desc") + .orderBy("key", "desc") + .limit(options.limit)) + : await this.db.all(query.orderBy("observedAt", "asc")); + return rows .map(eventFromJazz) .sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.id.localeCompare(right.id)); - return options.limit ? events.slice(-options.limit) : events; } async latestSourceEvent(source: string): Promise { @@ -512,11 +563,27 @@ export class JazzThoughtStore { return rows.map(runFromJazz); } - async getRunsForTriggerEvents(eventIds: string[]): Promise { + async getRunsForTriggerEvents(eventIds: string[], options: { limit?: number } = {}): Promise { const keys = [...new Set(eventIds)].filter(Boolean); if (keys.length === 0) return []; if (keys.length > 10_000) throw new Error("Trigger run lookup exceeds 10000 event ids"); - const rows = await this.db.all(thoughtstreamApp.runs.where({ triggerEventKey: { in: keys } })); + const query = thoughtstreamApp.runs.where({ triggerEventKey: { in: keys } }); + const rows = options.limit + ? await this.db.all(query + .orderBy("createdAt", "desc") + .orderBy("key", "desc") + .limit(options.limit)) + : await this.db.all(query); + return rows + .map(runFromJazz) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); + } + + async listRecentRuns(limit = 1_000): Promise { + const rows = await this.db.all(thoughtstreamApp.runs.where({}) + .orderBy("createdAt", "desc") + .orderBy("key", "desc") + .limit(limit)); return rows .map(runFromJazz) .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); diff --git a/src/projections/activity.ts b/src/projections/activity.ts index 6ab7b8b..0edef9e 100644 --- a/src/projections/activity.ts +++ b/src/projections/activity.ts @@ -27,6 +27,7 @@ export interface ActivityItem { presentation: ActivityPresentation; descendantEventCount: number; consumerRuns: ActivityConsumerRun[]; + consumerRunsComplete: boolean; } export interface ActivityPresentation { @@ -69,18 +70,20 @@ export interface RootActivityProjection { totalEvents: number; byType: Record; items: ActivityItem[]; + rootWindowComplete: boolean; } const ROOT_SOURCE_EVENT_TYPES = [...new Set([ ...createDefaultRegistry().list().map((entry) => entry.type).filter((type) => type.startsWith("stream.thought.source.")), "stream.thought.source.x.activity", ])]; -const TERMINAL_RUN_EVENT_TYPES = [ - "stream.thought.agent.run.completed", - "stream.thought.agent.run.failed", - "stream.thought.agent.run.blocked", - "stream.thought.agent.run.abandoned", -]; +const TERMINAL_RUN_STATUSES = new Set([ + "completed", + "failed", + "blocked", + "abandoned", + "skipped", +]); export async function buildRootActivity(store: JazzThoughtStore, limit = 100): Promise { const [events, runs] = await Promise.all([store.listEvents(), store.listRuns()]); @@ -114,6 +117,7 @@ export async function buildRootActivity(store: JazzThoughtStore, limit = 100): P summary: summarizeRoot(event), presentation: presentActivityEvent(event), descendantEventCount: descendantsByRoot.get(event.id) ?? 0, + consumerRunsComplete: true, consumerRuns: (runsByRoot.get(event.id) ?? []).map((run) => ({ id: run.id, agentId: run.agentId, @@ -124,40 +128,43 @@ export async function buildRootActivity(store: JazzThoughtStore, limit = 100): P outputCount: run.outputEventIds.length, })), })); - return { totalEvents: events.length, byType, items }; + return { totalEvents: events.length, byType, items, rootWindowComplete: true }; } export async function buildRecentRootActivity( store: JazzThoughtStore, - runs: AgentRun[], limit = 100, ): Promise { - const inputIds = [...new Set(runs.flatMap((run) => run.inputEventIds))]; - const [sourceEvents, inputEvents, terminalEvents, sources] = await Promise.all([ - store.listEvents({ types: ROOT_SOURCE_EVENT_TYPES }), - store.getEvents(inputIds), - store.listEvents({ types: TERMINAL_RUN_EVENT_TYPES }), + const runLimit = 1_000; + const [rootWindow, sources, recentRuns] = await Promise.all([ + store.listRecentRootEvents({ types: ROOT_SOURCE_EVENT_TYPES, limit }), store.listSources(), + store.listRecentRuns(runLimit), ]); - const roots = sourceEvents.filter((event) => event.id === event.rootEventId); - const inputsById = new Map([...sourceEvents, ...inputEvents].map((event) => [event.id, event])); - const terminalByRun = new Map(); - for (const event of terminalEvents) { - const runId = typeof event.payload.runId === "string" ? event.payload.runId : undefined; - if (!runId) continue; - terminalByRun.set(runId, [...(terminalByRun.get(runId) ?? []), event]); - } + const roots = rootWindow.events; + const rootIds = roots.map((event) => event.id); + const rootEventLimit = 1_000; + const rootEvents = await store.listEventsForRoots(rootIds, { limit: rootEventLimit }); + const directTriggerIds = [...new Set(rootEvents.map((event) => event.id))]; + const directRuns = await store.getRunsForTriggerEvents(directTriggerIds, { limit: runLimit }); + const runs = [...new Map([...recentRuns, ...directRuns].map((run) => [run.id, run])).values()]; + const triggerIds = [...new Set(runs.map((run) => run.triggerEventId))]; + const triggerEvents = await store.getEvents(triggerIds); + const inputsById = new Map([...rootEvents, ...roots, ...triggerEvents].map((event) => [event.id, event])); + const recentRootIds = new Set(roots.map((event) => event.id)); + const consumerRunsComplete = recentRuns.length < runLimit && rootEvents.length < rootEventLimit; const descendantsByRoot = new Map>(); const runsByRoot = new Map(); for (const run of runs) { const inputs = run.inputEventIds.map((id) => inputsById.get(id)).filter((event): event is ThoughtEvent => Boolean(event)); const rootIds = rootIdsForRun(run, inputsById); for (const rootId of rootIds) { + if (!recentRootIds.has(rootId)) continue; runsByRoot.set(rootId, [...(runsByRoot.get(rootId) ?? []), run]); const descendants = descendantsByRoot.get(rootId) ?? new Set(); for (const input of inputs) if (input.id !== rootId) descendants.add(input.id); for (const outputId of run.outputEventIds) descendants.add(outputId); - for (const terminal of terminalByRun.get(run.id) ?? []) descendants.add(terminal.id); + if (TERMINAL_RUN_STATUSES.has(run.status)) descendants.add(`terminal:${run.id}`); descendantsByRoot.set(rootId, descendants); } } @@ -175,6 +182,7 @@ export async function buildRecentRootActivity( summary: summarizeRoot(event), presentation: presentActivityEvent(event), descendantEventCount: descendantsByRoot.get(event.id)?.size ?? 0, + consumerRunsComplete, consumerRuns: (runsByRoot.get(event.id) ?? []).map((run) => ({ id: run.id, agentId: run.agentId, @@ -192,6 +200,7 @@ export async function buildRecentRootActivity( totalEvents: sources.reduce((total, source) => total + source.lastSequence, 0), byType, items, + rootWindowComplete: rootWindow.complete, }; } @@ -238,15 +247,16 @@ export async function buildRecentSourceActivity( summary: summarizeRoot(event), presentation: presentActivityEvent(event), descendantEventCount: consumerRuns.reduce((total, run) => total + run.outputCount + 1, 0), + consumerRunsComplete: true, consumerRuns, }; }); - return { totalEvents: roots.length, byType, items }; + return { totalEvents: roots.length, byType, items, rootWindowComplete: true }; } function rootIdsForRun(run: AgentRun, eventsById: Map): Set { const rootIds = new Set(); - for (const inputId of run.inputEventIds) { + for (const inputId of new Set([run.triggerEventId, ...run.inputEventIds])) { const input = eventsById.get(inputId); if (!input) continue; rootIds.add(input.rootEventId); diff --git a/src/web/authenticated-proxy.ts b/src/web/authenticated-proxy.ts index 2272a7e..0a5009f 100644 --- a/src/web/authenticated-proxy.ts +++ b/src/web/authenticated-proxy.ts @@ -8,6 +8,7 @@ import { isIP } from "node:net"; import { InspectorOAuthAuth, OAuthCallbackQuarantineCapacityError } from "./oauth-auth.js"; import { createOAuthRouteRateLimiter, type OAuthRouteRateLimiter } from "./rate-limit.js"; import { loadPublicPages, publicPageForRoute, type PublicPage } from "./public-site.js"; +import { FONT_DEBUG_ASSET_PATH, FONT_DEBUG_SCRIPT } from "./font-debug.js"; import { REVIEW_CSRF_HEADER, REVIEW_NONCE_HEADER, @@ -50,14 +51,17 @@ const SECURITY_HEADERS = { "x-frame-options": "DENY", } as const; +const PUBLIC_PAGE_CONTENT_SECURITY_POLICY = + "default-src 'self'; script-src 'self'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src data: https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"; + const LANDING_CONTENT_SECURITY_POLICY = - "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; font-src data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' https:"; + "default-src 'self'; script-src 'self'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src data: https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' https:"; const OAUTH_LOGIN_CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' https:"; const INSPECTOR_HTML_CONTENT_SECURITY_POLICY = - "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self'; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; const FORWARDED_REQUEST_HEADERS = new Set([ "accept", @@ -175,13 +179,23 @@ async function handleRequest(options: { send(options.response, 400, "Invalid request.\n", { "content-type": "text/plain; charset=utf-8" }); return; } + if (url.pathname === FONT_DEBUG_ASSET_PATH) { + if (!isReadMethod(options.request.method)) return methodNotAllowed(options.request, options.response, "GET, HEAD"); + options.request.resume(); + send(options.response, 200, options.request.method === "HEAD" ? "" : FONT_DEBUG_SCRIPT, { + "content-type": "text/javascript; charset=utf-8", + }); + return; + } const publicPage = publicPageForRoute(options.publicPages, url.pathname); if (publicPage) { if (!isReadMethod(options.request.method)) return methodNotAllowed(options.request, options.response, "GET, HEAD"); options.request.resume(); send(options.response, 200, options.request.method === "HEAD" ? "" : publicPage.html, { "content-type": "text/html; charset=utf-8", - ...(publicPage.route === "/" ? { "content-security-policy": LANDING_CONTENT_SECURITY_POLICY } : {}), + "content-security-policy": publicPage.route === "/" + ? LANDING_CONTENT_SECURITY_POLICY + : PUBLIC_PAGE_CONTENT_SECURITY_POLICY, }); return; } diff --git a/src/web/font-debug.ts b/src/web/font-debug.ts new file mode 100644 index 0000000..ea90154 --- /dev/null +++ b/src/web/font-debug.ts @@ -0,0 +1,128 @@ +export const FONT_DEBUG_ASSET_PATH = "/assets/font-debug.js"; + +export const FONT_DEBUG_SCRIPT = `(() => { + const storageKey = "stream-body-font-preview"; + const defaultStack = "Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif"; + const familyPattern = /^[A-Za-z0-9][A-Za-z0-9 -]{0,79}$/; + let requestVersion = 0; + let pendingLink = null; + + const style = document.createElement("style"); + style.textContent = ".font-debug-panel{position:fixed;right:max(16px,env(safe-area-inset-right));bottom:max(16px,env(safe-area-inset-bottom));z-index:10000;width:min(340px,calc(100vw - 32px));border:1px solid var(--border,var(--line,#444));border-radius:16px;background:var(--panel,var(--bg,#111));color:var(--text,#eee);box-shadow:0 18px 56px rgba(0,0,0,.28);padding:16px;font:14px/1.45 var(--font-body)}.font-debug-panel[hidden]{display:none}.font-debug-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.font-debug-head strong{font-size:14px}.font-debug-close{border:0;background:transparent;color:var(--muted,#888);padding:4px;cursor:pointer}.font-debug-panel label{display:grid;gap:6px;color:var(--muted,#888);font-size:12px}.font-debug-panel input{width:100%;min-height:40px;border:1px solid var(--border,var(--line,#444));border-radius:10px;background:var(--surface-control,var(--surface,#171717));color:var(--text,#eee);padding:8px 10px}.font-debug-actions{display:flex;gap:8px;margin-top:10px}.font-debug-actions button{min-height:36px;border:1px solid var(--border,var(--line,#444));border-radius:999px;background:transparent;color:var(--text,#eee);padding:7px 12px;cursor:pointer}.font-debug-actions button:first-child{border-color:var(--accent,#b497bf);background:var(--accent-soft,rgba(180,151,191,.11))}.font-debug-status{min-height:20px;margin:10px 0 0;color:var(--muted,#888);font-size:12px}"; + document.head.append(style); + + const panel = document.createElement("section"); + panel.className = "font-debug-panel"; + panel.hidden = true; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", "Body font preview"); + panel.innerHTML = '
Body font

Around display type stays unchanged.

'; + document.body.append(panel); + + const input = panel.querySelector("#font-debug-family"); + const status = panel.querySelector(".font-debug-status"); + const closeButton = panel.querySelector(".font-debug-close"); + const applyButton = panel.querySelector("[data-font-apply]"); + const resetButton = panel.querySelector("[data-font-reset]"); + + function storedFamily() { + try { return localStorage.getItem(storageKey) || ""; } catch { return ""; } + } + + function storeFamily(family) { + try { + if (family) localStorage.setItem(storageKey, family); + else localStorage.removeItem(storageKey); + } catch {} + } + + function setPanelOpen(open) { + panel.hidden = !open; + if (open) { + input.value = storedFamily(); + input.focus(); + input.select(); + } + } + + function resetFont() { + requestVersion += 1; + pendingLink = null; + document.querySelectorAll("link[data-stream-font-debug]").forEach(link => link.remove()); + document.documentElement.style.removeProperty("--font-body"); + storeFamily(""); + input.value = ""; + status.textContent = "Using the default body font. Around display type stays unchanged."; + } + + function applyFont(rawFamily, persist = true) { + const family = rawFamily.trim().replace(/\\s+/g, " "); + if (!familyPattern.test(family)) { + status.textContent = "Use the exact Google Fonts family name with letters, numbers, spaces, or hyphens."; + return; + } + const version = ++requestVersion; + pendingLink?.remove(); + const previous = document.querySelector("#font-debug-google-font"); + const link = document.createElement("link"); + link.dataset.streamFontDebug = "true"; + link.rel = "stylesheet"; + link.href = "https://fonts.googleapis.com/css2?family=" + encodeURIComponent(family).replace(/%20/g, "+") + "&display=swap"; + link.addEventListener("load", () => { + if (version !== requestVersion) { + link.remove(); + return; + } + pendingLink = null; + previous?.remove(); + link.id = "font-debug-google-font"; + document.documentElement.style.setProperty("--font-body", '"' + family + '",' + defaultStack); + if (persist) storeFamily(family); + input.value = family; + status.textContent = "Using " + family + " for body text. Around is unchanged."; + }); + link.addEventListener("error", () => { + if (pendingLink === link) pendingLink = null; + link.remove(); + if (version !== requestVersion) return; + status.textContent = "Google Fonts did not return that family."; + }); + pendingLink = link; + document.head.append(link); + status.textContent = "Loading " + family + "…"; + } + + applyButton.addEventListener("click", () => applyFont(input.value)); + resetButton.addEventListener("click", resetFont); + closeButton.addEventListener("click", () => setPanelOpen(false)); + input.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + applyFont(input.value); + } + }); + document.addEventListener("keydown", event => { + if (event.key === "Escape" && !panel.hidden) setPanelOpen(false); + if (event.altKey && event.shiftKey && event.key.toLowerCase() === "f") { + event.preventDefault(); + setPanelOpen(panel.hidden); + } + }); + + const trigger = document.querySelector("h1"); + let clickCount = 0; + let lastClickAt = 0; + trigger?.addEventListener("click", () => { + const now = Date.now(); + clickCount = now - lastClickAt < 650 ? clickCount + 1 : 1; + lastClickAt = now; + if (clickCount === 3) { + clickCount = 0; + setPanelOpen(panel.hidden); + } + }); + + const saved = storedFamily(); + if (saved && familyPattern.test(saved)) applyFont(saved, false); +})(); +`; diff --git a/src/web/inspector.ts b/src/web/inspector.ts index ed20ee9..d4d8177 100644 --- a/src/web/inspector.ts +++ b/src/web/inspector.ts @@ -46,6 +46,7 @@ import { import { materializeMemoryDecision } from "../agent-proposals/memory-materializer.js"; import { recordProposalDecision } from "../agent-proposals/review.js"; import { z } from "zod"; +import { FONT_DEBUG_ASSET_PATH, FONT_DEBUG_SCRIPT } from "./font-debug.js"; const RUN_TERMINAL_EVENT_TYPES = [ "stream.thought.agent.run.completed", @@ -295,6 +296,10 @@ async function handleRequest( send(response, 200, "image/svg+xml; charset=utf-8", STREAM_APP_ICON); return; } + if (url.pathname === FONT_DEBUG_ASSET_PATH) { + send(response, 200, "text/javascript; charset=utf-8", FONT_DEBUG_SCRIPT); + return; + } if (url.pathname === "/sw.js") { send(response, 200, "text/javascript; charset=utf-8", STREAM_SERVICE_WORKER); return; @@ -339,38 +344,21 @@ async function handleRequest( } return; } + if (url.pathname === "/api/activity") { + sendJson(response, 200, await buildInspectorActivity(store)); + return; + } + if (url.pathname === "/api/system") { + sendJson(response, 200, await buildInspectorSystem(store)); + return; + } if (url.pathname === "/api/snapshot") { - const [runs, agents, sources] = await Promise.all([ - store.listRuns(), - store.listAgents(), - buildSourceHealth(store), + const runs = await store.listRuns(); + const [activity, system] = await Promise.all([ + buildInspectorActivity(store), + buildInspectorSystem(store, runs), ]); - const [activity, evidenceEvents] = await Promise.all([ - buildRecentRootActivity(store, runs), - loadRunEvidenceEvents(store, runs), - ]); - const runEvidence = auditRunEvidence(runs, evidenceEvents); - const adapterSelections = agents - .map((agent) => ({ - id: agent.id, - version: agent.version, - enabled: agent.enabled, - adapter: adapterSelectionFromSpec(agent.spec), - })) - .filter((agent) => agent.adapter !== undefined); - const adapterCatalogs = [...new Map(adapterSelections.map((selection) => [ - `${selection.adapter!.digest}:${selection.adapter!.generation}`, - { digest: selection.adapter!.digest, generation: selection.adapter!.generation }, - ])).values()]; - sendJson(response, 200, { - activity, - runs: runs.slice().reverse().map(inspectorRunSummary), - agents, - sources, - adapterInventory: { catalogs: adapterCatalogs, selections: adapterSelections }, - runEvidence, - evidenceContradictions: runEvidence.filter((report) => !report.consistent).length, - }); + sendJson(response, 200, { activity, ...system }); return; } if (url.pathname === "/api/live") { @@ -985,11 +973,11 @@ export function renderInspectorHtml(): string { Stream @@ -79,6 +80,7 @@ function renderLandingPage(title: string, body: string, fontDataUrl: string): st
${body}
+ `; } @@ -93,13 +95,14 @@ function renderDocumentationPage(title: string, body: string, fontDataUrl: strin
Stream
${body}
+ `; } diff --git a/test/authenticated-proxy.test.ts b/test/authenticated-proxy.test.ts index 9c1d094..14683fc 100644 --- a/test/authenticated-proxy.test.ts +++ b/test/authenticated-proxy.test.ts @@ -159,7 +159,7 @@ describe("authenticated inspector proxy", () => { const page = await fetch(`${baseUrl(proxy)}/inspector/`, { headers }); expect(await page.text()).toContain("fetch('api/snapshot')"); - expect(page.headers.get("content-security-policy")).toContain("script-src 'unsafe-inline'"); + expect(page.headers.get("content-security-policy")).toContain("script-src 'self' 'unsafe-inline'"); expect(page.headers.get("content-security-policy")).toContain("img-src 'self' data:"); expect(page.headers.get("content-security-policy")).toContain("worker-src 'self'"); expect(page.headers.get("content-security-policy")).not.toContain("img-src 'self' data: https:"); @@ -204,14 +204,23 @@ describe("authenticated inspector proxy", () => { expect(landingHtml).toMatch(/\.landing-main h1\{[^}]*text-align:center\}/); expect(landingHtml).toContain(".login-form{margin-top:28px;text-align:center}"); expect(landingHtml).toContain( - ".landing-footer{padding:16px 0 0;border-top:1px solid var(--border);font-size:12px;text-align:center}", + ".landing-footer{padding:16px 0 0;font-size:12px;text-align:center}", ); + expect(landingHtml).toContain(''); + expect(landingHtml).not.toContain(".landing-footer{padding:16px 0 0;border-top:"); expect(landingHtml).toContain('href="https://tangled.org/@cameron.stream/thought-stream">code'); expect(landingHtml).toContain("data:font/woff2;base64,"); expect(landingHtml).not.toContain('href="/docs"'); expect(landingHtml).not.toContain("private inspector"); expect(landing.headers.get("content-security-policy")).toContain("form-action 'self' https:"); expect(landing.headers.get("content-security-policy")).toContain("font-src data:"); + expect(landing.headers.get("content-security-policy")).toContain("https://fonts.googleapis.com"); + expect(landing.headers.get("content-security-policy")).toContain("https://fonts.gstatic.com"); + const fontDebug = await fetch(`${base}/assets/font-debug.js`); + expect(fontDebug.status).toBe(200); + expect(fontDebug.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + const fontDebugScript = await fontDebug.text(); + expect(() => new Function(fontDebugScript)).not.toThrow(); const architecture = await fetch(`${base}/docs/architecture`); expect(architecture.status).toBe(200); expect(await architecture.text()).toContain("Connector cursors advance only after durable events"); diff --git a/test/inspector.test.ts b/test/inspector.test.ts index 06e80f5..312416e 100644 --- a/test/inspector.test.ts +++ b/test/inspector.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { afterEach, describe, expect, test, vi } from "vitest"; import { startInspectorServer } from "../src/web/inspector.js"; import type { JazzThoughtStore } from "../src/jazz/store.js"; +import type { AgentRun } from "../src/store/types.js"; import { temporaryProject, testStore } from "./helpers.js"; const stores: JazzThoughtStore[] = []; @@ -124,6 +125,10 @@ describe("thought stream inspector", () => { expect(pageHtml).not.toContain("grid-template-columns:repeat(6,minmax(0,1fr))"); expect(pageHtml).toContain("const primaryResource=tabResource();"); expect(pageHtml).toContain("await loadResource(primaryResource)"); + expect(pageHtml).toContain("for(const key of ['activity','course','artifacts','proposals','reviews','system'])"); + expect(pageHtml).toContain("tabResource()===key||(key==='system'&&state.tab==='events')"); + expect(pageHtml).toContain("activity:{path:'api/activity',label:'recent activity'}"); + expect(pageHtml).toContain("system:{path:'api/system',label:'system evidence'}"); expect(pageHtml).not.toContain("Promise.all([fetch('api/snapshot')"); expect(pageHtml).not.toContain("fetch('/api/snapshot')"); expect(pageHtml).toContain("response.headers.get('content-type')"); @@ -145,12 +150,15 @@ describe("thought stream inspector", () => { expect(pageHtml).toContain("All sources"); expect(pageHtml).toContain("is configured but has no durable activity yet."); expect(pageHtml).toContain("No agent processed this observation."); + expect(pageHtml).toContain("Earlier processing may be outside this window."); + expect(pageHtml).toContain("Some older observations are outside this bounded feed window."); + expect(pageHtml).toContain("processed||!processingComplete"); expect(pageHtml).toContain("Technical details"); expect(pageHtml).not.toContain("No consumer processed this event."); expect(pageHtml).toContain("No recent activity has been recorded."); expect(pageHtml).toContain("No artifacts are available."); expect(pageHtml).toContain("new EventSource('api/live')"); - expect(pageHtml).toContain("scheduleSnapshotRefresh"); + expect(pageHtml).toContain("scheduleActivityRefresh"); expect(pageHtml).toContain("Internal producers"); expect(pageHtml).toContain("Loading source activity…"); expect(pageHtml).toContain("id==='resident-letta-conversation'?'Stream'"); @@ -195,16 +203,28 @@ describe("thought stream inspector", () => { expect(pageHtml).toContain(".item:hover,.item.active { background:transparent }"); expect(pageHtml).toContain(".item:hover .telegram-inlay"); expect(pageHtml).toContain("cursor:pointer; overflow-wrap:anywhere"); - expect(pageHtml).toContain("font:15px/1.58 Inter"); + expect(pageHtml).toContain("font:15px/1.58 var(--font-body)"); + expect(pageHtml).toContain(''); + expect(pageHtml).not.toContain("border-bottom:1px solid var(--line); border-radius:0; margin:0; padding:20px 0"); expect(page.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); expect(page.headers.get("content-security-policy")).toContain("img-src 'self' data:"); expect(page.headers.get("content-security-policy")).toContain("worker-src 'self'"); + expect(page.headers.get("content-security-policy")).toContain("https://fonts.googleapis.com"); + expect(page.headers.get("content-security-policy")).toContain("https://fonts.gstatic.com"); expect(page.headers.get("content-security-policy")).not.toContain("img-src 'self' data: https:"); const font = await fetch(`${base}/assets/around-regular.woff2`); expect(font.status).toBe(200); expect(font.headers.get("content-type")).toBe("font/woff2"); expect(Buffer.from(await font.arrayBuffer()).subarray(0, 4).toString("ascii")).toBe("wOF2"); + const fontDebug = await fetch(`${base}/assets/font-debug.js`); + expect(fontDebug.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + const fontDebugScript = await fontDebug.text(); + expect(fontDebugScript).toContain("fonts.googleapis.com/css2?family="); + expect(fontDebugScript).toContain("event.altKey && event.shiftKey"); + expect(fontDebugScript).toContain('querySelectorAll("link[data-stream-font-debug]")'); + expect(fontDebugScript).toContain("pendingLink?.remove()"); + expect(() => new Function(fontDebugScript)).not.toThrow(); const manifest = await fetch(`${base}/manifest.webmanifest`); expect(manifest.headers.get("content-type")).toBe("application/manifest+json; charset=utf-8"); expect(await manifest.json()).toMatchObject({ name: "Stream", start_url: "./", display: "standalone" }); @@ -222,6 +242,15 @@ describe("thought stream inspector", () => { runEvidence: Array<{ runId: string; consistent: boolean; issues: Array<{ code: string }> }>; sources: Array<{ source: string; status: string; recordCount: number; operationsStarted: number; operationFailures: number; inFlight: number; cursor: { etag?: string } }>; }; + const rootEventsSpy = vi.spyOn(store, "listRecentRootEvents"); + const activity = await (await fetch(`${base}/api/activity`)).json() as { totalEvents: number }; + expect(rootEventsSpy).toHaveBeenCalledTimes(1); + expect(rootEventsSpy).toHaveBeenCalledWith({ types: expect.any(Array), limit: 100 }); + rootEventsSpy.mockRestore(); + const system = await (await fetch(`${base}/api/system`)).json() as { evidenceContradictions: number; sources: unknown[] }; + expect(activity.totalEvents).toBe(3); + expect(system.evidenceContradictions).toBe(1); + expect(system.sources).toHaveLength(1); expect(snapshot.activity.totalEvents).toBe(3); expect(snapshot.evidenceContradictions).toBe(1); expect(snapshot.runs[0]).toMatchObject({ id: "run_contradictory" }); @@ -579,6 +608,167 @@ describe("thought stream inspector", () => { ]); }); + test("preserves recent roots across a child-heavy source window without listing all runs", async () => { + const project = await temporaryProject(); + roots.push(project); + const store = testStore(project); + stores.push(store); + const root = (await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:child-window", + sourceKind: "rss", + externalId: "root", + idempotencyKey: "root", + occurredAt: "2026-08-22T10:00:00.000Z", + observedAt: "2026-08-22T10:00:00.000Z", + actor: "rss:child-window", + correlationId: "root", + privacy: "public-source", + payload: { title: "Root observation" }, + })).event; + const secondRoot = (await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:child-window", + sourceKind: "rss", + externalId: "second-root", + idempotencyKey: "second-root", + occurredAt: "2026-08-22T10:00:30.000Z", + observedAt: "2026-08-22T10:00:30.000Z", + actor: "rss:child-window", + correlationId: "second-root", + privacy: "public-source", + payload: { title: "Second root observation" }, + })).event; + for (let index = 0; index < 100; index += 1) { + await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:child-window", + sourceKind: "rss", + externalId: `child-${index}`, + idempotencyKey: `child-${index}`, + occurredAt: `2026-08-22T10:01:${String(index % 60).padStart(2, "0")}.000Z`, + observedAt: `2026-08-22T10:${String(1 + Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + actor: "rss:child-window", + rootEventId: root.id, + parentEventId: root.id, + correlationId: "root", + privacy: "public-source", + payload: { title: `Child ${index}` }, + }); + } + + const listRunsSpy = vi.spyOn(store, "listRuns"); + const recentRunsSpy = vi.spyOn(store, "listRecentRuns"); + const server = await startInspectorServer(store, { port: 0 }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing inspector address"); + + const activity = await (await fetch(`http://127.0.0.1:${address.port}/api/activity`)).json() as { + totalEvents: number; + items: Array<{ id: string }>; + rootWindowComplete: boolean; + }; + expect(activity.totalEvents).toBe(102); + expect(activity.rootWindowComplete).toBe(true); + expect(activity.items).toEqual([ + expect.objectContaining({ id: secondRoot.id }), + expect.objectContaining({ id: root.id }), + ]); + expect(listRunsSpy).not.toHaveBeenCalled(); + expect(recentRunsSpy).toHaveBeenCalledWith(1_000); + }); + + test("recovers a child-triggered run outside the recent run window and marks history incomplete", async () => { + const project = await temporaryProject(); + roots.push(project); + const store = testStore(project); + stores.push(store); + const root = (await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:child-run", + sourceKind: "rss", + externalId: "root", + idempotencyKey: "root", + occurredAt: "2026-08-22T10:00:00.000Z", + actor: "rss:child-run", + correlationId: "root", + privacy: "public-source", + payload: { title: "Root with child processing" }, + })).event; + const child = (await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:child-run", + sourceKind: "rss", + externalId: "child", + idempotencyKey: "child", + occurredAt: "2026-08-22T10:01:00.000Z", + actor: "rss:child-run", + rootEventId: root.id, + parentEventId: root.id, + correlationId: "root", + privacy: "public-source", + payload: { title: "Child activity" }, + })).event; + await store.upsertRun({ + id: "run_child_activity", + executionKey: "execution:run_child_activity", + triggerEventId: child.id, + agentId: "child-processor", + agentVersion: 1, + status: "completed", + inputEventIds: [child.id], + outputEventIds: [], + attempt: 1, + provider: "deterministic", + model: "deterministic", + promptHash: "child-prompt", + contextManifest: {}, + result: { summary: "Processed child activity" }, + createdAt: "2026-08-22T10:01:01.000Z", + completedAt: "2026-08-22T10:01:02.000Z", + updatedAt: "2026-08-22T10:01:02.000Z", + }); + const fillerRuns: AgentRun[] = Array.from({ length: 1_000 }, (_, index) => ({ + id: `run_filler_${index}`, + executionKey: `execution:run_filler_${index}`, + triggerEventId: `missing_event_${index}`, + agentId: "filler", + agentVersion: 1, + status: "running", + inputEventIds: [], + outputEventIds: [], + attempt: 1, + provider: "deterministic", + model: "deterministic", + promptHash: `filler_${index}`, + contextManifest: {}, + createdAt: `2026-08-22T11:${String(Math.floor(index / 60) % 60).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: "2026-08-22T12:00:00.000Z", + })); + vi.spyOn(store, "listRecentRuns").mockResolvedValue(fillerRuns); + + const server = await startInspectorServer(store, { port: 0 }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing inspector address"); + const activity = await (await fetch(`http://127.0.0.1:${address.port}/api/activity`)).json() as { + items: Array<{ id: string; consumerRunsComplete: boolean; consumerRuns: Array<{ id: string }> }>; + }; + expect(activity.items).toEqual([ + expect.objectContaining({ + id: root.id, + consumerRunsComplete: false, + consumerRuns: [expect.objectContaining({ id: "run_child_activity" })], + }), + ]); + }); + test("streams Jazz activity signals without exposing event content in the stream", async () => { const project = await temporaryProject(); roots.push(project); diff --git a/test/jazz-store.test.ts b/test/jazz-store.test.ts index bd630f9..d671058 100644 --- a/test/jazz-store.test.ts +++ b/test/jazz-store.test.ts @@ -73,6 +73,69 @@ describe("JazzThoughtStore", () => { expect((await store.listSources()).find((source) => source.id === "rss:test")?.lastSequence).toBe(1); }); + test("limits event queries in storage while preserving chronological output", async () => { + const root = await temporaryProject(); + roots.push(root); + const store = testStore(root); + stores.push(store); + for (const index of [1, 2, 3]) { + await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:bounded", + sourceKind: "rss", + externalId: `item-${index}`, + idempotencyKey: `item-${index}`, + occurredAt: `2026-08-22T10:00:0${index}.000Z`, + observedAt: "2026-08-22T10:00:04.000Z", + actor: "rss:bounded", + correlationId: "bounded-query", + privacy: "public-source", + payload: { index }, + }); + } + + const recent = await store.listEvents({ + types: ["stream.thought.source.rss.item"], + limit: 2, + }); + const all = await store.listEvents({ types: ["stream.thought.source.rss.item"] }); + expect(recent.map((event) => event.id)).toEqual(all.slice(-2).map((event) => event.id)); + + const rootEvent = all[0]!; + for (const index of [4, 5, 6]) { + await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:bounded", + sourceKind: "rss", + externalId: `child-${index}`, + idempotencyKey: `child-${index}`, + occurredAt: `2026-08-22T10:00:0${index}.000Z`, + observedAt: `2026-08-22T10:00:0${index}.000Z`, + actor: "rss:bounded", + rootEventId: rootEvent.id, + parentEventId: rootEvent.id, + correlationId: "bounded-query", + privacy: "public-source", + payload: { index }, + }); + } + const incomplete = await store.listRecentRootEvents({ + types: ["stream.thought.source.rss.item"], + limit: 2, + maxScanned: 2, + }); + expect(incomplete).toMatchObject({ events: [], complete: false, scannedEvents: 2 }); + const recovered = await store.listRecentRootEvents({ + types: ["stream.thought.source.rss.item"], + limit: 2, + maxScanned: 10, + }); + expect(recovered.complete).toBe(true); + expect(recovered.events).toHaveLength(2); + }); + test("persists events across a process-shaped reopen", async () => { const root = await temporaryProject(); roots.push(root);