From 2aea2d3da19b8ad69ec2b18fcb670bd41c2dce50 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:00:21 -0500 Subject: [PATCH 1/9] Add bounded FACTORY swarm Observatory viewer and full hierarchy navigation --- docs/SWARM.md | 74 ++++++++++ index.html | 1 + package.json | 1 + src/main.ts | 3 + src/swarm/main.ts | 274 ++++++++++++++++++++++++++++++++++++++ src/swarm/model.ts | 177 ++++++++++++++++++++++++ src/swarm/observatory.css | 126 ++++++++++++++++++ src/swarm/preview.ts | 24 ++++ src/swarm/renderer.ts | 213 +++++++++++++++++++++++++++++ src/swarm/swarm.css | 1 + swarm.html | 15 +++ test/swarm.test.mjs | 92 +++++++++++++ vite.config.ts | 1 + 13 files changed, 1002 insertions(+) create mode 100644 docs/SWARM.md create mode 100644 src/swarm/main.ts create mode 100644 src/swarm/model.ts create mode 100644 src/swarm/observatory.css create mode 100644 src/swarm/preview.ts create mode 100644 src/swarm/renderer.ts create mode 100644 src/swarm/swarm.css create mode 100644 swarm.html create mode 100644 test/swarm.test.mjs diff --git a/docs/SWARM.md b/docs/SWARM.md new file mode 100644 index 0000000..f12f14b --- /dev/null +++ b/docs/SWARM.md @@ -0,0 +1,74 @@ +# Brain Observatory + +`swarm.html` is the multipage FACTORY viewer built with the normal Vite build. +Brain Online embeds it under `/viewer/` from the upstream Brain image and supplies +authenticated observations. Its normal Brain viewer links to `/factory/swarm` +when hosted at `/viewer/`; standalone hosts link to `./swarm.html`. + +The component accepts public v1 snapshots from an explicit operator source +registry. It does not discover factories, receive credentials, execute work, +or infer authority links between sources. All identities include the registry +source and factory. The read-only index preserves every recorded cell, task, +effect, evidence digest and local parent relationship while rendering a bounded +subset. Search, breadcrumbs and 128-child pages navigate the complete index. + +## Hosting + +Embedded hosts pass `?embed=1&channel=<16..128 character channel>` and exchange +same-origin `brain-swarm/1` messages with the exact parent/frame: + +- viewer → parent: `ready` or `select` with the channel and scoped selection; +- parent → viewer: `observe` with channel, increasing integer sequence and model; +- parent → viewer: `ping`; viewer repeats `ready`. + +The model has schemaVersion 1, scope `operator-source-registry`, commands false, +observedAt and at most 32 sources. Each source has id, label, factoryId, +observed/stale/unavailable status, a public canonical FACTORY snapshot or null, +and optional metadata events. `model.ts` positively allowlists these facts; +private extensions are dropped and unsupported execution capability is rejected. +Never put a bearer credential in a URL, iframe message or client environment. + +A standalone deployment must supply its own authenticated same-origin JSON bridge +at `/api/factory/swarm` (or the `source` parameter's same-origin absolute path). +The Brain manager does not automatically expose credentialed FACTORY reads. +Standalone polling uses same-origin credentials, no-store reads and a five-second +delay after each observation. An unavailable bridge shows an empty/unavailable +world; there is no real-data-to-fixture fallback. + +`swarm.html?preview=1&cells=10000` explicitly generates a 10,000-cell design +fixture. Normal preview defaults to 54 cells across three independent sources. +The fixture label remains visible. Brain Online has its own explicit preview +host and uses the same upstream renderer. + +## Scale and rendering + +The full index uses iterative hierarchy layout, deterministic positions and +scoped keys. It handles deep chains and wide parents without recursion or a +force simulation. Visible nodes are capped at 600, labels at 24, and navigation +lists/search results are bounded. Ancestor/sibling context and child pages keep +hidden records reachable. A source overview has no cross-source authority edges. + +The WebGL renderer batches node points and delegation/reference lines, disposes +replaced geometry, suspends hidden documents, renders idle views only on demand, +and caps evidenced activity at 30 frames/second. Reduced motion disables pulse +and animated camera flight. Context loss switches to the 2D renderer, which +uses the same index and scoped selection. Rendering diagnostics report CPU +submission time separately from draw-call and frame counts; they do not prove +GPU timing or physical worker health. + +Fresh observations do not establish worker activity. A cell needs an active +controller, ready lifecycle, recent heartbeat, an unexpired running lease and +no owned unknown outcome. Completed/cancelled operations do not animate as +running work. Pause, effect drain and worker quiescence remain separate facts. +The selected inspector preserves task, candidate and review digests and states +that quiescence is unverified. + +## Development and verification + +Run `npm ci`, `npm run build` and `npm run test:swarm`. Tests use the actual model +and preview modules and cover duplicate record IDs across authorities, complete +10,000-deep/wide navigation, stable layout, stale/lease/terminal activity gates, +positive allowlists and missing-source behavior. Copy the built `dist` artifact +to the consumer's ignored viewer directory for local integration; keep source +in this repository. Qualify desktop/mobile composition and 3D/2D navigation in +the browser against the built artifact before publishing the Brain image. diff --git a/index.html b/index.html index cccf657..c9b2388 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,7 @@

brain

+ ⬡ swarm
Waiting for an authorized observationREAD ONLY +
+ +
A wider mind.

Orbit the recorded world. Enter a source. Follow its work.

LIVE TRACKING
Rendering details
+ +
+
FACTORY owns execution and recovery. Recorded activity does not establish host health. Worker quiescence: unverified.
+
+ + diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs new file mode 100644 index 0000000..c223066 --- /dev/null +++ b/test/swarm.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { performance } from 'node:perf_hooks'; +import ts from 'typescript'; +const generated = new URL('../.vite/swarm-tests/', import.meta.url); +await mkdir(generated, { recursive: true }); +for (const name of ['model', 'preview']) { + const source = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); + await writeFile(new URL(`${name}.mjs`, generated), ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText); +} +const { parseSwarm, SwarmIndex, nodeKey, activities } = await import(new URL('model.mjs', generated)); +const { swarmPreview } = await import(new URL('preview.mjs', generated)); + +test('same bare root/task/effect identities remain distinct across authorities and registration is separate', () => { + const swarm = parseSwarm(swarmPreview()); + const index = new SwarmIndex(swarm); + const keys = swarm.sources.map(source => nodeKey(source, 'cell', 'root')); + assert.equal(new Set(keys).size, 3); + for (const source of swarm.sources) { + const root = index.nodeFor({ sourceId: source.id, kind: 'cell', id: 'root' }); + assert.equal(root.source.factoryId, source.factoryId); + assert.equal(index.ancestors(root.key)[0].selection.kind, 'source'); + assert.ok(index.ancestors(root.key).every(node => node.source.id === source.id)); + assert.equal(index.nodeFor({ sourceId: source.id, kind: 'task', id: 'exploration' }).source.id, source.id); + } +}); +test('10,000-level delegation can be indexed, searched, selected and drawn without recursive overflow or losing focus', () => { + const swarm = swarmPreview(10000), source = swarm.sources[0]; + swarm.sources = [source]; + const template = source.snapshot.snapshot.cells[0]; + source.snapshot.snapshot.cells = Array.from({ length: 10000 }, (_, index) => ({ ...template, id: index ? `deep-${index}` : 'root', parentId: index === 0 ? null : index === 1 ? 'root' : `deep-${index - 1}`, depth: index })); + const start = performance.now(), index = new SwarmIndex(parseSwarm(swarm)); + const target = index.nodeFor({ sourceId: source.id, kind: 'cell', id: 'deep-9999' }); + assert.ok(target); + assert.equal(index.ancestors(target.key).length, 10001); + assert.equal(index.search('deep-9999')[0].id, 'deep-9999'); + const visible = index.visible(target.key); + assert.ok(visible.length <= 600); + assert.ok(visible.some(node => node.key === target.key)); + assert.ok(Number.isFinite(target.point.x)); + console.log(`Deep-swarm model qualification: ${Math.round(performance.now() - start)}ms, ${index.nodes.size} indexed / ${visible.length} drawn.`); +}); +test('10,000 siblings remain reachable across bounded child pages rather than being silently truncated', () => { + const swarm = swarmPreview(10000), source = swarm.sources[0]; swarm.sources = [source]; + const template = source.snapshot.snapshot.cells[0]; + source.snapshot.snapshot.cells = Array.from({ length: 10000 }, (_, index) => ({ ...template, id: index ? `wide-${index}` : 'root', parentId: index ? 'root' : null, depth: index ? 1 : 0 })); + const index = new SwarmIndex(parseSwarm(swarm)), root = nodeKey(source, 'cell', 'root'), reached = new Set(); + for (let page = 0; page < Math.ceil(9999 / 128); page++) { + const visible = index.visible(root, page); assert.ok(visible.length <= 600); + for (const node of visible) if (node.cell?.id !== 'root' && node.cell) reached.add(node.cell.id); + } + assert.equal(reached.size, 9999); + assert.ok(index.search('wide-9999').some(selection => selection.id === 'wide-9999')); +}); +test('spatial positions survive heartbeat and recorded-status updates', () => { + const swarm = parseSwarm(swarmPreview()), before = new SwarmIndex(swarm); + swarm.sources[0].snapshot.snapshot.cells[1].heartbeat = new Date(Date.now() + 1000).toISOString(); + swarm.sources[0].snapshot.snapshot.tasks[0].status = 'cancelled'; + const after = new SwarmIndex(swarm); + for (const [key, node] of before.nodes) assert.deepEqual(after.nodes.get(key).point, node.point); +}); +test('observation freshness never renews worker evidence, and terminal states do not activate it', () => { + const swarm = parseSwarm(swarmPreview()), source = swarm.sources[0], now = Date.parse(source.snapshot.observedAt); + assert.equal(activities(source, now).get('cell-1'), 'recent'); + assert.equal(activities(source, now + 16000).get('cell-1'), 'uncertain'); + source.snapshot.observedAt = new Date(now + 61000).toISOString(); + assert.equal(activities(source, now + 61000).get('cell-1'), 'uncertain'); + source.snapshot.snapshot.tasks[0].status = 'completed'; + assert.equal(activities(source, now + 61000).get('cell-1'), 'idle'); + source.snapshot.snapshot.tasks[0].status = 'cancelled'; + assert.equal(activities(source, now + 61000).get('cell-1'), 'idle'); + source.snapshot.snapshot.tasks[0].status = 'running'; source.status = 'stale'; + assert.equal(activities(source, now).get('cell-1'), 'uncertain'); + source.status = 'observed'; source.snapshot.snapshot.control.status = 'paused'; + assert.equal(activities(source, now).get('cell-1'), 'uncertain'); +}); +test('consumer drops private extensions and fails closed on capabilities/identity/duplicate records', () => { + const input = swarmPreview(); input.token = 'private'; input.sources[0].origin = 'private'; input.sources[0].snapshot.snapshot.tasks[2].candidate.path = 'private'; input.sources[0].snapshot.snapshot.paidBudget = { token: 'private' }; + assert.equal(JSON.stringify(parseSwarm(input)).includes('private'), false); + input.sources[0].snapshot.capabilities.commands = true; assert.throws(() => parseSwarm(input)); + input.sources[0].snapshot.capabilities.commands = false; input.sources[0].snapshot.factoryId = 'wrong'; assert.throws(() => parseSwarm(input)); + input.sources[0].snapshot.factoryId = input.sources[0].factoryId; input.sources.push(input.sources[0]); assert.throws(() => parseSwarm(input)); +}); +test('unavailable source remains in the registry without invented cells; historical hashes are preserved', () => { + const input = swarmPreview(); input.sources[1].snapshot = null; input.sources[1].status = 'unavailable'; + const swarm = parseSwarm(input), index = new SwarmIndex(swarm); + assert.equal(index.sources.size, 3); + assert.equal([...index.nodes.values()].filter(node => node.source.id === input.sources[1].id).length, 1); + const task = swarm.sources[0].snapshot.snapshot.tasks.find(task => task.status === 'cancelled'); + assert.equal(task.review.candidateDigest, task.candidate.sha256); +}); diff --git a/vite.config.ts b/vite.config.ts index 1d0abe4..d22f250 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ input: { main: resolve(__dirname, 'index.html'), lobby: resolve(__dirname, 'lobby.html'), + swarm: resolve(__dirname, 'swarm.html'), }, }, }, From a3be79d2713d75e2c1c49dfdcd0e3b936170f46b Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:50:45 -0500 Subject: [PATCH 2/9] Implement Observatory design and qualify scoped swarm navigation --- docs/SWARM.md | 25 ++- src/swarm/main.ts | 70 +++++--- src/swarm/model.ts | 22 ++- src/swarm/observatory.css | 338 +++++++++++++++++++++++++------------- src/swarm/preview.ts | 2 +- src/swarm/renderer.ts | 248 +++++++++++++++++++++------- src/swarm/transport.ts | 15 ++ swarm.html | 54 +++++- test/swarm.test.mjs | 40 ++++- 9 files changed, 609 insertions(+), 205 deletions(-) create mode 100644 src/swarm/transport.ts diff --git a/docs/SWARM.md b/docs/SWARM.md index f12f14b..05582b8 100644 --- a/docs/SWARM.md +++ b/docs/SWARM.md @@ -17,15 +17,18 @@ subset. Search, breadcrumbs and 128-child pages navigate the complete index. Embedded hosts pass `?embed=1&channel=<16..128 character channel>` and exchange same-origin `brain-swarm/1` messages with the exact parent/frame: -- viewer → parent: `ready` or `select` with the channel and scoped selection; +- viewer → parent: `ready` or `select` with the channel and a selection naming + sourceId, factoryId, kind and id; - parent → viewer: `observe` with channel, increasing integer sequence and model; - parent → viewer: `ping`; viewer repeats `ready`. The model has schemaVersion 1, scope `operator-source-registry`, commands false, observedAt and at most 32 sources. Each source has id, label, factoryId, observed/stale/unavailable status, a public canonical FACTORY snapshot or null, -and optional metadata events. `model.ts` positively allowlists these facts; -private extensions are dropped and unsupported execution capability is rejected. +and optional metadata events. Hash links also name the exact factory; changing +the authority behind a registry alias clears its former selection. +`model.ts` positively allowlists these facts; private extensions are dropped +and unsupported execution capability is rejected. Never put a bearer credential in a URL, iframe message or client environment. A standalone deployment must supply its own authenticated same-origin JSON bridge @@ -56,6 +59,12 @@ uses the same index and scoped selection. Rendering diagnostics report CPU submission time separately from draw-call and frame counts; they do not prove GPU timing or physical worker health. +The Observatory design and first UI implementation were completed by Claude Opus +through a normal local FLUJO subscription-agent conversation at Medium effort +with Bash. The integration pass added camera fitting around visible instruments, +responsive reframing, measured label collision bounds, live reduced-motion +changes and a trailing diagnostics update when on-demand rendering settles. + Fresh observations do not establish worker activity. A cell needs an active controller, ready lifecycle, recent heartbeat, an unexpired running lease and no owned unknown outcome. Completed/cancelled operations do not animate as @@ -72,3 +81,13 @@ positive allowlists and missing-source behavior. Copy the built `dist` artifact to the consumer's ignored viewer directory for local integration; keep source in this repository. Qualify desktop/mobile composition and 3D/2D navigation in the browser against the built artifact before publishing the Brain image. + +The 2026-10-03 fixture qualification reached `cell-3333` in a 10,000-cell world +in both modes and preserved its source/factory identity after actual WebGL +context loss. The world had 10,003 indexed nodes; its overview drew 18 nodes and +the selected neighborhood drew 10, with two GPU draw calls in 3D and no GL calls +in 2D. Desktop, 615-pixel and 390-pixel layouts were checked. Reduced motion +stopped an already-running renderer; 2D settled without an animation loop. +These are fixture/navigation checks, not live FACTORY health evidence. The +read-only observer was still offline at the last 2026-10-03 qualification check; +its owner must restore and qualify it before a fresh live witness is recorded. diff --git a/src/swarm/main.ts b/src/swarm/main.ts index fcf074a..6d5ee1f 100644 --- a/src/swarm/main.ts +++ b/src/swarm/main.ts @@ -1,7 +1,8 @@ import './swarm.css'; import './observatory.css'; -import { activities, nodeKey, parseSwarm, SwarmIndex, type Node, type Selection, type Source, type Swarm } from './model'; -import { SwarmRenderer } from './renderer'; +import { activities, nodeKey, parseSwarm, selectionFragment, selectionFromFragment, SwarmIndex, type Node, type Selection, type Source, type Swarm } from './model'; +import { readSwarmJson } from './transport'; +import { SwarmRenderer, sourceColor } from './renderer'; import { swarmPreview } from './preview'; const $ = (id: string): HTMLElement => document.getElementById(id)!; @@ -18,10 +19,7 @@ let swarm = empty(), index = new SwarmIndex(swarm), selection: Selection | null let renderer: SwarmRenderer; let mode: '3d' | '2d' = query.get('view') === '2d' || ((navigator.hardwareConcurrency ?? 8) <= 4) ? '2d' : '3d'; let lastActivity = '', follow = false, firstObservation = true; -const selectionHash = (): Selection | null => { - const hash = new URLSearchParams(location.hash.slice(1)), sourceId = hash.get('source'), kind = hash.get('kind'), id = hash.get('id'); - return sourceId && id && ['source', 'cell', 'task', 'effect'].includes(kind ?? '') ? { sourceId, kind: kind as Selection['kind'], id } : null; -}; +const selectionHash = (): Selection | null => selectionFromFragment(swarm, location.hash); function validSelection(value: Selection): boolean { const source = index.sources.get(value.sourceId); if (!source) return false; @@ -29,12 +27,16 @@ function validSelection(value: Selection): boolean { } function select(next: Selection | null, move = true): void { if (next && !validSelection(next)) return; + const previousNode = focusNode()?.key; selection = next; page = 0; document.body.classList.toggle('has-selection', !!next); - const hash = next ? new URLSearchParams({ source: next.sourceId, kind: next.kind, id: next.id }).toString() : ''; + const source = next ? index.sources.get(next.sourceId)! : null; + // The selected authority tints the instruments with its deterministic hue. + document.body.style.setProperty('--accent', source ? sourceColor(source.id) : '#7fd1de'); + const hash = next && source ? selectionFragment(source, next) : ''; history.replaceState(null, '', `${location.pathname}${location.search}${hash ? `#${hash}` : ''}`); - redraw(); if (move) frameSelection(); - if (embedded) window.parent.postMessage({ protocol: 'brain-swarm/1', type: 'select', channel, selection: next }, location.origin); + redraw(); if (move && (!next || !['task', 'effect'].includes(next.kind) || focusNode()?.key !== previousNode)) frameSelection(); + if (embedded) window.parent.postMessage({ protocol: 'brain-swarm/1', type: 'select', channel, selection: next && source ? { ...next, factoryId: source.factoryId } : null }, location.origin); } function sourceStatus(source: Source): string { const age = source.snapshot ? Date.now() - Date.parse(source.snapshot.observedAt) : Infinity; @@ -47,18 +49,26 @@ function frameSelection(): void { else renderer.frame(node?.point); } +let statsTimer: ReturnType | undefined; function createRenderer(): void { + clearTimeout(statsTimer); + lastStats = 0; renderer?.dispose(); const canvas = element('canvas'); canvas.id = 'swarm-canvas'; canvas.tabIndex = 0; canvas.setAttribute('aria-label', 'Spatial swarm. Use navigation buttons to select cells; Home fits the whole swarm.'); $('swarm-canvas').replaceWith(canvas); renderer = new SwarmRenderer(canvas, $('swarm-labels'), mode); mode = renderer.mode; + document.body.dataset.view = mode; (document.getElementById('swarm-mode') as HTMLSelectElement).value = mode; renderer.onPick = node => select(node.selection); renderer.onStats = stats => { const diagnostics = $('swarm-diagnostics'); Object.assign(diagnostics.dataset, { mode: stats.mode, visible: String(stats.visible), indexed: String(index.nodes.size), labels: String(stats.labels), draws: String(stats.draws), frames: String(stats.submissions), cpuMs: stats.cpuMs.toFixed(3) }); - if (performance.now() - lastStats < 500) return; lastStats = performance.now(); - diagnostics.textContent = `${stats.mode.toUpperCase()} · ${stats.visible} drawn / ${index.nodes.size} indexed nodes · ${stats.labels} labels · ${stats.draws} GL draw calls · ${stats.cpuMs.toFixed(2)} ms CPU submission · ${stats.submissions} frames`; + const text = `${stats.mode.toUpperCase()} · ${stats.visible} drawn / ${index.nodes.size} indexed nodes · ${stats.labels} labels · ${stats.draws} GL draw calls · ${stats.cpuMs.toFixed(2)} ms CPU submission · ${stats.submissions} frames`; + const publish = () => { statsTimer = undefined; lastStats = performance.now(); diagnostics.textContent = text; }; + clearTimeout(statsTimer); + const remaining = 500 - (performance.now() - lastStats); + if (lastStats && remaining > 0) statsTimer = setTimeout(publish, remaining); + else publish(); }; redraw(); frameSelection(); } @@ -67,16 +77,26 @@ function paintNavigation(): void { const sourceList = $('source-list'); sourceList.replaceChildren(); for (const source of index.sources.values()) { const item = button(source.label, () => select({ sourceId: source.id, kind: 'source', id: source.id }), selection?.sourceId === source.id); - item.className = `source-record source-${sourceStatus(source)}`; - item.append(element('small', `${sourceStatus(source)} · ${source.snapshot?.snapshot.cells.length ?? 0} cells`)); item.title = source.factoryId; sourceList.append(item); + const status = sourceStatus(source), cells = source.snapshot?.snapshot.cells ?? []; + const evidenced = cells.reduce((sum, cell) => sum + (index.nodes.get(nodeKey(source, 'cell', cell.id))?.activity === 'recent' ? 1 : 0), 0); + item.className = `source-record source-${status}`; + // Exactly the renderer's deterministic plate colour, never a list position. + item.style.setProperty('--accent', sourceColor(source.id)); + item.dataset.status = status; + item.replaceChildren(element('span', undefined, 'source-dot'), element('strong', source.label, 'source-name'), + element('small', `${status} · ${cells.length} cells${evidenced ? ` · ${evidenced} evidenced` : ''}`)); + item.title = source.factoryId; sourceList.append(item); } const focus = focusNode(), children = focus ? index.children.get(focus.key) ?? [] : []; - $('branch-heading').textContent = focus ? `${focus.label} · ${children.length} children` : 'Select a source to explore its delegation'; + $('branch-heading').textContent = focus ? `DELEGATION · ${focus.label} · ${children.length} children` : 'DELEGATION · select a source'; $('branch-list').replaceChildren(); $('branch-pages').replaceChildren(); for (const key of children.slice(page * 128, (page + 1) * 128)) { const node = index.nodes.get(key)!; const item = button(node.label, () => select(node.selection), key === focus?.key); - item.append(element('small', `${node.cell?.role ?? 'source'} · ${node.cell?.status ?? sourceStatus(node.source)} · ${index.children.get(key)?.length ?? 0} children`)); + item.className = `branch-record activity-${node.activity}`; + item.style.setProperty('--accent', sourceColor(node.source.id)); + item.replaceChildren(element('span', undefined, 'branch-dot'), element('strong', node.label, 'branch-name'), + element('small', `${node.cell?.role ?? 'source'} · ${node.cell?.status ?? sourceStatus(node.source)} · ${index.children.get(key)?.length ?? 0} children`)); $('branch-list').append(item); } if (children.length > 128) { @@ -106,9 +126,12 @@ function evidenceList(container: HTMLElement, pairs: Array<[string, string]>): v } function paintInspector(): void { const container = $('swarm-inspect'); container.replaceChildren(); - if (!selection) { container.append(element('h2', 'A constellation of work.'), element('p', 'Move from registered authorities to their coordinators and delegated cells. Search reaches every recorded cell, task and external effect.'), element('p', 'This view follows observations. It cannot start, stop or resume workers.'), element('p', 'Select a source to inspect its provenance and recorded work.')); return; } + container.style.removeProperty('--accent'); + if (!selection) { container.append(element('p', 'THE OBSERVATORY', 'evidence-kicker'), element('h2', 'A constellation of work.'), element('p', 'Move from registered authorities to their coordinators and delegated cells. Search reaches every recorded cell, task and external effect.'), element('p', 'This view follows observations. It cannot start, stop or resume workers.'), element('p', 'Select a source to inspect its provenance and recorded work.')); return; } const source = index.sources.get(selection.sourceId)!; - container.append(element('div', `${sourceStatus(source)} · ${source.label}`, 'source-state'), element('h2', selection.kind === 'source' ? source.label : selection.id)); + container.style.setProperty('--accent', sourceColor(source.id)); + container.append(element('p', selection.kind === 'source' ? 'REGISTERED AUTHORITY' : `RECORDED ${selection.kind.toUpperCase()}`, 'evidence-kicker'), + element('div', `${sourceStatus(source)} · ${source.label}`, 'source-state'), element('h2', selection.kind === 'source' ? source.label : selection.id)); evidenceList(container, [['Factory identity', source.factoryId], ['Registry source', source.id]]); const envelope = source.snapshot; if (!envelope) { container.append(element('p', 'This registered source has no available observation. No cells or execution state are inferred.', 'evidence-note')); return; } @@ -167,6 +190,9 @@ function redraw(): void { renderer?.setNodes(visible, focus?.key ?? null); document.body.classList.toggle('has-selection', !!selection); paintNavigation(); paintInspector(); paintTracking(); + document.body.classList.toggle('field-empty', !swarm.sources.length); + const accented = selection ? index.sources.get(selection.sourceId) : null; + document.body.style.setProperty('--accent', accented ? sourceColor(accented.id) : '#7fd1de'); const cells = swarm.sources.reduce((sum, source) => sum + (source.snapshot?.snapshot.cells.length ?? 0), 0), tasks = swarm.sources.reduce((sum, source) => sum + (source.snapshot?.snapshot.tasks.length ?? 0), 0); $('swarm-counts').textContent = `${swarm.sources.length} registered sources · ${cells.toLocaleString()} recorded cells · ${tasks.toLocaleString()} tasks`; const stale = swarm.sources.filter(source => sourceStatus(source) !== 'observed').length; @@ -184,7 +210,7 @@ function observe(value: unknown): void { return source; }); swarm = next; index = new SwarmIndex(next); - if (selection && !validSelection(selection)) selection = null; + if (selection && (previous.get(selection.sourceId)?.factoryId !== index.sources.get(selection.sourceId)?.factoryId || !validSelection(selection))) select(null, false); if (firstObservation) { const wanted = selectionHash(); if (wanted && validSelection(wanted)) selection = wanted; firstObservation = false; } const active = [...index.nodes.values()].filter(node => node.activity === 'recent').map(node => node.key).sort().join('|'); const changedActivity = active && active !== lastActivity; lastActivity = active; @@ -199,7 +225,7 @@ createRenderer(); ($('swarm-follow') as HTMLInputElement).onchange = event => { follow = (event.target as HTMLInputElement).checked; }; $('swarm-home').onclick = () => select(null); $('swarm-close').onclick = () => select(null, false); -$('swarm-explore').onclick = () => { const hidden = document.body.classList.toggle('navigation-hidden'); $('swarm-explore').setAttribute('aria-expanded', String(!hidden)); }; +$('swarm-explore').onclick = () => { const hidden = document.body.classList.toggle('navigation-hidden'); $('swarm-explore').setAttribute('aria-expanded', String(!hidden)); frameSelection(); }; $('swarm-find').onclick = () => { document.body.classList.remove('navigation-hidden'); document.body.classList.add('search-open'); $('swarm-search').focus(); }; window.addEventListener('keydown', event => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) return; @@ -261,8 +287,8 @@ if (embedded) { const response = await fetch(path.pathname, { cache: 'no-store', signal: controller.signal, credentials: 'same-origin' }); if ([401, 403, 404].includes(response.status)) { observe(empty()); throw new Error('Observation access unavailable'); } if (!response.ok) throw new Error('Swarm bridge unavailable'); - const body = await response.text(); if (body.length > 32 * 1024 * 1024) throw new Error('Swarm observation exceeds its bound'); - if (!stopped) observe(JSON.parse(body)); + const body = await readSwarmJson(response); + if (!stopped) observe(body); } catch { if (!stopped) { swarm.sources = swarm.sources.map(source => ({ ...source, status: source.snapshot ? 'stale' : 'unavailable' })); index = new SwarmIndex(swarm); redraw(); $('swarm-status').textContent = 'Observation unavailable · retained facts are stale'; } } finally { clearTimeout(deadline); if (!stopped) timer = setTimeout(() => void poll(), 5000); } @@ -271,4 +297,4 @@ if (embedded) { } window.addEventListener('pagehide', () => { stopped = true; controller?.abort(); clearTimeout(timer); }); } -window.addEventListener('pagehide', () => { clearInterval(expiryTimer); renderer.dispose(); }); +window.addEventListener('pagehide', () => { clearInterval(expiryTimer); clearTimeout(statsTimer); renderer.dispose(); }); diff --git a/src/swarm/model.ts b/src/swarm/model.ts index a2edccc..0639640 100644 --- a/src/swarm/model.ts +++ b/src/swarm/model.ts @@ -75,6 +75,18 @@ export function parseSwarm(value: unknown): Swarm { export const nodeKey = (source: Pick, kind: string, localId: string) => JSON.stringify([source.id, source.factoryId, kind, localId]); export const selectionKey = (source: Source, selection: Selection) => nodeKey(source, selection.kind, selection.id); +export const selectionFragment = (source: Pick, selection: Selection): string => new URLSearchParams({ source: source.id, factory: source.factoryId, kind: selection.kind, id: selection.id }).toString(); +/** A saved link must name the authority, not just a reusable registry alias. */ +export function selectionFromFragment(swarm: Swarm, fragment: string): Selection | null { + const params = new URLSearchParams(fragment.replace(/^#/, '')); + if (['source', 'factory', 'kind', 'id'].some(key => params.getAll(key).length !== 1)) return null; + const source = swarm.sources.find(source => source.id === params.get('source')); + if (!source || source.factoryId !== params.get('factory')) return null; + const kind = params.get('kind'), localId = params.get('id')!; + const state = source.snapshot?.snapshot; + const valid = kind === 'source' ? localId === source.id : kind === 'cell' ? state?.cells.some(cell => cell.id === localId) : kind === 'task' ? state?.tasks.some(task => task.id === localId) : kind === 'effect' && state?.effects.some(effect => effect.key === localId); + return valid ? { sourceId: source.id, kind: kind as Selection['kind'], id: localId } : null; +} const hash = (value: string) => { let result = 2166136261; for (let i = 0; i < value.length; i++) result = Math.imul(result ^ value.charCodeAt(i), 16777619); return (result >>> 0) / 4294967296; }; /** O(cells + tasks + effects), including expiry independent of read time. */ @@ -146,13 +158,17 @@ export class SwarmIndex { if (focus) { for (const node of this.ancestors(focus).slice(-32)) keys.add(node.key); const nearby = this.children.get(focus) ?? []; - for (const key of nearby.slice(page * 128, (page + 1) * 128)) keys.add(key); + const pageKeys = nearby.slice(page * 128, (page + 1) * 128); + for (const key of pageKeys) keys.add(key); + if (!this.nodes.get(focus)?.cell) for (const root of pageKeys) for (const key of (this.children.get(root) ?? []).slice(0, 12)) keys.add(key); const parent = this.nodes.get(focus)?.parent; if (parent) for (const key of (this.children.get(parent) ?? []).slice(0, 128)) keys.add(key); } else { for (const source of this.sources.values()) { - const root = nodeKey(source, 'cell', 'root'); if (this.nodes.has(root)) keys.add(root); - for (const key of (this.children.get(root) ?? []).slice(0, 12)) keys.add(key); + for (const root of (this.children.get(nodeKey(source, 'source', source.id)) ?? []).slice(0, 128)) { + keys.add(root); + for (const key of (this.children.get(root) ?? []).slice(0, 12)) keys.add(key); + } } } return [...keys].slice(0, limit).map(key => this.nodes.get(key)!).filter(Boolean); diff --git a/src/swarm/observatory.css b/src/swarm/observatory.css index f5c2125..4a6b438 100644 --- a/src/swarm/observatory.css +++ b/src/swarm/observatory.css @@ -1,126 +1,242 @@ -/* Observatory art direction: source plates and a wide field, with instruments - * floating over it. All motion and semantic colouring still follow evidence. */ +/* THE OBSERVATORY — art direction for the FACTORY swarm viewer. + * A single deep field holds every authority; instruments float over it on + * hairline glass plates. Motion, colour and emphasis still follow evidence: + * source hues come from the renderer's deterministic palette, amber means + * uncertain, grey means retired/stale, and nothing animates without a lease. */ :root { - --ink:#0a0e14; --panel:#10151d; --rule:#26313d; --text:#e8edf4; - --muted:#a7b2c2; --quiet:#7d8798; --signal:#7fd1de; + --void:#04060b; --ink:#070a11; --deep:#0a0f18; + --plate:rgba(11,16,25,.82); --plate-solid:#0b1019; + --rule:rgba(150,180,210,.14); --rule-strong:rgba(160,196,226,.28); + --text:#e9eef7; --muted:#a6b3c7; --quiet:#73829a; --faint:#5a677b; + --signal:#7fd1de; --accent:#7fd1de; --warn:#e0a85c; --serif:ui-serif,'Iowan Old Style','Palatino Linotype',Palatino,Georgia,serif; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - background:var(--ink); color:var(--text); + --kicker:9px/1.1 var(--mono); + --gap:22px; + background:var(--void); color:var(--text); } -body{background:var(--ink)} -button,input,select{border-radius:2px} -button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--signal);outline-offset:2px} -button{background:transparent;border-color:var(--rule)} -button:hover{background:#172431;border-color:#506675} -.swarm-top{height:64px;padding:0 24px;border-color:var(--rule);background:#0c1017;gap:20px} -.swarm-brand{font-family:var(--serif);font-size:21px;letter-spacing:-.04em} -.swarm-brand b{color:#b9d8e7;font-weight:400} -.swarm-top button{font-size:11px;padding:6px 9px;color:var(--muted)} -#swarm-status{font-family:var(--mono);font-size:10px;color:var(--muted);text-align:right} -.read-only{font-family:var(--mono);font-size:9px;letter-spacing:.08em;color:var(--quiet);border-color:var(--rule);border-radius:2px} -.swarm-shell{display:block;position:relative;height:calc(100vh - 108px);min-height:640px} -.swarm-space{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 48%,#1a273940,transparent 58%),radial-gradient(ellipse at 72% 55%,#252b4233,transparent 45%)} -#swarm-stage{height:100%;min-height:0} -.space-toolbar{position:absolute;top:18px;left:310px;right:24px;z-index:8;border:0;padding:0;gap:14px;align-items:flex-start} -.has-selection .space-toolbar{right:382px} -.space-controls{font-size:10px;gap:10px;color:var(--muted)} -.space-controls button,.space-controls select{font-size:10px;padding:5px 7px} -.follow-control{color:var(--quiet);font-size:10px} -#swarm-path{font-family:var(--mono);font-size:10px} -#swarm-path button{font-size:10px;padding:4px 7px;border-color:transparent;max-width:140px} -#swarm-path button[aria-pressed=true]{color:var(--signal);background:transparent;border-bottom-color:var(--signal)} -.swarm-navigation{position:absolute;z-index:10;left:20px;top:18px;bottom:76px;width:266px;border:1px solid var(--rule);padding:20px 17px;background:#10151de8;overflow:auto} +body{margin:0;background:var(--void);color:var(--text);overflow-x:hidden} +#swarm-app{position:relative;isolation:isolate} +button,input,select{border-radius:3px;font-family:inherit} +button:focus-visible,input:focus-visible,select:focus-visible,canvas:focus-visible,summary:focus-visible{outline:1px solid var(--accent);outline-offset:3px} +button{background:transparent;border:1px solid var(--rule);color:var(--muted);transition:color .14s,border-color .14s,background .14s} +button:hover{background:rgba(127,209,222,.07);border-color:var(--rule-strong);color:var(--text)} +button[aria-pressed=true]{color:var(--text);border-color:var(--accent);background:rgba(127,209,222,.09)} +kbd{font:9px var(--mono);border:1px solid var(--rule);border-radius:2px;padding:1px 3px;color:var(--faint);margin-left:5px} + +/* ── Masthead ───────────────────────────────────────────────────────────── */ +.swarm-top{position:relative;z-index:20;height:66px;padding:0 26px;gap:18px;align-items:center; + border-bottom:1px solid var(--rule);background:linear-gradient(#0a0e16,#070a11)} +.swarm-brand{display:flex;align-items:center;gap:11px;font:400 21px/1 var(--serif);letter-spacing:-.035em;color:#e4ecf6;white-space:nowrap} +.swarm-brand b{color:#b7d9e8;font-weight:400} +.brand-mark{width:13px;height:13px;border-radius:50%;flex:0 0 auto; + background:radial-gradient(circle at 34% 32%,#eafaff,#7fd1de 46%,#2a5c6a 78%,#0a1016); + box-shadow:0 0 0 1px rgba(127,209,222,.4),0 0 16px rgba(127,209,222,.38)} +.swarm-top button{font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:7px 11px} +#swarm-status{flex:1;font:10px/1.5 var(--mono);color:var(--muted);text-align:right;letter-spacing:.02em} +.read-only{font:9px var(--mono);letter-spacing:.14em;color:var(--quiet);border:1px solid var(--rule);border-radius:2px;padding:4px 7px;white-space:nowrap} + +/* ── The field ──────────────────────────────────────────────────────────── */ +.swarm-shell{display:block;position:relative;height:calc(100vh - 112px);min-height:660px} +.swarm-space{position:absolute;inset:0;overflow:hidden;background: + radial-gradient(130% 92% at 50% 128%,#12243390 0%,transparent 62%), + radial-gradient(90% 70% at 74% 18%,#1b1f3c66 0%,transparent 58%), + radial-gradient(80% 60% at 16% 30%,#10303a55 0%,transparent 60%), + linear-gradient(#060911,#04060b)} +.field-backdrop{position:absolute;inset:0;z-index:0;pointer-events:none} +.field-backdrop>span{position:absolute;inset:0;display:block} +.field-nebula{background: + conic-gradient(from 210deg at 46% 54%,#0e3a4733,#1d2a5a2e,#3a1f452b,#0e3a4733); + filter:blur(70px);opacity:.55} +.field-dust{opacity:.5;background: + radial-gradient(1px 1px at 12% 18%,#cfe9f2aa,transparent 60%), + radial-gradient(1px 1px at 72% 12%,#bcd6ff99,transparent 60%), + radial-gradient(1px 1px at 38% 74%,#e4d8ff88,transparent 60%), + radial-gradient(1px 1px at 86% 62%,#cfe9f299,transparent 60%), + repeating-radial-gradient(circle at 20% 30%,#9fc4d41a 0 1px,transparent 1px 90px), + repeating-radial-gradient(circle at 78% 68%,#aab8e018 0 1px,transparent 1px 124px)} +.field-horizon{top:auto;bottom:16%;height:1px;background:linear-gradient(90deg,transparent,#5e8fa233 18%,#9fd3e155 50%,#5e8fa233 82%,transparent);opacity:.5} +.field-vignette{background:radial-gradient(120% 100% at 50% 46%,transparent 38%,#04060bcc 100%)} +#swarm-stage{position:absolute;inset:0;z-index:1;height:100%;min-height:0;overflow:hidden} +#swarm-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;background:transparent} +#swarm-labels{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:2} + +/* ── Shared glass plate ─────────────────────────────────────────────────── */ +.swarm-navigation,.swarm-inspector,.swarm-timeline,.swarm-diagnostics,.space-toolbar>.space-controls,#swarm-path{ + background:var(--plate);backdrop-filter:blur(14px) saturate(1.1);-webkit-backdrop-filter:blur(14px) saturate(1.1); + border:1px solid var(--rule);box-shadow:0 24px 60px -30px #000c,inset 0 1px 0 rgba(190,220,240,.07)} +.section-title{font:var(--kicker);letter-spacing:.17em;color:var(--quiet);text-transform:uppercase} + +/* ── Atlas rail ─────────────────────────────────────────────────────────── */ +.swarm-navigation{position:absolute;z-index:12;left:22px;top:22px;bottom:86px;width:294px;padding:22px 20px;overflow:auto;scrollbar-width:thin} .navigation-hidden .swarm-navigation{display:none} -.navigation-hidden .space-toolbar{left:24px} -.navigation-hidden .space-caption{left:32px} -.section-title{font:9px var(--mono);letter-spacing:.15em;color:var(--quiet);margin-bottom:18px} -.search-label{font-size:10px;color:var(--muted)} -input[type=search]{font-size:11px;background:#0b1119;border-color:#2c3645;padding:10px;color:var(--text)} -#source-list{margin:22px 0} -#source-list button{font:15px var(--serif);padding:11px 8px 10px 12px;border:0;border-left:2px solid #7fd1de;border-bottom:1px solid var(--rule);margin:0;border-radius:0;background:transparent} -#source-list button:nth-child(2){border-left-color:#9bb8e8} -#source-list button:nth-child(3){border-left-color:#c2a7e8} -#source-list button:nth-child(4){border-left-color:#e8a7b8} -#source-list button:nth-child(5){border-left-color:#e8c98a} -#source-list button small{font:10px var(--mono);color:var(--quiet);margin-top:5px} -#source-list button[aria-pressed=true]{background:#1a2936;color:#e8f7fb} -#source-list button.source-stale,#source-list button.source-unavailable{background:repeating-linear-gradient(45deg,transparent 0 4px,#a7b2c208 4px 5px);color:var(--muted)} -#branch-list{max-height:36vh} -#branch-list button{font-size:11px;border:0;border-bottom:1px solid #202b35;border-radius:0;background:transparent;padding:9px 6px;margin:0} -#branch-list button small{font:9px var(--mono);color:var(--quiet)} -#branch-pages{font:10px var(--mono);color:var(--quiet)} -#branch-pages button{font-size:10px;margin-top:8px} -#search-results button{border:0;border-bottom:1px solid var(--rule);font-size:11px;border-radius:0;padding:8px 4px} -.scope-note{font-size:10px;color:var(--quiet);border-top:1px dotted #33414c;padding-top:14px;line-height:1.6} -.space-caption{left:320px;bottom:92px;color:var(--muted);font:11px var(--mono)} -.observatory-title{font:38px var(--serif);letter-spacing:-.025em;color:#dee8f2;margin-bottom:10px} -.has-selection .observatory-title{font-size:27px} -.space-caption p{font:10px var(--mono);color:var(--quiet);margin-top:8px} -.swarm-label{font:10px var(--mono);color:#97adbb;text-shadow:0 1px 4px #050a10} -.swarm-label.source-label{font:17px var(--serif);letter-spacing:.015em;color:#c7e0e7} -.swarm-label.selected{color:#eafcff} -.swarm-inspector{display:none;position:absolute;z-index:12;right:20px;top:76px;bottom:76px;width:340px;padding:22px;background:#10151df2;border:1px solid var(--rule);overflow:auto} +.swarm-navigation>.section-title:first-child{display:block;margin-bottom:16px;color:var(--muted)} +.search-label{display:grid;gap:8px;font:10px var(--mono);letter-spacing:.08em;color:var(--quiet);text-transform:uppercase} +input[type=search]{width:100%;font:12px var(--mono);background:#060a11;border:1px solid var(--rule);padding:10px 11px;color:var(--text)} +input[type=search]::placeholder{color:#4e5b6d} +#search-results{max-height:30vh;overflow:auto;margin-top:12px} +#search-results p{font:10px/1.6 var(--mono);color:var(--quiet);margin:0 0 6px} +#search-results button{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid rgba(150,180,210,.08);border-radius:0;font:11px var(--mono);padding:9px 2px;margin:0;color:var(--muted);overflow-wrap:anywhere} +.rail-heading{display:flex;align-items:center;gap:10px;margin:26px 0 12px} +.rail-heading .section-title{margin:0} +.rail-heading::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,var(--rule),transparent)} +#source-list{margin:0 0 6px;display:grid} +#source-list button{display:grid;grid-template-columns:auto minmax(0,1fr);grid-template-rows:auto auto;column-gap:11px;align-items:center; + width:100%;text-align:left;margin:0;padding:13px 10px 12px 12px;border:0;border-bottom:1px solid rgba(150,180,210,.08);border-radius:0;background:transparent;position:relative} +#source-list button::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:.55} +#source-list button:hover::before,#source-list button[aria-pressed=true]::before{opacity:1;box-shadow:0 0 12px var(--accent)} +#source-list button[aria-pressed=true]{background:linear-gradient(90deg,rgba(127,209,222,.1),transparent 72%)} +.source-dot{grid-row:1/3;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 1px #04060b,0 0 10px var(--accent)} +.source-name{font:400 16px/1.25 var(--serif);letter-spacing:-.015em;color:#e6eef7;overflow-wrap:anywhere} +#source-list button small{grid-column:2;font:9px var(--mono);letter-spacing:.08em;color:var(--quiet);margin-top:4px;text-transform:uppercase} +#source-list button.source-stale .source-dot,#source-list button.source-unavailable .source-dot{background:#6b7789;box-shadow:none} +#source-list button.source-stale,#source-list button.source-unavailable{color:var(--muted)} +#source-list button.source-stale .source-name,#source-list button.source-unavailable .source-name{color:#b0bccb} +#source-list button.source-unavailable::before{background:repeating-linear-gradient(180deg,#6b7789 0 3px,transparent 3px 7px);opacity:.8} +#branch-heading{margin:22px 0 10px;color:var(--muted)} +#branch-list{max-height:30vh;overflow:auto;display:grid} +#branch-list button{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:9px;align-items:baseline;width:100%;text-align:left; + border:0;border-bottom:1px solid rgba(150,180,210,.07);border-radius:0;background:transparent;padding:9px 4px;margin:0} +.branch-dot{width:5px;height:5px;border-radius:50%;background:#55637a;transform:translateY(-2px)} +.activity-recent .branch-dot{background:var(--accent);box-shadow:0 0 8px var(--accent)} +.activity-uncertain .branch-dot{background:var(--warn);box-shadow:0 0 8px rgba(224,168,92,.5)} +.branch-name{font:11px var(--mono);color:#cdd9e8;overflow-wrap:anywhere} +#branch-list button small{grid-column:2;display:block;font:9px var(--mono);color:var(--quiet);margin-top:4px} +#branch-list button[aria-pressed=true] .branch-name{color:#eafcff} +#branch-pages{font:9px/1.7 var(--mono);color:var(--quiet);margin-top:10px} +#branch-pages button{font:9px var(--mono);letter-spacing:.08em;padding:6px 8px;margin:6px 6px 0 0;text-transform:uppercase} +#branch-pages p{margin:8px 0 0} +.scope-note{font:10px/1.75 var(--mono);color:var(--faint);border-top:1px solid var(--rule);padding-top:15px;margin-top:20px} + +/* ── Sextant bar: path + view controls ──────────────────────────────────── */ +.space-toolbar{position:absolute;z-index:14;top:22px;left:338px;right:26px;border:0;padding:0;display:flex;gap:14px;align-items:flex-start;justify-content:space-between;pointer-events:none} +.has-selection .space-toolbar{right:422px} +.space-toolbar>*{pointer-events:auto} +#swarm-path{display:flex;flex-wrap:wrap;align-items:center;gap:3px;font:10px var(--mono);padding:5px 9px;border-radius:999px;max-width:min(560px,56%)} +#swarm-path button{font:10px var(--mono);padding:4px 8px;border:0;border-radius:999px;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--quiet)} +#swarm-path button:hover{color:var(--text);background:rgba(127,209,222,.08)} +#swarm-path button[aria-pressed=true]{color:#eafcff;background:rgba(127,209,222,.13);border:0} +#swarm-path span{color:#45505f;font-size:9px;padding:0 1px} +.space-controls{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:999px;font:10px var(--mono);color:var(--muted)} +.space-controls button{font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:6px 9px;border-radius:999px} +.control-field,.follow-control{display:flex;align-items:center;gap:6px;font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--quiet)} +.space-controls select{font:10px var(--mono);padding:5px 6px;background:#070b12;border:1px solid var(--rule);color:var(--muted)} +.follow-control input{accent-color:var(--accent);width:12px;height:12px} + +/* ── Caption: the display voice of the field ────────────────────────────── */ +.space-caption{position:absolute;z-index:6;left:340px;bottom:96px;max-width:min(430px,38vw);pointer-events:none;color:var(--muted)} +.navigation-hidden .space-caption{left:34px} +.navigation-hidden .space-toolbar{left:26px} +.caption-kicker{font:var(--kicker);letter-spacing:.24em;color:var(--faint);margin:0 0 12px;text-transform:uppercase} +.observatory-title{font:400 clamp(30px,3.6vw,50px)/1.02 var(--serif);letter-spacing:-.035em;color:#eaf2fa;margin:0 0 12px; + text-shadow:0 2px 34px #04060b,0 0 70px rgba(127,209,222,.12)} +.has-selection .observatory-title{font-size:clamp(24px,2.3vw,32px)} +#swarm-counts{display:block;font:10px/1.7 var(--mono);letter-spacing:.06em;color:#9fb0c5} +.space-caption p{font:10px/1.75 var(--mono);color:var(--faint);margin:8px 0 0;max-width:36ch} +.field-legend{display:flex;flex-wrap:wrap;gap:6px 16px;list-style:none;margin:16px 0 0;padding:14px 0 0;border-top:1px solid var(--rule);font:9px var(--mono);letter-spacing:.07em;color:var(--quiet);text-transform:uppercase} +.field-legend li{display:flex;align-items:center;gap:7px} +.field-legend i{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 9px var(--accent)} +.legend-uncertain i{background:var(--warn);box-shadow:0 0 9px rgba(224,168,92,.55)} +.legend-quiet i{background:#6b7789;box-shadow:none} +.legend-plate i{background:transparent;border:1px solid var(--accent);box-shadow:0 0 9px rgba(127,209,222,.25)} +.has-selection .field-legend{display:none} + +/* ── Field labels ───────────────────────────────────────────────────────── */ +.swarm-label{position:absolute;top:0;left:0;font:10px var(--mono);letter-spacing:.04em;color:#a2b6c7;white-space:nowrap;max-width:168px;overflow:hidden;text-overflow:ellipsis; + padding:2px 6px;border-radius:2px;background:rgba(5,8,14,.42);text-shadow:0 1px 6px #04060b} +.swarm-label.active{color:#d6f4fb;box-shadow:inset 0 0 0 1px rgba(127,209,222,.22)} +.swarm-label.source-label{display:flex;flex-direction:column;align-items:center;gap:2px;font:400 18px/1.1 var(--serif);letter-spacing:-.01em;color:#dcecf4;background:transparent;padding:0;max-width:220px} +.swarm-label.source-label i{font:9px var(--mono);font-style:normal;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);opacity:.75} +.swarm-label.selected{color:#f1fdff;background:rgba(127,209,222,.12);box-shadow:inset 0 0 0 1px rgba(127,209,222,.4)} + +/* ── Evidence sheet ─────────────────────────────────────────────────────── */ +.swarm-inspector{display:none;position:absolute;z-index:16;right:22px;top:22px;bottom:86px;width:380px;padding:24px 24px 30px;overflow:auto;scrollbar-width:thin} +.swarm-inspector::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:linear-gradient(180deg,var(--accent),transparent 72%);opacity:.8} .has-selection .swarm-inspector{display:block} -.swarm-inspector>.section-title{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--rule);padding-bottom:14px} -#swarm-close{font-size:18px;padding:0 5px;color:var(--quiet);border:0} -.swarm-inspector h2{font:26px/1.15 var(--serif);letter-spacing:-.025em;margin:18px 0} -.swarm-inspector h3{font:15px var(--serif);color:#d3e5e9;border-top:1px solid var(--rule);padding-top:18px;margin-top:26px} -.swarm-inspector p{font-size:12px;color:var(--muted);line-height:1.65} -.swarm-inspector dl{display:block;font:11px/1.5 var(--mono)} -.swarm-inspector dt{color:var(--quiet);padding-top:12px;margin:0;border-top:1px solid #25303a} -.swarm-inspector dd{margin:3px 0 12px;color:#d0dae5} -.swarm-inspector button:not(#swarm-close){display:block;width:100%;text-align:left;font-size:11px;border:0;border-bottom:1px solid var(--rule);border-radius:0;padding:10px 0;margin:0;color:#bbd9df} -.source-state{font:9px var(--mono);letter-spacing:.1em;color:var(--signal);line-height:1.6} -.evidence-note{border:0!important;border-left:2px solid #d7a86b!important;background:#2b251d60!important;border-radius:0;padding:11px 13px;color:#d4bc96!important} -.swarm-timeline{position:absolute;bottom:20px;left:310px;z-index:14;width:min(490px,calc(100% - 710px));padding:14px 16px;border:1px solid var(--rule);background:#10151dec} -.swarm-timeline summary{margin:0;cursor:pointer;font:9px var(--mono);letter-spacing:.12em;color:var(--muted)} -.swarm-timeline summary span{font-size:9px;color:var(--quiet);letter-spacing:0} -#tracking-list{font-family:var(--mono);font-size:10px;max-height:180px;padding-top:10px} -.swarm-measurements{position:absolute;right:20px;bottom:27px;z-index:16;font:9px var(--mono);color:var(--quiet);max-width:300px;text-align:right} -.swarm-measurements summary{cursor:pointer} -.swarm-diagnostics{font:9px/1.8 var(--mono);border:1px solid var(--rule);background:#10151df2;margin-top:10px;padding:10px;color:var(--muted);text-align:left} -.sample-notice{color:#dfba80!important} -footer{height:44px;position:relative;border-color:var(--rule);font:9px/1.65 var(--mono);color:var(--quiet);padding:14px 24px;background:#0c1017} +.swarm-inspector>.section-title{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--rule);padding-bottom:14px;margin-bottom:20px;color:var(--muted)} +#swarm-close{font-size:17px;line-height:1;padding:2px 6px;color:var(--quiet);border:0} +.evidence-kicker{font:var(--kicker)!important;letter-spacing:.22em!important;color:var(--accent)!important;margin:0 0 6px!important;text-transform:uppercase} +.source-state{font:9px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--quiet);margin-bottom:4px} +.swarm-inspector h2{font:400 clamp(21px,2vw,28px)/1.14 var(--serif);letter-spacing:-.03em;margin:6px 0 18px;color:#eaf2fa;overflow-wrap:anywhere} +.swarm-inspector h3{font:400 15px var(--serif);color:#d4e4ea;border-top:1px solid var(--rule);padding-top:17px;margin:28px 0 6px} +.swarm-inspector p{font:11.5px/1.7 var(--mono);color:var(--muted);overflow-wrap:anywhere} +.swarm-inspector dl{display:block;font:11px/1.55 var(--mono);margin:14px 0 0} +.swarm-inspector dt{color:var(--quiet);padding-top:11px;margin:0;border-top:1px solid rgba(150,180,210,.09);font-size:9px;letter-spacing:.11em;text-transform:uppercase} +.swarm-inspector dd{margin:4px 0 11px;color:#d6e1ee;overflow-wrap:anywhere} +.swarm-inspector button:not(#swarm-close){display:block;width:100%;text-align:left;font:10.5px var(--mono);border:0;border-bottom:1px solid rgba(150,180,210,.09);border-radius:0;padding:10px 2px;margin:0;color:#bcd8e0;overflow-wrap:anywhere} +.swarm-inspector button:not(#swarm-close):hover{color:#eafcff;background:rgba(127,209,222,.06)} +.evidence-note{border:0!important;border-left:2px solid var(--warn)!important;background:rgba(224,168,92,.07)!important;border-radius:0;padding:11px 14px!important;color:#dcc49d!important;margin:14px 0} + +/* ── Tracking ledger and instruments ────────────────────────────────────── */ +.swarm-timeline{position:absolute;z-index:15;bottom:24px;left:340px;width:min(470px,calc(100% - 800px));padding:13px 16px;border-radius:3px} +.swarm-timeline summary{display:flex;gap:10px;align-items:baseline;cursor:pointer;margin:0;font:var(--kicker);letter-spacing:.16em;color:var(--muted);text-transform:uppercase} +.swarm-timeline summary::marker{color:var(--faint)} +.swarm-timeline summary span{font:9px var(--mono);letter-spacing:.04em;color:var(--faint);text-transform:none} +#tracking-list{font:10px/1.6 var(--mono);max-height:190px;overflow:auto;padding-top:12px} +#tracking-list p{color:var(--faint);margin:0} +.tracking-row{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:12px;padding:7px 0;border-bottom:1px solid rgba(150,180,210,.07);color:#c3cfdd} +.tracking-row time{color:var(--faint);font-size:9px} +.swarm-measurements{position:absolute;z-index:17;right:24px;bottom:28px;max-width:320px;text-align:right;font:9px var(--mono);color:var(--faint)} +.swarm-measurements summary{cursor:pointer;letter-spacing:.14em;text-transform:uppercase} +.swarm-diagnostics{font:9px/1.85 var(--mono);margin-top:10px;padding:11px 12px;color:var(--muted);text-align:left;overflow-wrap:anywhere} +.sample-notice{color:#e3bf85!important} +footer{position:relative;z-index:20;height:46px;display:flex;flex-wrap:wrap;align-items:center;gap:7px 18px; + border-top:1px solid var(--rule);font:9px/1.6 var(--mono);letter-spacing:.07em;color:var(--faint);padding:0 26px;background:#070a11;text-transform:uppercase} +footer b{color:var(--warn);font-weight:400} +.field-empty .observatory-title{opacity:.75} + +/* ── Responsive framing ─────────────────────────────────────────────────── */ +@media(max-width:1320px){ + .swarm-timeline{width:min(420px,calc(100% - 760px))} + .swarm-inspector{width:340px} + .has-selection .space-toolbar{right:378px} +} @media(max-width:1120px){ - .swarm-navigation{width:220px;left:14px;padding:18px 14px} - .space-toolbar{left:252px;right:18px} - .has-selection .space-toolbar{right:316px} - .swarm-inspector{width:278px;right:14px;padding:18px;top:100px} - .space-caption{left:256px;bottom:100px}.observatory-title{font-size:32px} - .swarm-timeline{left:252px;width:calc(100% - 568px)} + .swarm-navigation{width:252px;left:16px;top:16px;padding:18px 16px} + .space-toolbar{left:282px;right:18px;top:16px} + .has-selection .space-toolbar{right:312px} + .swarm-inspector{width:286px;right:16px;top:74px;padding:20px} + .space-caption{left:284px;bottom:104px;max-width:min(360px,44vw)} + .swarm-timeline{left:282px;width:calc(100% - 620px)} + .field-legend{gap:5px 12px} +} +@media(max-width:900px){ + .swarm-timeline{display:none} + .space-caption{bottom:38px} } @media(max-width:800px){ - .swarm-top{height:auto;min-height:76px;gap:12px;padding:12px 16px;position:sticky;top:0;z-index:30} + .swarm-top{height:auto;min-height:72px;flex-wrap:wrap;gap:10px;padding:12px 16px;position:sticky;top:0} + .swarm-brand{font-size:19px} #swarm-status{order:4;flex-basis:100%;text-align:left;font-size:9px} - .swarm-brand{font-size:19px}.read-only{margin-left:auto}.swarm-top button{font-size:10px} + .read-only{margin-left:auto} .swarm-shell{height:auto;min-height:0;display:block} - .swarm-space{position:relative;height:600px} - #swarm-stage{height:100%} - .space-toolbar,.has-selection .space-toolbar{top:14px;left:14px;right:14px;justify-content:space-between;gap:10px} - .space-controls{gap:6px;font-size:9px}.space-controls label{font-size:9px} - .swarm-navigation{left:14px;right:14px;top:92px;bottom:auto;width:auto;max-height:220px;padding:10px 12px;background:#10151ddb} - .swarm-navigation>.section-title:first-child,.search-label,#search-results,.scope-note{display:none} - .search-open .search-label,.search-open #search-results{display:block} - .search-open .search-label input{margin-top:7px} - .search-open .swarm-navigation{max-height:360px} - #source-list{margin:0;display:flex;gap:10px;overflow:auto} - #source-list button{min-width:150px;width:auto;padding:8px 10px;font-size:14px;border-bottom:0} - #source-list button small{font-size:9px} - #branch-heading{font-size:8px;margin:10px 0 6px} - #branch-list{max-height:98px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px} - #branch-list button{font-size:10px;padding:7px 4px} - #branch-list button small{font-size:8px} - .space-caption,.navigation-hidden .space-caption{left:20px;bottom:95px;font-size:9px}.observatory-title{font-size:32px}.has-selection .observatory-title{font-size:27px} - .swarm-timeline{left:14px;right:14px;bottom:20px;width:auto;padding:12px;max-width:calc(100% - 28px)} - .swarm-measurements{right:14px;bottom:67px;font-size:8px} - .swarm-inspector{position:relative;right:auto;left:auto;top:auto;bottom:auto;width:auto;margin:0 14px 20px;padding:22px;max-height:none} - .swarm-inspector dl{display:block}.swarm-inspector dd{margin:4px 0 13px} + .swarm-space{position:relative;height:clamp(420px,62vh,620px)} + .space-toolbar,.has-selection .space-toolbar{top:12px;left:12px;right:12px;flex-wrap:wrap;gap:8px} + #swarm-path{max-width:100%} + .swarm-navigation{position:relative;left:auto;top:auto;bottom:auto;width:auto;margin:16px 12px 0;max-height:none;padding:18px 16px;backdrop-filter:none;background:var(--plate-solid)} + .navigation-hidden .swarm-navigation{display:none} + #search-results{max-height:none} + #source-list{grid-auto-flow:column;grid-auto-columns:minmax(186px,1fr);overflow:auto;gap:10px;padding-bottom:6px} + #source-list button{border-bottom:0;border:1px solid var(--rule);padding:12px} + #branch-list{max-height:220px} + .space-caption,.navigation-hidden .space-caption{left:18px;bottom:24px;max-width:calc(100% - 36px)} + .observatory-title,.has-selection .observatory-title{font-size:clamp(26px,7vw,36px)} + .field-legend,.has-selection .field-legend{display:flex} + .swarm-measurements{right:14px;bottom:14px;max-width:62%} + .swarm-inspector,.has-selection .swarm-inspector{display:block;position:relative;right:auto;left:auto;top:auto;bottom:auto;width:auto;margin:16px 12px;padding:22px;backdrop-filter:none;background:var(--plate-solid)} footer{height:auto;padding:14px 16px} } @media(max-width:520px){ - .swarm-brand{font-size:18px}.swarm-top{gap:9px}#swarm-find{margin-left:auto}.read-only{display:none} - .swarm-space{height:540px}.space-toolbar{align-items:flex-start}.space-controls{flex-basis:100%} - .swarm-navigation{top:104px}.space-caption{max-width:calc(100% - 40px)} - .space-caption p{font-size:9px}.observatory-title{font-size:30px} - .swarm-inspector h2{font-size:27px} + .swarm-brand{font-size:18px} + #swarm-find{margin-left:auto} + .read-only{display:none} + .swarm-space{height:clamp(500px,62vh,600px)} + .space-controls{flex-wrap:wrap} + .field-legend li:nth-child(n+3){display:none} + .space-caption p{display:none} + .swarm-inspector h2{font-size:22px} +} +@media(prefers-reduced-motion:reduce){ + *{transition:none!important;animation:none!important} + .field-nebula{filter:none;opacity:.35} } diff --git a/src/swarm/preview.ts b/src/swarm/preview.ts index 4014341..a7cfdbf 100644 --- a/src/swarm/preview.ts +++ b/src/swarm/preview.ts @@ -11,7 +11,7 @@ export function swarmPreview(totalCells = 54): Swarm { depth: index === 0 ? 0 : Math.floor(Math.log(index * 3 + 1) / Math.log(4)), role: index === 0 ? 'coordinator' : index % 5 === 0 ? 'verifier' : index % 7 === 0 ? 'watcher' : 'developer', status: index % 17 === 16 ? 'retired' : 'ready', heartbeat: now, purpose: index === 0 ? 'Coordinate the recorded local delegation.' : `Inspect the work assigned to ${label} cell ${index}.`, - allocationCents: index === 0 ? 10000 : 500, budgetBasis: 'logical-allocation', + allocationCents: Math.floor(10000 / 4 ** (index === 0 ? 0 : Math.floor(Math.log(index * 3 + 1) / Math.log(4)))), budgetBasis: 'logical-allocation', })); const tasks = cells.length > 1 ? [{ id: 'implementation', projectId: 'flujo', branch: 'codex/sample', status: 'running' as const, owner: 'cell-1', attempt: 1, leaseExpiry: future, specDigest: digest, candidate: null, review: null }, { id: 'retire-operation', projectId: 'flujo', branch: 'codex/sample-retire', status: 'completed' as const, owner: cells.length > 2 ? 'cell-2' : 'root', attempt: 1, leaseExpiry: null, specDigest: digest, candidate: null, review: null }, diff --git a/src/swarm/renderer.ts b/src/swarm/renderer.ts index ef305bb..b2c2af7 100644 --- a/src/swarm/renderer.ts +++ b/src/swarm/renderer.ts @@ -2,13 +2,44 @@ import { BufferGeometry, Color, Float32BufferAttribute, LineBasicMaterial, LineS import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import type { Node, Point } from './model'; +/** Deterministic authority palette. The navigation indicators read the same + * function, so a plate in the field and its index entry cannot disagree. */ const hues = ['#7fd1de', '#9bb8e8', '#c2a7e8', '#e8a7b8', '#e8c98a', '#8fd9b6']; -const sourceHue = (id: string) => hues[[...id].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % hues.length]!; -const tint = (node: Node): string => node.source.status !== 'observed' ? '#7d8798' : node.activity === 'uncertain' ? '#e0a85c' : node.cell?.status === 'retired' ? '#7d8798' : sourceHue(node.source.id); -const vertexShader = `attribute float aSize; attribute float aActive; attribute float aFill; varying vec3 vColor; varying float vActive; varying float vFill; uniform float uTime; -void main(){vColor=color; vActive=aActive; vFill=aFill; vec4 p=modelViewMatrix*vec4(position,1.); float pulse=1.+aActive*.08*sin(uTime*2.); gl_PointSize=clamp(aSize*pulse*180./max(1.,-p.z),7.,56.); gl_Position=projectionMatrix*p;}`; -const fragmentShader = `varying vec3 vColor; varying float vActive; varying float vFill; -void main(){float d=length(gl_PointCoord-.5)*2.; if(d>1.)discard; float ring=smoothstep(.7,.8,d)*(1.-smoothstep(.86,1.,d)); float core=(1.-smoothstep(.2,.54,d))*vFill; float glow=exp(-d*d*5.)*.12*vFill; gl_FragColor=vec4(mix(vColor,vec3(.92,.98,1.),core*.6),ring*.8+core*.7+glow);}`; +export const sourceHue = (id: string) => hues[[...id].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % hues.length]!; +/** Shared with the atlas rail; identical input gives an identical colour. */ +export const sourceColor = sourceHue; +export const uncertainHue = '#e0a85c'; +export const retiredHue = '#7d8798'; +const tint = (node: Node): string => node.source.status !== 'observed' ? retiredHue : node.activity === 'uncertain' ? uncertainHue : node.cell?.status === 'retired' ? retiredHue : sourceHue(node.source.id); + +/** One batched point program: engraved plates, cell rings, cores and haloes. + * Depth attenuation keeps a deep field legible without a second pass. */ +const vertexShader = `attribute float aSize; attribute float aActive; attribute float aFill; attribute float aPlate; attribute float aSelected; +varying vec3 vColor; varying float vActive; varying float vFill; varying float vPlate; varying float vSelected; varying float vFade; uniform float uTime; +void main(){ + vColor=color; vActive=aActive; vFill=aFill; vPlate=aPlate; vSelected=aSelected; + vec4 p=modelViewMatrix*vec4(position,1.); + float depth=max(1.,-p.z); + vFade=clamp(1.25-depth/5200.,.34,1.); + float breath=1.+aActive*.09*sin(uTime*2.+position.x*.12); + gl_PointSize=clamp(aSize*breath*(1.+aSelected*.26)*200./depth,7.,78.); + gl_Position=projectionMatrix*p; +}`; +const fragmentShader = `varying vec3 vColor; varying float vActive; varying float vFill; varying float vPlate; varying float vSelected; varying float vFade; +void main(){ + vec2 uv=gl_PointCoord-.5; float d=length(uv)*2.; + if(d>1.)discard; + float core=(1.-smoothstep(.0,.40,d))*vFill; + float ring=smoothstep(.49,.58,d)*(1.-smoothstep(.70,.80,d)); + float plate=vPlate*smoothstep(.86,.91,d)*(1.-smoothstep(.97,1.,d)); + float halo=exp(-d*d*3.6)*(.10+.12*vFill); + vec2 q=abs(uv)*2.; + float spike=vSelected*max(exp(-q.x*22.),exp(-q.y*22.))*(1.-smoothstep(.2,1.,d))*.55; + float select=vSelected*smoothstep(.80,.88,d)*(1.-smoothstep(.95,1.,d)); + vec3 lit=mix(vColor,vec3(.93,.99,1.),clamp(core*.55+spike*.7+select*.8,0.,1.)); + float alpha=(ring*.9+core*.85+halo+plate*.55+spike*.6+select*.95)*vFade; + gl_FragColor=vec4(lit,clamp(alpha,0.,1.)); +}`; /** Two semantic draw batches, bounded labels, no postprocessing or idle orbit. * Rendering is demand-driven; evidenced activity pulses at no more than 30fps. */ @@ -16,7 +47,7 @@ export class SwarmRenderer { private gl: WebGLRenderer | null = null; private ctx: CanvasRenderingContext2D | null = null; private scene = new Scene(); - private camera = new PerspectiveCamera(50, 1, .1, 20000); + private camera = new PerspectiveCamera(46, 1, .1, 20000); private controls: OrbitControls | null = null; private points: Points | null = null; private lines: LineSegments | null = null; @@ -34,10 +65,13 @@ export class SwarmRenderer { private observer: ResizeObserver; private disposeInput: () => void; private disposed = false; + private motion = matchMedia('(prefers-reduced-motion: reduce)'); private labelElements: HTMLSpanElement[] = []; + private labelSizes = new Map(); private submissions = 0; private lastSubmissionMs = 0; private flight: { start: number; fromPosition: Vector3; fromTarget: Vector3; toPosition: Vector3; toTarget: Vector3 } | null = null; + private lastFit: { point?: Point; sourceId?: string } | null = null; readonly mode: '3d' | '2d'; onPick: (node: Node) => void = () => undefined; onStats: (stats: { mode: string; visible: number; labels: number; draws: number; submissions: number; cpuMs: number }) => void = () => undefined; @@ -47,13 +81,14 @@ export class SwarmRenderer { try { this.gl = new WebGLRenderer({ canvas, antialias: false, powerPreference: 'low-power', alpha: true }); this.gl.setPixelRatio(Math.min(devicePixelRatio || 1, 1.25)); - this.gl.setClearColor(0x081216, 0); - this.camera.position.set(40, 120, 180); + this.gl.setClearColor(0x05070c, 0); + this.camera.position.set(52, 128, 196); this.controls = new OrbitControls(this.camera, canvas); this.controls.enableDamping = false; this.controls.enablePan = true; this.controls.minDistance = 8; this.controls.maxDistance = 10000; + this.controls.maxPolarAngle = Math.PI * .92; this.controls.addEventListener('change', this.request); canvas.addEventListener('webglcontextlost', this.contextLost); } catch { this.gl = null; } @@ -69,73 +104,142 @@ export class SwarmRenderer { this.observer = new ResizeObserver(this.resize); this.observer.observe(this.canvas.parentElement!); document.addEventListener('visibilitychange', this.visibility); + this.motion.addEventListener('change', this.motionChanged); this.disposeInput = this.wireInput(); this.resize(); } private contextLost = (event: Event) => { event.preventDefault(); this.active = false; this.canvas.dispatchEvent(new CustomEvent('swarm-renderer-lost', { bubbles: true })); }; private visibility = () => { if (document.hidden) { cancelAnimationFrame(this.frameId); this.frameId = 0; } else this.request(); }; + private motionChanged = () => { + if (this.motion.matches) { + this.flight = null; + cancelAnimationFrame(this.frameId); this.frameId = 0; + } + this.setNodes(this.nodes, this.selected); + }; private resize = () => { const rect = this.canvas.parentElement!.getBoundingClientRect(); this.width = Math.max(1, rect.width); this.height = Math.max(1, rect.height); if (this.gl) { this.gl.setSize(this.width, this.height, false); this.camera.aspect = this.width / this.height; this.camera.updateProjectionMatrix(); } else { const ratio = Math.min(devicePixelRatio || 1, 1.25); this.canvas.width = this.width * ratio; this.canvas.height = this.height * ratio; this.ctx!.setTransform(ratio, 0, 0, ratio, 0, 0); } + this.viewport(); + if (this.lastFit) this.frame(this.lastFit.point, this.lastFit.sourceId); this.request(); }; + /** Fit observations into the area left by visible instruments. The backdrop + * remains full bleed, while atlas/evidence/caption plates do not hide focus. */ + private viewport(): { x: number; y: number; width: number; height: number } { + const stage = this.canvas.parentElement!.getBoundingClientRect(); + let left = 20, right = this.width - 20, top = 24, bottom = this.height - 24; + const overlaps = (rect: DOMRect) => rect.width > 0 && rect.height > 0 && rect.top < stage.bottom && rect.bottom > stage.top; + const atlas = document.querySelector('.swarm-navigation')?.getBoundingClientRect(); + if (atlas && overlaps(atlas) && atlas.left < stage.left + this.width / 2) left = Math.max(left, atlas.right - stage.left + 20); + const evidence = document.querySelector('.swarm-inspector')?.getBoundingClientRect(); + if (evidence && overlaps(evidence) && evidence.right > stage.left + this.width / 2) right = Math.min(right, evidence.left - stage.left - 20); + const toolbar = document.querySelector('.space-toolbar')?.getBoundingClientRect(); + if (toolbar && overlaps(toolbar)) top = Math.max(top, toolbar.bottom - stage.top + 20); + const caption = document.querySelector('.space-caption')?.getBoundingClientRect(); + if (caption && overlaps(caption)) bottom = Math.min(bottom, caption.top - stage.top - 20); + const width = Math.max(120, right - left), height = Math.max(120, bottom - top); + const x = left + width / 2, y = top + height / 2; + if (this.gl) this.camera.setViewOffset(this.width, this.height, this.width / 2 - x, this.height / 2 - y, this.width, this.height); + return { x, y, width, height }; + } setNodes(nodes: Node[], selected: string | null): void { this.nodes = nodes; this.selected = selected; - this.active = !matchMedia('(prefers-reduced-motion: reduce)').matches && nodes.some(node => node.activity === 'recent'); + this.viewport(); + // The plan view has no animated shader; it only needs observation/input redraws. + this.active = !!this.gl && !this.motion.matches && nodes.some(node => node.activity === 'recent'); if (this.gl) { - const pos: number[] = [], colors: number[] = [], sizes: number[] = [], active: number[] = [], fill: number[] = [], linkPos: number[] = [], linkColors: number[] = []; + const pos: number[] = [], colors: number[] = [], sizes: number[] = [], active: number[] = [], fill: number[] = [], plate: number[] = [], chosen: number[] = []; + const linkPos: number[] = [], linkColors: number[] = []; const byKey = new Map(nodes.map(node => [node.key, node])); - // Static engraved source plates share the delegation line batch. They - // are reference rings, not edges or activity. No cross-authority lines. + // Static engraved source plates share the delegation line batch. They are + // decorative reference rings, not edges, authority or activity, and no + // cross-authority line is ever emitted. for (const source of nodes.filter(node => !node.cell)) { - const color = new Color(sourceHue(source.source.id)); - for (const radius of [26, 49, 72]) for (let i = 0; i < 64; i++) for (const t of [i / 64, (i + 1) / 64]) { - const angle = t * Math.PI * 2; - linkPos.push(source.point.x + Math.cos(angle) * radius, 0, source.point.z + Math.sin(angle) * radius); - linkColors.push(color.r * .07, color.g * .07, color.b * .07); + const color = new Color(sourceHue(source.source.id)), base = source.point.y; + const faint = source.source.status !== 'observed' ? .035 : .085; + for (const [ri, radius] of [30, 56, 84].entries()) { + const segments = 80, weight = faint * (ri === 1 ? 1.35 : 1); + for (let i = 0; i < segments; i++) for (const t of [i / segments, (i + 1) / segments]) { + const angle = t * Math.PI * 2; + linkPos.push(source.point.x + Math.cos(angle) * radius, base, source.point.z + Math.sin(angle) * radius); + linkColors.push(color.r * weight, color.g * weight, color.b * weight); + } + } + // Graduated ticks on the outer ring read as an instrument bezel. + for (let i = 0; i < 24; i++) { + const angle = i / 24 * Math.PI * 2, inner = i % 6 === 0 ? 78 : 81.5, weight = faint * 1.9; + linkPos.push(source.point.x + Math.cos(angle) * inner, base, source.point.z + Math.sin(angle) * inner); + linkColors.push(color.r * weight, color.g * weight, color.b * weight); + linkPos.push(source.point.x + Math.cos(angle) * 88, base, source.point.z + Math.sin(angle) * 88); + linkColors.push(color.r * weight * .25, color.g * weight * .25, color.b * weight * .25); } } for (const node of nodes) { pos.push(node.point.x, node.point.y, node.point.z); - const color = new Color(node.key === selected ? '#e1fff5' : tint(node)); colors.push(color.r, color.g, color.b); - sizes.push(!node.cell ? 18 : node.key === selected ? 17 : node.cell.role === 'coordinator' ? 13 : 9); active.push(node.activity === 'recent' ? 1 : 0); + const own = new Color(tint(node)), color = new Color(node.key === selected ? '#e6fff8' : tint(node)); + colors.push(color.r, color.g, color.b); + sizes.push(!node.cell ? 21 : node.key === selected ? 17 : node.cell.role === 'coordinator' ? 13.5 : node.cell.role === 'verifier' ? 10 : 8.5); + active.push(!this.motion.matches && node.activity === 'recent' ? 1 : 0); fill.push(node.source.status === 'observed' && node.cell?.status !== 'retired' && node.cell?.status !== 'reserved' && node.activity !== 'uncertain' ? 1 : 0); + plate.push(node.cell ? 0 : 1); chosen.push(node.key === selected ? 1 : 0); const parent = node.parent ? byKey.get(node.parent) : undefined; if (parent?.cell && node.cell?.parentId) { - // Short curved delegation links, one shared line batch. - for (let i = 0; i < 8; i++) { - for (const t of [i / 8, (i + 1) / 8]) { - linkPos.push(parent.point.x + (node.point.x - parent.point.x) * t, parent.point.y + (node.point.y - parent.point.y) * t + Math.sin(t * Math.PI) * 3, parent.point.z + (node.point.z - parent.point.z) * t); - const intensity = node.key === selected || parent.key === selected ? .65 : .2; linkColors.push(color.r * intensity, color.g * intensity, color.b * intensity); - } + // Lifted delegation ribbons fading parent to child, one line batch. + const from = new Color(tint(parent)), lit = node.key === selected || parent.key === selected; + for (let i = 0; i < 10; i++) for (const t of [i / 10, (i + 1) / 10]) { + const arc = Math.sin(t * Math.PI), intensity = (lit ? .72 : .19) * (.45 + .55 * arc); + linkPos.push(parent.point.x + (node.point.x - parent.point.x) * t, parent.point.y + (node.point.y - parent.point.y) * t + arc * 3.4, parent.point.z + (node.point.z - parent.point.z) * t); + linkColors.push((from.r + (own.r - from.r) * t) * intensity, (from.g + (own.g - from.g) * t) * intensity, (from.b + (own.b - from.b) * t) * intensity); } } } - const geometry = new BufferGeometry(); geometry.setAttribute('position', new Float32BufferAttribute(pos, 3)); geometry.setAttribute('color', new Float32BufferAttribute(colors, 3)); geometry.setAttribute('aSize', new Float32BufferAttribute(sizes, 1)); geometry.setAttribute('aActive', new Float32BufferAttribute(active, 1)); geometry.setAttribute('aFill', new Float32BufferAttribute(fill, 1)); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(pos, 3)); geometry.setAttribute('color', new Float32BufferAttribute(colors, 3)); + geometry.setAttribute('aSize', new Float32BufferAttribute(sizes, 1)); geometry.setAttribute('aActive', new Float32BufferAttribute(active, 1)); + geometry.setAttribute('aFill', new Float32BufferAttribute(fill, 1)); geometry.setAttribute('aPlate', new Float32BufferAttribute(plate, 1)); + geometry.setAttribute('aSelected', new Float32BufferAttribute(chosen, 1)); if (this.points) { this.points.geometry.dispose(); this.points.geometry = geometry; } else { this.points = new Points(geometry, new ShaderMaterial({ vertexShader, fragmentShader, vertexColors: true, transparent: true, depthWrite: false, uniforms: { uTime: { value: 0 } } })); this.scene.add(this.points); } const links = new BufferGeometry(); links.setAttribute('position', new Float32BufferAttribute(linkPos, 3)); links.setAttribute('color', new Float32BufferAttribute(linkColors, 3)); if (this.lines) { this.lines.geometry.dispose(); this.lines.geometry = links; } - else { this.lines = new LineSegments(links, new LineBasicMaterial({ vertexColors: true, transparent: true, opacity: .9, depthWrite: false })); this.scene.add(this.lines); } + else { this.lines = new LineSegments(links, new LineBasicMaterial({ vertexColors: true, transparent: true, opacity: .92, depthWrite: false })); this.scene.add(this.lines); } } - const priority = nodes.filter(node => node.key === selected).concat(nodes.filter(node => !node.cell || node.cell.role === 'coordinator'), nodes.filter(node => node.cell && node.cell.role !== 'coordinator').sort((a, b) => a.cell!.depth - b.cell!.depth)); + const priority = nodes.filter(node => node.key === selected).concat(nodes.filter(node => !node.cell || node.cell.role === 'coordinator'), nodes.filter(node => node.cell && node.activity === 'recent'), nodes.filter(node => node.cell && node.cell.role !== 'coordinator').sort((a, b) => a.cell!.depth - b.cell!.depth)); const labelled = [...new Map(priority.map(node => [node.key, node])).values()].slice(0, 24); this.labels.replaceChildren(); this.labelElements = []; - for (const node of labelled) { const span = document.createElement('span'); span.className = `swarm-label${!node.cell ? ' source-label' : ''}${node.key === selected ? ' selected' : ''}`; span.textContent = node.label.length > 24 ? `${node.label.slice(0, 22)}…` : node.label; span.title = `${node.source.factoryId} / ${node.label}`; span.dataset.key = node.key; this.labels.append(span); this.labelElements.push(span); } + for (const node of labelled) { + const span = document.createElement('span'); + span.className = `swarm-label${!node.cell ? ' source-label' : ''}${node.key === selected ? ' selected' : ''}${node.activity === 'recent' ? ' active' : ''}`; + span.textContent = node.label.length > 24 ? `${node.label.slice(0, 22)}…` : node.label; + span.title = `${node.source.factoryId} / ${node.label}`; + span.dataset.key = node.key; + span.style.setProperty('--accent', sourceHue(node.source.id)); + if (!node.cell) { const kicker = document.createElement('i'); kicker.textContent = node.source.status === 'observed' ? 'authority' : node.source.status; span.append(kicker); } + this.labels.append(span); this.labelElements.push(span); + } + // Measure once per observation, rather than reading layout on every frame. + this.labelSizes = new Map(this.labelElements.map(label => { + const rect = label.getBoundingClientRect(); + return [label, { width: rect.width, height: rect.height }]; + })); this.request(); } frame(point?: Point, sourceId?: string): void { + this.lastFit = { point, sourceId }; + const viewport = this.viewport(); const nodes = sourceId ? this.nodes.filter(node => node.source.id === sourceId) : this.nodes; const center = point ?? nodes.reduce((sum, node) => ({ x: sum.x + node.point.x / Math.max(1, nodes.length), y: sum.y + node.point.y / Math.max(1, nodes.length), z: sum.z + node.point.z / Math.max(1, nodes.length) }), { x: 0, y: 0, z: 0 }); - const radius = point ? 35 : Math.max(sourceId ? 78 : 40, ...nodes.map(node => Math.hypot(node.point.x - center.x, node.point.z - center.z))); + const radius = point ? 35 : Math.max(sourceId ? 92 : 48, ...nodes.map(node => Math.hypot(node.point.x - center.x, node.point.z - center.z))); if (this.gl) { - const distance = radius * (this.width < this.height ? 3.8 : sourceId ? 2.5 : 1.8); - const target = new Vector3(center.x, center.y, center.z), position = new Vector3(center.x + distance * .16, center.y + distance * .65, center.z + distance); - if (matchMedia('(prefers-reduced-motion: reduce)').matches) { this.controls!.target.copy(target); this.camera.position.copy(position); this.controls!.update(); } + const distance = radius * Math.max((point ? 2.7 : sourceId ? 1.45 : 1.1) * this.height / viewport.height, 2.4 * this.height / viewport.width); + const target = new Vector3(center.x, center.y + (point ? 0 : 6), center.z); + const position = new Vector3(center.x + distance * .34, center.y + distance * (point ? .42 : .52), center.z + distance * .86); + if (this.motion.matches) { this.controls!.target.copy(target); this.camera.position.copy(position); this.controls!.update(); } else this.flight = { start: performance.now(), fromPosition: this.camera.position.clone(), fromTarget: this.controls!.target.clone(), toPosition: position, toTarget: target }; - } else { this.zoom = Math.min(this.width, this.height) / (radius * 2.8); this.pan = { x: -center.x * this.zoom, y: -center.z * this.zoom }; } + } else { this.zoom = Math.min(viewport.width, viewport.height) / (radius * 2.8); this.pan = { x: viewport.x - this.width / 2 - center.x * this.zoom, y: viewport.y - this.height / 2 - center.z * this.zoom }; } this.request(); } request = (): void => { if (!this.disposed && !document.hidden && !this.frameId) this.frameId = requestAnimationFrame(this.draw); }; @@ -147,43 +251,70 @@ export class SwarmRenderer { private draw = (time: number): void => { this.frameId = 0; if (this.disposed || document.hidden) return; - if ((this.active || this.flight) && time - this.lastFrame < 33) { this.request(); return; } + if ((this.active || this.flight) && time - this.lastFrame < 1000 / 30) { this.request(); return; } this.lastFrame = time; const start = performance.now(); this.projected.clear(); if (this.flight) { - const progress = Math.min(1, (time - this.flight.start) / 620), eased = 1 - (1 - progress) ** 3; + const progress = Math.min(1, (time - this.flight.start) / 760), eased = 1 - (1 - progress) ** 3; this.camera.position.lerpVectors(this.flight.fromPosition, this.flight.toPosition, eased); this.controls!.target.lerpVectors(this.flight.fromTarget, this.flight.toTarget, eased); this.controls!.update(); if (progress >= 1) this.flight = null; } for (const node of this.nodes) this.projected.set(node.key, this.project(node.point)); if (this.gl) { if (this.points) this.points.material.uniforms.uTime!.value = time / 1000; this.gl.render(this.scene, this.camera); } - else { - const context = this.ctx!; context.clearRect(0, 0, this.width, this.height); - for (const source of this.nodes.filter(node => !node.cell)) { const point = this.projected.get(source.key)!; context.strokeStyle = `${sourceHue(source.source.id)}18`; context.lineWidth = 1; for (const radius of [26, 49, 72]) { context.beginPath(); context.arc(point.x, point.y, radius * this.zoom, 0, Math.PI * 2); context.stroke(); } } - for (const node of this.nodes) { - const parent = node.parent && this.projected.get(node.parent), point = this.projected.get(node.key)!; - if (!parent || !node.cell?.parentId) continue; - context.beginPath(); context.moveTo(parent.x, parent.y); context.quadraticCurveTo((parent.x + point.x) / 2 + 7, (parent.y + point.y) / 2 - 12, point.x, point.y); context.strokeStyle = node.key === this.selected ? '#7cdcbda0' : '#739c9e36'; context.lineWidth = 1; context.stroke(); - } - for (const node of this.nodes) { - const point = this.projected.get(node.key)!, radius = !node.cell ? 13 : node.key === this.selected ? 10 : node.cell.role === 'coordinator' ? 9 : 5; - const gradient = context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius * 3); gradient.addColorStop(0, `${tint(node)}d0`); gradient.addColorStop(.3, `${tint(node)}60`); gradient.addColorStop(1, `${tint(node)}00`); - context.fillStyle = gradient; context.beginPath(); context.arc(point.x, point.y, radius * 3, 0, Math.PI * 2); context.fill(); - context.strokeStyle = tint(node); context.beginPath(); context.arc(point.x, point.y, radius * .75, 0, Math.PI * 2); context.stroke(); - if (node.source.status === 'observed' && node.cell?.status !== 'retired' && node.activity !== 'uncertain') { context.fillStyle = tint(node); context.beginPath(); context.arc(point.x, point.y, radius * .35, 0, Math.PI * 2); context.fill(); } - if (node.key === this.selected) { context.strokeStyle = '#c6ffec'; context.beginPath(); context.arc(point.x, point.y, radius + 4, 0, Math.PI * 2); context.stroke(); } - } - } - const occupied = new Set(); + else this.paint2d(); + const occupied: Array<{ left: number; right: number; top: number; bottom: number }> = []; for (const label of this.labelElements) { const point = this.projected.get(label.dataset.key!); label.hidden = !point?.size || point.x < 0 || point.x > this.width || point.y < 0 || point.y > this.height; if (point && point.y > this.height - 145) label.hidden = true; - if (point && !label.hidden) { const slot = `${Math.floor(point.x / 110)}:${Math.floor(point.y / 26)}`; if (occupied.has(slot)) label.hidden = true; else occupied.add(slot); } - if (point) label.style.transform = `translate(${point.x}px,${point.y + 17}px) translateX(-50%)`; + if (point && !label.hidden) { + const size = this.labelSizes.get(label)!; + const rect = { left: point.x - size.width / 2 - 4, right: point.x + size.width / 2 + 4, top: point.y + 16, bottom: point.y + 20 + size.height }; + if (occupied.some(other => rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top)) label.hidden = true; + else occupied.push(rect); + } + if (point) label.style.transform = `translate(${point.x}px,${point.y + 18}px) translateX(-50%)`; } this.submissions++; this.lastSubmissionMs = performance.now() - start; this.onStats({ mode: this.mode, visible: this.nodes.length, labels: this.labelElements.length, draws: this.gl?.info.render.calls ?? 0, submissions: this.submissions, cpuMs: this.lastSubmissionMs }); if (this.active || this.flight) this.request(); }; + /** 2D fallback: the same index, the same deterministic colours, plan view. */ + private paint2d(): void { + const context = this.ctx!; + context.clearRect(0, 0, this.width, this.height); + for (const source of this.nodes.filter(node => !node.cell)) { + const point = this.projected.get(source.key)!, hue = sourceHue(source.source.id); + context.lineWidth = 1; + for (const radius of [30, 56, 84]) { context.strokeStyle = `${hue}${radius === 56 ? '22' : '16'}`; context.beginPath(); context.arc(point.x, point.y, radius * this.zoom, 0, Math.PI * 2); context.stroke(); } + context.strokeStyle = `${hue}2c`; + for (let i = 0; i < 24; i++) { + const angle = i / 24 * Math.PI * 2, inner = (i % 6 === 0 ? 78 : 81.5) * this.zoom, outer = 88 * this.zoom; + context.beginPath(); context.moveTo(point.x + Math.cos(angle) * inner, point.y + Math.sin(angle) * inner); context.lineTo(point.x + Math.cos(angle) * outer, point.y + Math.sin(angle) * outer); context.stroke(); + } + } + for (const node of this.nodes) { + const parent = node.parent && this.projected.get(node.parent), point = this.projected.get(node.key)!; + if (!parent || !node.cell?.parentId) continue; + const lit = node.key === this.selected || node.parent === this.selected; + context.beginPath(); context.moveTo(parent.x, parent.y); + context.quadraticCurveTo((parent.x + point.x) / 2 + 7, (parent.y + point.y) / 2 - 13, point.x, point.y); + context.strokeStyle = lit ? `${tint(node)}c0` : `${tint(node)}30`; context.lineWidth = lit ? 1.4 : 1; context.stroke(); + } + for (const node of this.nodes) { + const point = this.projected.get(node.key)!, hue = tint(node); + const radius = !node.cell ? 14 : node.key === this.selected ? 10 : node.cell.role === 'coordinator' ? 9 : node.cell.role === 'verifier' ? 6 : 5; + const gradient = context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius * 3.2); + gradient.addColorStop(0, `${hue}cc`); gradient.addColorStop(.32, `${hue}55`); gradient.addColorStop(1, `${hue}00`); + context.fillStyle = gradient; context.beginPath(); context.arc(point.x, point.y, radius * 3.2, 0, Math.PI * 2); context.fill(); + context.strokeStyle = hue; context.lineWidth = node.cell ? 1 : 1.4; context.beginPath(); context.arc(point.x, point.y, radius * .78, 0, Math.PI * 2); context.stroke(); + if (!node.cell) { context.strokeStyle = `${hue}66`; context.beginPath(); context.arc(point.x, point.y, radius * 1.5, 0, Math.PI * 2); context.stroke(); } + if (node.source.status === 'observed' && node.cell?.status !== 'retired' && node.cell?.status !== 'reserved' && node.activity !== 'uncertain') { context.fillStyle = hue; context.beginPath(); context.arc(point.x, point.y, radius * .34, 0, Math.PI * 2); context.fill(); } + if (node.key === this.selected) { + context.strokeStyle = '#d2fff0'; context.lineWidth = 1.2; + context.beginPath(); context.arc(point.x, point.y, radius + 5, 0, Math.PI * 2); context.stroke(); + context.beginPath(); context.moveTo(point.x - radius - 11, point.y); context.lineTo(point.x - radius - 3, point.y); context.moveTo(point.x + radius + 3, point.y); context.lineTo(point.x + radius + 11, point.y); context.stroke(); + } + } + } private wireInput(): () => void { const down = (event: PointerEvent) => { this.flight = null; this.drag = { x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY, moved: false }; if (!this.gl) this.canvas.setPointerCapture(event.pointerId); }; const move = (event: PointerEvent) => { @@ -207,7 +338,8 @@ export class SwarmRenderer { } dispose(): void { this.disposed = true; cancelAnimationFrame(this.frameId); this.observer.disconnect(); this.disposeInput(); document.removeEventListener('visibilitychange', this.visibility); + this.motion.removeEventListener('change', this.motionChanged); this.canvas.removeEventListener('webglcontextlost', this.contextLost); this.controls?.removeEventListener('change', this.request); this.controls?.dispose(); - this.points?.geometry.dispose(); this.points?.material.dispose(); this.lines?.geometry.dispose(); this.lines?.material.dispose(); this.gl?.dispose(); this.labels.replaceChildren(); + this.points?.geometry.dispose(); this.points?.material.dispose(); this.lines?.geometry.dispose(); this.lines?.material.dispose(); this.gl?.dispose(); this.labels.replaceChildren(); this.labelSizes.clear(); } } diff --git a/src/swarm/transport.ts b/src/swarm/transport.ts new file mode 100644 index 0000000..b984bbb --- /dev/null +++ b/src/swarm/transport.ts @@ -0,0 +1,15 @@ +/** Bound streamed bytes before JSON decoding, including multibyte UTF-8. */ +export async function readSwarmJson(response: Response, maxBytes = 32 * 1024 * 1024): Promise { + if (!response.body) throw new Error('Swarm observation has no body'); + const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true }); + const chunks: string[] = []; let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) throw new Error('Swarm observation exceeds its bound'); + chunks.push(decoder.decode(value, { stream: true })); + } + return JSON.parse(chunks.join('') + decoder.decode()); + } finally { await reader.cancel().catch(() => undefined); } +} diff --git a/swarm.html b/swarm.html index 57e691a..b7be622 100644 --- a/swarm.html +++ b/swarm.html @@ -1,15 +1,59 @@ -Brain · swarm +Brain · swarm
-
brain observatoryWaiting for an authorized observationREAD ONLY
+
+ brain observatory + + + Waiting for an authorized observation + READ ONLY +
- -
A wider mind.

Orbit the recorded world. Enter a source. Follow its work.

LIVE TRACKING
Rendering details
+ +
+ +
+ +
+ + + +
+
+
+ + +
+

RECORDED FACTORY SPACE

+
A wider mind.
+ +

Orbit the recorded world. Enter a source. Follow its work.

+
    +
  • unexpired lease · recent heartbeat
  • +
  • uncertain evidence
  • +
  • retired · reserved · stale
  • +
  • authority plate
  • +
+
+
+
LIVE TRACKING
+
Rendering details
+
-
FACTORY owns execution and recovery. Recorded activity does not establish host health. Worker quiescence: unverified.
+
FACTORY owns execution and recovery.Recorded activity does not establish host health.Worker quiescence: unverified.
diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index c223066..724ea32 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -5,12 +5,34 @@ import { performance } from 'node:perf_hooks'; import ts from 'typescript'; const generated = new URL('../.vite/swarm-tests/', import.meta.url); await mkdir(generated, { recursive: true }); -for (const name of ['model', 'preview']) { +for (const name of ['model', 'preview', 'transport']) { const source = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); await writeFile(new URL(`${name}.mjs`, generated), ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText); } -const { parseSwarm, SwarmIndex, nodeKey, activities } = await import(new URL('model.mjs', generated)); +const { parseSwarm, SwarmIndex, nodeKey, activities, selectionFragment, selectionFromFragment } = await import(new URL('model.mjs', generated)); const { swarmPreview } = await import(new URL('preview.mjs', generated)); +const { readSwarmJson } = await import(new URL('transport.mjs', generated)); + +test('deep links bind a reused registry alias to its exact factory and reject ambiguous/missing authority', () => { + const swarm = swarmPreview(), source = swarm.sources[0], selected = { sourceId: source.id, kind: 'cell', id: 'root' }; + const fragment = selectionFragment(source, selected); + assert.deepEqual(selectionFromFragment(swarm, fragment), selected); + assert.equal(selectionFromFragment(swarm, fragment + '&factory=another'), null); + assert.equal(selectionFromFragment(swarm, `source=${source.id}&kind=cell&id=root`), null); + source.factoryId = 'replacement-authority'; source.snapshot.factoryId = source.factoryId; + assert.equal(selectionFromFragment(swarm, fragment), null); + assert.deepEqual(selectionFromFragment(swarm, selectionFragment(source, selected)), selected); +}); + +test('consumer bounds streamed bytes before parsing and handles split UTF-8 without corruption', async () => { + const value = new TextEncoder().encode('{"label":"ø"}'); + const response = new Response(new ReadableStream({ start(controller) { controller.enqueue(value.slice(0, 11)); controller.enqueue(value.slice(11)); controller.close(); } })); + assert.deepEqual(await readSwarmJson(response, value.length), { label: 'ø' }); + let cancelled = false; + const oversized = new Response(new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(64)); }, cancel() { cancelled = true; } })); + await assert.rejects(readSwarmJson(oversized, 32), /exceeds its bound/); + assert.equal(cancelled, true); +}); test('same bare root/task/effect identities remain distinct across authorities and registration is separate', () => { const swarm = parseSwarm(swarmPreview()); @@ -60,6 +82,20 @@ test('spatial positions survive heartbeat and recorded-status updates', () => { const after = new SwarmIndex(swarm); for (const [key, node] of before.nodes) assert.deepEqual(after.nodes.get(key).point, node.point); }); + +test('large source overviews use recorded roots even when their ID is not root, and source focus shows its delegation context', () => { + const swarm = swarmPreview(3000), source = swarm.sources[0]; swarm.sources = [source]; + source.snapshot.snapshot.cells[0].id = 'coordinator-alpha'; + for (const cell of source.snapshot.snapshot.cells) if (cell.parentId === 'root') cell.parentId = 'coordinator-alpha'; + for (const task of source.snapshot.snapshot.tasks) if (task.owner === 'root') task.owner = 'coordinator-alpha'; + const index = new SwarmIndex(parseSwarm(swarm)), root = nodeKey(source, 'cell', 'coordinator-alpha'); + assert.ok(index.visible(null).some(node => node.key === root)); + assert.ok(index.visible(null).some(node => node.cell?.id === 'cell-1')); + const focused = index.visible(nodeKey(source, 'source', source.id)); + assert.ok(focused.some(node => node.key === root)); + assert.ok(focused.some(node => node.cell?.parentId === 'coordinator-alpha')); + assert.ok(focused.length <= 600); +}); test('observation freshness never renews worker evidence, and terminal states do not activate it', () => { const swarm = parseSwarm(swarmPreview()), source = swarm.sources[0], now = Date.parse(source.snapshot.observedAt); assert.equal(activities(source, now).get('cell-1'), 'recent'); From 7390063e63f9fe161cfcd451f6e17e860362df6f Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:02:45 -0500 Subject: [PATCH 3/9] Preserve unknown effect uncertainty after task completion --- docs/SWARM.md | 4 +++- src/swarm/model.ts | 3 ++- test/swarm.test.mjs | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/SWARM.md b/docs/SWARM.md index 05582b8..bc3aed0 100644 --- a/docs/SWARM.md +++ b/docs/SWARM.md @@ -68,7 +68,9 @@ changes and a trailing diagnostics update when on-demand rendering settles. Fresh observations do not establish worker activity. A cell needs an active controller, ready lifecycle, recent heartbeat, an unexpired running lease and no owned unknown outcome. Completed/cancelled operations do not animate as -running work. Pause, effect drain and worker quiescence remain separate facts. +running work. Unknown external outcomes keep their cell's uncertainty marker +after task completion or retirement, until an observed resolution replaces them. +Pause, effect drain and worker quiescence remain separate facts. The selected inspector preserves task, candidate and review digests and states that quiescence is unverified. diff --git a/src/swarm/model.ts b/src/swarm/model.ts index 0639640..afc5ba1 100644 --- a/src/swarm/model.ts +++ b/src/swarm/model.ts @@ -99,7 +99,8 @@ export function activities(source: Source, now: number, sample = false): Map 15000 || observedAge < -5000)) || state.control.status !== 'active' || cell.status !== 'ready' || heartbeatAge < 0 || heartbeatAge > 60000 || expiry <= now || unknown.has(cell.id) ? 'uncertain' : 'recent'); + // Task completion does not resolve an external operation's unknown outcome. + result.set(cell.id, unknown.has(cell.id) ? 'uncertain' : expiry === undefined ? 'idle' : source.status !== 'observed' || (!sample && (observedAge > 15000 || observedAge < -5000)) || state.control.status !== 'active' || cell.status !== 'ready' || heartbeatAge < 0 || heartbeatAge > 60000 || expiry <= now ? 'uncertain' : 'recent'); } return result; } diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index 724ea32..ccc779b 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -111,6 +111,20 @@ test('observation freshness never renews worker evidence, and terminal states do source.status = 'observed'; source.snapshot.snapshot.control.status = 'paused'; assert.equal(activities(source, now).get('cell-1'), 'uncertain'); }); +test('an unknown external outcome remains uncertain after operational completion, retirement and pause', () => { + const source = parseSwarm(swarmPreview()).sources[0], state = source.snapshot.snapshot; + const now = Date.parse(source.snapshot.observedAt), task = state.tasks[0]; + task.status = 'completed'; + state.cells.find(cell => cell.id === task.owner).status = 'retired'; + state.control.status = 'paused'; + state.effects.push({ key: 'retire-unknown', kind: 'retire', state: 'unknown', owner: task.owner, + taskId: task.id, scope: 'cleanup', scopeId: task.owner, ownerEpoch: 1, controlEpoch: 1, + createdAt: source.snapshot.observedAt, updatedAt: source.snapshot.observedAt, requestDigest: 'b'.repeat(64) }); + assert.equal(activities(source, now).get(task.owner), 'uncertain'); + assert.equal(state.workerQuiescence, 'unverified'); + state.effects[0].state = 'succeeded'; + assert.equal(activities(source, now).get(task.owner), 'idle'); +}); test('consumer drops private extensions and fails closed on capabilities/identity/duplicate records', () => { const input = swarmPreview(); input.token = 'private'; input.sources[0].origin = 'private'; input.sources[0].snapshot.snapshot.tasks[2].candidate.path = 'private'; input.sources[0].snapshot.snapshot.paidBudget = { token: 'private' }; assert.equal(JSON.stringify(parseSwarm(input)).includes('private'), false); From a865f67a54c6c2d363f106c8b694801bd5022dc6 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:17:15 -0500 Subject: [PATCH 4/9] Distinguish recorded swarm states without animation --- docs/SWARM.md | 25 +++++-- src/swarm/main.ts | 41 ++++++++--- src/swarm/observatory.css | 54 +++++++++++--- src/swarm/renderer.ts | 147 ++++++++++++++++++++++++++++++-------- swarm.html | 14 ++-- test/swarm.test.mjs | 34 ++++++++- 6 files changed, 254 insertions(+), 61 deletions(-) diff --git a/docs/SWARM.md b/docs/SWARM.md index bc3aed0..91c46d4 100644 --- a/docs/SWARM.md +++ b/docs/SWARM.md @@ -65,6 +65,13 @@ with Bash. The integration pass added camera fitting around visible instruments, responsive reframing, measured label collision bounds, live reduced-motion changes and a trailing diagnostics update when on-demand rendering settles. +Static shapes also preserve status under reduced motion: authority plates use +a hollow keystone, observed idle cells an open ring, recent qualifying leased +work a bullseye, uncertain evidence an amber barred ring, and retained records +a broken grey ring. Unconfirmed running-work evidence is distinct from an +owned unknown external outcome in the inspector. Only an owned unknown outcome +outranks retirement or source staleness; it remains unresolved by completion. + Fresh observations do not establish worker activity. A cell needs an active controller, ready lifecycle, recent heartbeat, an unexpired running lease and no owned unknown outcome. Completed/cancelled operations do not animate as @@ -76,8 +83,8 @@ that quiescence is unverified. ## Development and verification -Run `npm ci`, `npm run build` and `npm run test:swarm`. Tests use the actual model -and preview modules and cover duplicate record IDs across authorities, complete +Run `npm ci`, `npm run build` and `npm run test:swarm`. Tests use the actual model, +marker, transport and preview modules and cover duplicate record IDs across authorities, complete 10,000-deep/wide navigation, stable layout, stale/lease/terminal activity gates, positive allowlists and missing-source behavior. Copy the built `dist` artifact to the consumer's ignored viewer directory for local integration; keep source @@ -90,6 +97,14 @@ context loss. The world had 10,003 indexed nodes; its overview drew 18 nodes and the selected neighborhood drew 10, with two GPU draw calls in 3D and no GL calls in 2D. Desktop, 615-pixel and 390-pixel layouts were checked. Reduced motion stopped an already-running renderer; 2D settled without an animation loop. -These are fixture/navigation checks, not live FACTORY health evidence. The -read-only observer was still offline at the last 2026-10-03 qualification check; -its owner must restore and qualify it before a fresh live witness is recorded. +These are fixture/navigation checks, not live FACTORY health evidence. + +After FACTORY's owner restored and qualified its observer, the actual Brain +Online BFF passed a fresh read at 2026-10-03T21:06:54Z: loaded build +`d9d87c6ebbd05b6c82bdbf0f7c537e76eb4b9a66`, paused admission at revision 92, +five cells, three tasks and six effects. Staff reads returned 200, anonymous +reads 401 and customer reads 404; snapshot/event responses were no-store and +contained no server credential. The actual host component and upstream artifact +also displayed these live records in a local qualification harness with a +development staff principal and five-second refreshes. This does not qualify +a production Supabase login or prove provider freshness or worker quiescence. diff --git a/src/swarm/main.ts b/src/swarm/main.ts index 6d5ee1f..b9feb87 100644 --- a/src/swarm/main.ts +++ b/src/swarm/main.ts @@ -2,7 +2,7 @@ import './swarm.css'; import './observatory.css'; import { activities, nodeKey, parseSwarm, selectionFragment, selectionFromFragment, SwarmIndex, type Node, type Selection, type Source, type Swarm } from './model'; import { readSwarmJson } from './transport'; -import { SwarmRenderer, sourceColor } from './renderer'; +import { activityEvidence, markerLegend, markerOf, markerTitle, SwarmRenderer, sourceColor, type Marker } from './renderer'; import { swarmPreview } from './preview'; const $ = (id: string): HTMLElement => document.getElementById(id)!; @@ -10,6 +10,12 @@ const element = (tag: T, text?: string, c const result = document.createElement(tag); if (text !== undefined) result.textContent = text; if (className) result.className = className; return result; }; const button = (text: string, action: () => void, pressed = false): HTMLButtonElement => { const result = element('button', text); result.type = 'button'; result.setAttribute('aria-pressed', String(pressed)); result.onclick = action; return result; }; +const markerChip = (marker: Marker, className: string): HTMLSpanElement => { + // The rail, the legend and the field all read the same marker vocabulary. + const chip = element('span', undefined, `marker ${className}`); + chip.dataset.marker = marker; chip.title = `${markerTitle[marker]} — ${markerLegend[marker]}`; + return chip; +}; const timestamp = (value: string): string => new Intl.DateTimeFormat(undefined, { timeZone: 'America/Bogota', dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value)); const taskLabel = (status: string) => status === 'completed' ? 'operation completed' : status; const empty = (): Swarm => ({ schemaVersion: 1, scope: 'operator-source-registry', observedAt: new Date().toISOString(), commands: false, sources: [] }); @@ -83,9 +89,11 @@ function paintNavigation(): void { // Exactly the renderer's deterministic plate colour, never a list position. item.style.setProperty('--accent', sourceColor(source.id)); item.dataset.status = status; - item.replaceChildren(element('span', undefined, 'source-dot'), element('strong', source.label, 'source-name'), - element('small', `${status} · ${cells.length} cells${evidenced ? ` · ${evidenced} evidenced` : ''}`)); - item.title = source.factoryId; sourceList.append(item); + const marker: Marker = status === 'observed' ? 'plate' : 'quiet'; + item.dataset.marker = marker; + item.replaceChildren(markerChip(marker, 'source-dot'), element('strong', source.label, 'source-name'), + element('small', `${status} · ${cells.length} recorded cells${evidenced ? ` · ${evidenced} with a running lease` : ''}`)); + item.title = `${source.factoryId} · ${markerLegend[marker]}`; sourceList.append(item); } const focus = focusNode(), children = focus ? index.children.get(focus.key) ?? [] : []; $('branch-heading').textContent = focus ? `DELEGATION · ${focus.label} · ${children.length} children` : 'DELEGATION · select a source'; @@ -93,10 +101,13 @@ function paintNavigation(): void { for (const key of children.slice(page * 128, (page + 1) * 128)) { const node = index.nodes.get(key)!; const item = button(node.label, () => select(node.selection), key === focus?.key); + const marker = markerOf(node); item.className = `branch-record activity-${node.activity}`; + item.dataset.marker = marker; item.style.setProperty('--accent', sourceColor(node.source.id)); - item.replaceChildren(element('span', undefined, 'branch-dot'), element('strong', node.label, 'branch-name'), - element('small', `${node.cell?.role ?? 'source'} · ${node.cell?.status ?? sourceStatus(node.source)} · ${index.children.get(key)?.length ?? 0} children`)); + item.title = `${node.source.factoryId} / ${node.label} · ${markerLegend[marker]}`; + item.replaceChildren(markerChip(marker, 'branch-dot'), element('strong', node.label, 'branch-name'), + element('small', `${node.cell?.role ?? 'source'} · ${node.cell?.status ?? sourceStatus(node.source)} · ${markerTitle[marker].toLowerCase()} · ${index.children.get(key)?.length ?? 0} children`)); $('branch-list').append(item); } if (children.length > 128) { @@ -127,7 +138,20 @@ function evidenceList(container: HTMLElement, pairs: Array<[string, string]>): v function paintInspector(): void { const container = $('swarm-inspect'); container.replaceChildren(); container.style.removeProperty('--accent'); - if (!selection) { container.append(element('p', 'THE OBSERVATORY', 'evidence-kicker'), element('h2', 'A constellation of work.'), element('p', 'Move from registered authorities to their coordinators and delegated cells. Search reaches every recorded cell, task and external effect.'), element('p', 'This view follows observations. It cannot start, stop or resume workers.'), element('p', 'Select a source to inspect its provenance and recorded work.')); return; } + if (!selection) { + container.append(element('p', 'THE OBSERVATORY', 'evidence-kicker'), element('h2', 'A constellation of work.'), + element('p', 'Move from registered authorities to their coordinators and delegated cells. Search reaches every recorded cell, task and external effect.'), + element('p', 'This view follows observations. It cannot start, stop or resume workers.'), + element('h3', 'Reading the markers')); + const guide = element('ul', undefined, 'marker-guide'); + for (const marker of ['plate', 'idle', 'recent', 'uncertain', 'quiet'] as Marker[]) { + const row = element('li'); + row.append(markerChip(marker, 'guide-dot'), element('strong', markerTitle[marker]), element('small', markerLegend[marker])); + guide.append(row); + } + container.append(guide, element('p', 'Markers describe recorded observations only. They never establish host health, physical worker activity or quiescence, and shape rather than animation carries each state.', 'evidence-note')); + return; + } const source = index.sources.get(selection.sourceId)!; container.style.setProperty('--accent', sourceColor(source.id)); container.append(element('p', selection.kind === 'source' ? 'REGISTERED AUTHORITY' : `RECORDED ${selection.kind.toUpperCase()}`, 'evidence-kicker'), @@ -146,7 +170,8 @@ function paintInspector(): void { } else if (selection.kind === 'cell') { const cell = state.cells.find(cell => cell.id === selection!.id)!; container.append(element('p', cell.purpose)); - evidenceList(container, [['Role / lifecycle', `${cell.role} / ${cell.status}`], ['Recorded parent', cell.parentId ?? 'Local root'], ['Heartbeat', timestamp(cell.heartbeat)], ['Activity evidence', activities(source, Date.now(), swarm.sample).get(cell.id) ?? 'idle'], ['Logical allocation', `$${(cell.allocationCents / 100).toFixed(2)} · logical-allocation`], ['Worker quiescence', 'unverified']]); + const node = index.nodeFor(selection)!; + evidenceList(container, [['Role / lifecycle', `${cell.role} / ${cell.status}`], ['Recorded parent', cell.parentId ?? 'Local root'], ['Heartbeat', timestamp(cell.heartbeat)], ['Activity evidence', activityEvidence({ ...node, activity: activities(source, Date.now(), swarm.sample).get(cell.id) ?? 'idle' })], ['Logical allocation', `$${(cell.allocationCents / 100).toFixed(2)} · logical-allocation`], ['Worker quiescence', 'unverified']]); container.append(element('h3', 'Owned tasks')); appendTasks(container, source, state.tasks.filter(task => task.owner === cell.id)); container.append(element('h3', 'Owned external effects')); appendEffects(container, source, state.effects.filter(effect => effect.owner === cell.id)); if (cell.parentId && !state.cells.some(parent => parent.id === cell.parentId)) container.append(element('p', 'The parent is absent from this observation. Its position does not prove a delegation link.', 'evidence-note')); diff --git a/src/swarm/observatory.css b/src/swarm/observatory.css index 4a6b438..7faaa18 100644 --- a/src/swarm/observatory.css +++ b/src/swarm/observatory.css @@ -86,10 +86,10 @@ input[type=search]::placeholder{color:#4e5b6d} #source-list button::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:.55} #source-list button:hover::before,#source-list button[aria-pressed=true]::before{opacity:1;box-shadow:0 0 12px var(--accent)} #source-list button[aria-pressed=true]{background:linear-gradient(90deg,rgba(127,209,222,.1),transparent 72%)} -.source-dot{grid-row:1/3;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 1px #04060b,0 0 10px var(--accent)} +.source-dot{grid-row:1/3} .source-name{font:400 16px/1.25 var(--serif);letter-spacing:-.015em;color:#e6eef7;overflow-wrap:anywhere} #source-list button small{grid-column:2;font:9px var(--mono);letter-spacing:.08em;color:var(--quiet);margin-top:4px;text-transform:uppercase} -#source-list button.source-stale .source-dot,#source-list button.source-unavailable .source-dot{background:#6b7789;box-shadow:none} +#source-list button.source-stale,#source-list button.source-unavailable{--accent:#7d8798} #source-list button.source-stale,#source-list button.source-unavailable{color:var(--muted)} #source-list button.source-stale .source-name,#source-list button.source-unavailable .source-name{color:#b0bccb} #source-list button.source-unavailable::before{background:repeating-linear-gradient(180deg,#6b7789 0 3px,transparent 3px 7px);opacity:.8} @@ -97,9 +97,7 @@ input[type=search]::placeholder{color:#4e5b6d} #branch-list{max-height:30vh;overflow:auto;display:grid} #branch-list button{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:9px;align-items:baseline;width:100%;text-align:left; border:0;border-bottom:1px solid rgba(150,180,210,.07);border-radius:0;background:transparent;padding:9px 4px;margin:0} -.branch-dot{width:5px;height:5px;border-radius:50%;background:#55637a;transform:translateY(-2px)} -.activity-recent .branch-dot{background:var(--accent);box-shadow:0 0 8px var(--accent)} -.activity-uncertain .branch-dot{background:var(--warn);box-shadow:0 0 8px rgba(224,168,92,.5)} +.branch-dot{width:9px;height:9px;transform:translateY(-1px)} .branch-name{font:11px var(--mono);color:#cdd9e8;overflow-wrap:anywhere} #branch-list button small{grid-column:2;display:block;font:9px var(--mono);color:var(--quiet);margin-top:4px} #branch-list button[aria-pressed=true] .branch-name{color:#eafcff} @@ -135,16 +133,42 @@ input[type=search]::placeholder{color:#4e5b6d} .space-caption p{font:10px/1.75 var(--mono);color:var(--faint);margin:8px 0 0;max-width:36ch} .field-legend{display:flex;flex-wrap:wrap;gap:6px 16px;list-style:none;margin:16px 0 0;padding:14px 0 0;border-top:1px solid var(--rule);font:9px var(--mono);letter-spacing:.07em;color:var(--quiet);text-transform:uppercase} .field-legend li{display:flex;align-items:center;gap:7px} -.field-legend i{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 9px var(--accent)} -.legend-uncertain i{background:var(--warn);box-shadow:0 0 9px rgba(224,168,92,.55)} -.legend-quiet i{background:#6b7789;box-shadow:none} -.legend-plate i{background:transparent;border:1px solid var(--accent);box-shadow:0 0 9px rgba(127,209,222,.25)} -.has-selection .field-legend{display:none} +.legend-note{font:9px/1.6 var(--mono)!important;letter-spacing:.07em;color:var(--faint);margin:9px 0 0!important;text-transform:uppercase} +.has-selection .field-legend,.has-selection .legend-note{display:none} + +/* ── Static status markers ────────────────────────────────────────────────── + * The same five engraved forms the GL/2D field draws, rebuilt in pure CSS for + * the legend, the atlas rail and the reading guide. Shape alone identifies a + * state: nothing here animates, and nothing depends on glow to be read. + * A plate is an authority, never evidence of a working host. */ +.marker{position:relative;box-sizing:border-box;display:inline-block;flex:none;width:10px;height:10px;border-radius:50%;background:none;box-shadow:none} +.marker::after,.marker::before{content:'';position:absolute;box-sizing:border-box} +/* plate: bezel ring and a hollow keystone, deliberately no filled core */ +.marker[data-marker=plate]{border:1px solid var(--accent);opacity:.95} +.marker[data-marker=plate]::after{inset:2.5px;border:1px solid var(--accent);border-radius:1px;transform:rotate(45deg);opacity:.72} +/* idle: one open hairline ring — observed, no running lease */ +.marker[data-marker=idle]{border:1px solid var(--accent);opacity:.62} +/* recent: bullseye — ring, gap, separated core */ +.marker[data-marker=recent]{border:1px solid var(--accent)} +.marker[data-marker=recent]::after{inset:2.5px;border-radius:50%;background:var(--accent)} +/* uncertain: amber ring struck through */ +.marker[data-marker=uncertain]{border:1px solid var(--warn)} +.marker[data-marker=uncertain]::after{left:1.5px;right:1.5px;top:50%;height:1px;margin-top:-.5px;background:var(--warn)} +/* quiet: a broken ring, retained but not live */ +.marker[data-marker=quiet]{border:1px dashed #7d8798;opacity:.9} +.field-legend .marker{width:9px;height:9px} /* ── Field labels ───────────────────────────────────────────────────────── */ .swarm-label{position:absolute;top:0;left:0;font:10px var(--mono);letter-spacing:.04em;color:#a2b6c7;white-space:nowrap;max-width:168px;overflow:hidden;text-overflow:ellipsis; padding:2px 6px;border-radius:2px;background:rgba(5,8,14,.42);text-shadow:0 1px 6px #04060b} +.swarm-label:not(.source-label)[data-marker]::before{content:'';display:inline-block;box-sizing:border-box;width:7px;height:7px;margin-right:6px;border-radius:50%;vertical-align:.5px} +.swarm-label[data-marker=idle]::before{border:1px solid var(--accent);opacity:.6} +.swarm-label[data-marker=recent]::before{border:1px solid var(--accent);box-shadow:inset 0 0 0 1.6px #060a11,inset 0 0 0 4px var(--accent)} +.swarm-label[data-marker=uncertain]::before{border:1px solid var(--warn);background:linear-gradient(var(--warn),var(--warn)) center/4px 1px no-repeat} +.swarm-label[data-marker=quiet]::before{border:1px dashed #7d8798} .swarm-label.active{color:#d6f4fb;box-shadow:inset 0 0 0 1px rgba(127,209,222,.22)} +.swarm-label[data-marker=uncertain]{color:#e3c79c} +.swarm-label[data-marker=quiet]{color:#8c99ac} .swarm-label.source-label{display:flex;flex-direction:column;align-items:center;gap:2px;font:400 18px/1.1 var(--serif);letter-spacing:-.01em;color:#dcecf4;background:transparent;padding:0;max-width:220px} .swarm-label.source-label i{font:9px var(--mono);font-style:normal;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);opacity:.75} .swarm-label.selected{color:#f1fdff;background:rgba(127,209,222,.12);box-shadow:inset 0 0 0 1px rgba(127,209,222,.4)} @@ -167,6 +191,12 @@ input[type=search]::placeholder{color:#4e5b6d} .swarm-inspector button:not(#swarm-close):hover{color:#eafcff;background:rgba(127,209,222,.06)} .evidence-note{border:0!important;border-left:2px solid var(--warn)!important;background:rgba(224,168,92,.07)!important;border-radius:0;padding:11px 14px!important;color:#dcc49d!important;margin:14px 0} +.marker-guide{list-style:none;margin:14px 0 4px;padding:0;display:grid;gap:1px} +.marker-guide li{display:grid;grid-template-columns:12px minmax(0,1fr);gap:4px 11px;align-items:center;padding:11px 0;border-top:1px solid rgba(150,180,210,.09)} +.marker-guide .guide-dot{grid-row:1/3} +.marker-guide strong{font:9px var(--mono);font-weight:400;letter-spacing:.13em;text-transform:uppercase;color:#dce7f2} +.marker-guide small{font:10px/1.5 var(--mono);color:var(--quiet)} + /* ── Tracking ledger and instruments ────────────────────────────────────── */ .swarm-timeline{position:absolute;z-index:15;bottom:24px;left:340px;width:min(470px,calc(100% - 800px));padding:13px 16px;border-radius:3px} .swarm-timeline summary{display:flex;gap:10px;align-items:baseline;cursor:pointer;margin:0;font:var(--kicker);letter-spacing:.16em;color:var(--muted);text-transform:uppercase} @@ -222,6 +252,7 @@ footer b{color:var(--warn);font-weight:400} .space-caption,.navigation-hidden .space-caption{left:18px;bottom:24px;max-width:calc(100% - 36px)} .observatory-title,.has-selection .observatory-title{font-size:clamp(26px,7vw,36px)} .field-legend,.has-selection .field-legend{display:flex} + .legend-note,.has-selection .legend-note{display:block} .swarm-measurements{right:14px;bottom:14px;max-width:62%} .swarm-inspector,.has-selection .swarm-inspector{display:block;position:relative;right:auto;left:auto;top:auto;bottom:auto;width:auto;margin:16px 12px;padding:22px;backdrop-filter:none;background:var(--plate-solid)} footer{height:auto;padding:14px 16px} @@ -232,7 +263,8 @@ footer b{color:var(--warn);font-weight:400} .read-only{display:none} .swarm-space{height:clamp(500px,62vh,600px)} .space-controls{flex-wrap:wrap} - .field-legend li:nth-child(n+3){display:none} + .field-legend{gap:4px 10px;font-size:8.5px;letter-spacing:.04em} + .legend-note{display:none} .space-caption p{display:none} .swarm-inspector h2{font-size:22px} } diff --git a/src/swarm/renderer.ts b/src/swarm/renderer.ts index b2c2af7..0b2b8cc 100644 --- a/src/swarm/renderer.ts +++ b/src/swarm/renderer.ts @@ -1,6 +1,6 @@ import { BufferGeometry, Color, Float32BufferAttribute, LineBasicMaterial, LineSegments, PerspectiveCamera, Points, Scene, ShaderMaterial, Vector3, WebGLRenderer } from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; -import type { Node, Point } from './model'; +import type { Node, Point, Source } from './model'; /** Deterministic authority palette. The navigation indicators read the same * function, so a plate in the field and its index entry cannot disagree. */ @@ -10,34 +10,105 @@ export const sourceHue = (id: string) => hues[[...id].reduce((sum, char) => (sum export const sourceColor = sourceHue; export const uncertainHue = '#e0a85c'; export const retiredHue = '#7d8798'; -const tint = (node: Node): string => node.source.status !== 'observed' ? retiredHue : node.activity === 'uncertain' ? uncertainHue : node.cell?.status === 'retired' ? retiredHue : sourceHue(node.source.id); + +/** The five static status forms. Shape, not motion or brightness alone, + * carries the meaning, so every state stays recognisable with reduced motion: + * plate - a registered authority's engraved disc. Its centre is a hollow + * keystone, never a filled core: a plate is not worker activity. + * idle - observed record, no running lease: a single open hairline ring. + * recent - unexpired running lease on a fresh heartbeat: ring plus a + * separated inner core (bullseye). Observation, not host health. + * uncertain - unconfirmed running-work evidence or an owned unknown outcome: + * amber ring struck through. + * quiet - retained record (retired, reserved, stale or unavailable): + * a broken dashed ring with no core. */ +export type Marker = 'plate' | 'idle' | 'recent' | 'uncertain' | 'quiet'; +// Canonical envelopes are replaced on observation; clock ticks only update +// source availability/activity. Cache once per envelope, with no retained owner +// after that observation is collected and no per-cell scan of all effects. +const unknownOwners = new WeakMap, Set>(); +export function hasUnknownOutcome(source: Source, cellId: string): boolean { + const envelope = source.snapshot; if (!envelope) return false; + let owners = unknownOwners.get(envelope); + if (!owners) { owners = new Set(envelope.snapshot.effects.filter(effect => effect.state === 'unknown').map(effect => effect.owner)); unknownOwners.set(envelope, owners); } + return owners.has(cellId); +} +export const markerOf = (node: Node): Marker => !node.cell ? node.source.status === 'observed' ? 'plate' : 'quiet' + // An unknown owned effect outranks completion, retirement and pause. + : hasUnknownOutcome(node.source, node.cell.id) ? 'uncertain' + : node.source.status !== 'observed' || node.cell.status === 'retired' || node.cell.status === 'reserved' ? 'quiet' + : node.activity === 'uncertain' ? 'uncertain' + : node.activity === 'recent' ? 'recent' : 'idle'; +export const markerForm: Record = { plate: 0, idle: 1, recent: 2, uncertain: 3, quiet: 4 }; +/** Wording is deliberately about the record, never about physical health. */ +export const markerLegend: Record = { + plate: 'registered authority · not worker activity', + idle: 'observed record · no running lease', + recent: 'running lease · recent heartbeat recorded', + uncertain: 'running-work evidence unconfirmed or owned outcome unknown', + quiet: 'retained record · retired, reserved or stale', +}; +export const markerTitle: Record = { + plate: 'Authority plate', idle: 'Observed idle', recent: 'Recent leased work', uncertain: 'Uncertain', quiet: 'Retained record', +}; +export function activityEvidence(node: Node): string { + if (node.cell && hasUnknownOutcome(node.source, node.cell.id)) return 'owned external outcome unknown · unresolved by completion or retirement'; + if (node.source.status !== 'observed') return 'retained observation · current activity unconfirmed'; + if (node.cell?.status === 'retired' || node.cell?.status === 'reserved') return `${node.cell.status} record · worker activity unverified`; + return node.activity === 'uncertain' ? 'running-work evidence unconfirmed · inspect controller, lease and heartbeat' + : node.activity === 'recent' ? 'running lease unexpired · heartbeat recent in this observation' + : 'observed record · no running lease'; +} +export const markerColor = (node: Node): string => { + const marker = markerOf(node); + if (marker === 'uncertain') return uncertainHue; + if (marker === 'quiet' || node.source.status !== 'observed') return retiredHue; + return sourceHue(node.source.id); +}; +const tint = markerColor; /** One batched point program: engraved plates, cell rings, cores and haloes. * Depth attenuation keeps a deep field legible without a second pass. */ -const vertexShader = `attribute float aSize; attribute float aActive; attribute float aFill; attribute float aPlate; attribute float aSelected; -varying vec3 vColor; varying float vActive; varying float vFill; varying float vPlate; varying float vSelected; varying float vFade; uniform float uTime; +const vertexShader = `attribute float aSize; attribute float aActive; attribute float aForm; attribute float aSelected; +varying vec3 vColor; varying float vForm; varying float vSelected; varying float vFade; uniform float uTime; void main(){ - vColor=color; vActive=aActive; vFill=aFill; vPlate=aPlate; vSelected=aSelected; + vColor=color; vForm=aForm; vSelected=aSelected; vec4 p=modelViewMatrix*vec4(position,1.); float depth=max(1.,-p.z); vFade=clamp(1.25-depth/5200.,.34,1.); - float breath=1.+aActive*.09*sin(uTime*2.+position.x*.12); + // Optional breathing, a courtesy only: aActive is zero under reduced motion + // and recent work is already distinguished by its static bullseye form. + float breath=1.+aActive*.07*sin(uTime*2.+position.x*.12); gl_PointSize=clamp(aSize*breath*(1.+aSelected*.26)*200./depth,7.,78.); gl_Position=projectionMatrix*p; }`; -const fragmentShader = `varying vec3 vColor; varying float vActive; varying float vFill; varying float vPlate; varying float vSelected; varying float vFade; +const fragmentShader = `varying vec3 vColor; varying float vForm; varying float vSelected; varying float vFade; void main(){ vec2 uv=gl_PointCoord-.5; float d=length(uv)*2.; if(d>1.)discard; - float core=(1.-smoothstep(.0,.40,d))*vFill; - float ring=smoothstep(.49,.58,d)*(1.-smoothstep(.70,.80,d)); - float plate=vPlate*smoothstep(.86,.91,d)*(1.-smoothstep(.97,1.,d)); - float halo=exp(-d*d*3.6)*(.10+.12*vFill); + float plate=step(vForm,.5); + float idle=step(.5,vForm)*step(vForm,1.5); + float recent=step(1.5,vForm)*step(vForm,2.5); + float uncertain=step(2.5,vForm)*step(vForm,3.5); + float quiet=step(3.5,vForm); + // One shared engraved outline; quiet records break it into nine dashes. + float ring=smoothstep(.47,.55,d)*(1.-smoothstep(.65,.73,d)); + float dash=step(.42,fract(atan(uv.y,uv.x)*1.43239)); + float outline=ring*(1.-quiet*dash); + // Recent: a separated core inside the ring reads as a bullseye at any size. + float core=recent*(1.-smoothstep(.25,.33,d)); + // Uncertain: the ring is struck through, a caution glyph rather than a glow. + float bar=uncertain*(1.-smoothstep(.06,.10,abs(uv.y)*2.))*(1.-smoothstep(.30,.42,abs(uv.x)*2.)); + // Authority plate: wide bezel and a hollow keystone; deliberately no core. + float bezel=plate*smoothstep(.84,.90,d)*(1.-smoothstep(.96,1.,d)); + float keystone=plate*(1.-smoothstep(.03,.06,abs((abs(uv.x)+abs(uv.y))*2.-.36))); + float halo=exp(-d*d*3.4)*(.05+.13*recent+.04*plate); vec2 q=abs(uv)*2.; - float spike=vSelected*max(exp(-q.x*22.),exp(-q.y*22.))*(1.-smoothstep(.2,1.,d))*.55; + float spike=vSelected*max(exp(-q.x*22.),exp(-q.y*22.))*(1.-smoothstep(.2,1.,d))*.5; float select=vSelected*smoothstep(.80,.88,d)*(1.-smoothstep(.95,1.,d)); - vec3 lit=mix(vColor,vec3(.93,.99,1.),clamp(core*.55+spike*.7+select*.8,0.,1.)); - float alpha=(ring*.9+core*.85+halo+plate*.55+spike*.6+select*.95)*vFade; + float weight=outline*(recent+idle*.62+uncertain*.85+quiet*.5+plate*.78); + vec3 lit=mix(vColor,vec3(.93,.99,1.),clamp(core*.5+spike*.7+select*.8,0.,1.)); + float alpha=(weight+core*.95+bar*.9+bezel*.6+keystone*.5+halo+spike*.6+select*.95)*vFade; gl_FragColor=vec4(lit,clamp(alpha,0.,1.)); }`; @@ -151,7 +222,7 @@ export class SwarmRenderer { // The plan view has no animated shader; it only needs observation/input redraws. this.active = !!this.gl && !this.motion.matches && nodes.some(node => node.activity === 'recent'); if (this.gl) { - const pos: number[] = [], colors: number[] = [], sizes: number[] = [], active: number[] = [], fill: number[] = [], plate: number[] = [], chosen: number[] = []; + const pos: number[] = [], colors: number[] = [], sizes: number[] = [], active: number[] = [], form: number[] = [], chosen: number[] = []; const linkPos: number[] = [], linkColors: number[] = []; const byKey = new Map(nodes.map(node => [node.key, node])); // Static engraved source plates share the delegation line batch. They are @@ -181,10 +252,13 @@ export class SwarmRenderer { pos.push(node.point.x, node.point.y, node.point.z); const own = new Color(tint(node)), color = new Color(node.key === selected ? '#e6fff8' : tint(node)); colors.push(color.r, color.g, color.b); - sizes.push(!node.cell ? 21 : node.key === selected ? 17 : node.cell.role === 'coordinator' ? 13.5 : node.cell.role === 'verifier' ? 10 : 8.5); - active.push(!this.motion.matches && node.activity === 'recent' ? 1 : 0); - fill.push(node.source.status === 'observed' && node.cell?.status !== 'retired' && node.cell?.status !== 'reserved' && node.activity !== 'uncertain' ? 1 : 0); - plate.push(node.cell ? 0 : 1); chosen.push(node.key === selected ? 1 : 0); + const marker = markerOf(node); + // Evidenced and uncertain records are also drawn slightly larger, so + // the distinction survives a small point without relying on motion. + const emphasis = marker === 'recent' ? 1.3 : marker === 'uncertain' ? 1.16 : marker === 'quiet' ? .92 : 1; + sizes.push((!node.cell ? 21 : node.key === selected ? 17 : node.cell.role === 'coordinator' ? 13.5 : node.cell.role === 'verifier' ? 10 : 8.5) * (node.cell ? emphasis : 1)); + active.push(!this.motion.matches && marker === 'recent' ? 1 : 0); + form.push(markerForm[marker]); chosen.push(node.key === selected ? 1 : 0); const parent = node.parent ? byKey.get(node.parent) : undefined; if (parent?.cell && node.cell?.parentId) { // Lifted delegation ribbons fading parent to child, one line batch. @@ -199,7 +273,7 @@ export class SwarmRenderer { const geometry = new BufferGeometry(); geometry.setAttribute('position', new Float32BufferAttribute(pos, 3)); geometry.setAttribute('color', new Float32BufferAttribute(colors, 3)); geometry.setAttribute('aSize', new Float32BufferAttribute(sizes, 1)); geometry.setAttribute('aActive', new Float32BufferAttribute(active, 1)); - geometry.setAttribute('aFill', new Float32BufferAttribute(fill, 1)); geometry.setAttribute('aPlate', new Float32BufferAttribute(plate, 1)); + geometry.setAttribute('aForm', new Float32BufferAttribute(form, 1)); geometry.setAttribute('aSelected', new Float32BufferAttribute(chosen, 1)); if (this.points) { this.points.geometry.dispose(); this.points.geometry = geometry; } else { this.points = new Points(geometry, new ShaderMaterial({ vertexShader, fragmentShader, vertexColors: true, transparent: true, depthWrite: false, uniforms: { uTime: { value: 0 } } })); this.scene.add(this.points); } @@ -212,9 +286,11 @@ export class SwarmRenderer { this.labels.replaceChildren(); this.labelElements = []; for (const node of labelled) { const span = document.createElement('span'); - span.className = `swarm-label${!node.cell ? ' source-label' : ''}${node.key === selected ? ' selected' : ''}${node.activity === 'recent' ? ' active' : ''}`; + const marker = markerOf(node); + span.className = `swarm-label${!node.cell ? ' source-label' : ''}${node.key === selected ? ' selected' : ''}${marker === 'recent' ? ' active' : ''}`; + span.dataset.marker = marker; span.textContent = node.label.length > 24 ? `${node.label.slice(0, 22)}…` : node.label; - span.title = `${node.source.factoryId} / ${node.label}`; + span.title = `${node.source.factoryId} / ${node.label} · ${markerLegend[marker]}`; span.dataset.key = node.key; span.style.setProperty('--accent', sourceHue(node.source.id)); if (!node.cell) { const kicker = document.createElement('i'); kicker.textContent = node.source.status === 'observed' ? 'authority' : node.source.status; span.append(kicker); } @@ -300,14 +376,25 @@ export class SwarmRenderer { context.strokeStyle = lit ? `${tint(node)}c0` : `${tint(node)}30`; context.lineWidth = lit ? 1.4 : 1; context.stroke(); } for (const node of this.nodes) { - const point = this.projected.get(node.key)!, hue = tint(node); - const radius = !node.cell ? 14 : node.key === this.selected ? 10 : node.cell.role === 'coordinator' ? 9 : node.cell.role === 'verifier' ? 6 : 5; - const gradient = context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius * 3.2); - gradient.addColorStop(0, `${hue}cc`); gradient.addColorStop(.32, `${hue}55`); gradient.addColorStop(1, `${hue}00`); - context.fillStyle = gradient; context.beginPath(); context.arc(point.x, point.y, radius * 3.2, 0, Math.PI * 2); context.fill(); - context.strokeStyle = hue; context.lineWidth = node.cell ? 1 : 1.4; context.beginPath(); context.arc(point.x, point.y, radius * .78, 0, Math.PI * 2); context.stroke(); - if (!node.cell) { context.strokeStyle = `${hue}66`; context.beginPath(); context.arc(point.x, point.y, radius * 1.5, 0, Math.PI * 2); context.stroke(); } - if (node.source.status === 'observed' && node.cell?.status !== 'retired' && node.cell?.status !== 'reserved' && node.activity !== 'uncertain') { context.fillStyle = hue; context.beginPath(); context.arc(point.x, point.y, radius * .34, 0, Math.PI * 2); context.fill(); } + const point = this.projected.get(node.key)!, hue = tint(node), marker = markerOf(node); + const scale = marker === 'recent' ? 1.3 : marker === 'uncertain' ? 1.16 : marker === 'quiet' ? .92 : 1; + const radius = (!node.cell ? 14 : node.key === this.selected ? 10 : node.cell.role === 'coordinator' ? 9 : node.cell.role === 'verifier' ? 6 : 5) * (node.cell ? scale : 1); + const bloom = marker === 'recent' ? 2.6 : marker === 'plate' ? 3.2 : 1.9; + const gradient = context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius * bloom); + gradient.addColorStop(0, `${hue}${marker === 'idle' || marker === 'quiet' ? '44' : '88'}`); gradient.addColorStop(.34, `${hue}33`); gradient.addColorStop(1, `${hue}00`); + context.fillStyle = gradient; context.beginPath(); context.arc(point.x, point.y, radius * bloom, 0, Math.PI * 2); context.fill(); + context.setLineDash(marker === 'quiet' ? [2.4, 2.4] : []); + context.strokeStyle = marker === 'idle' ? `${hue}b0` : marker === 'quiet' ? `${hue}a0` : hue; + context.lineWidth = marker === 'plate' ? 1.4 : marker === 'recent' ? 1.3 : 1; + context.beginPath(); context.arc(point.x, point.y, radius * .78, 0, Math.PI * 2); context.stroke(); + context.setLineDash([]); + if (marker === 'plate') { + // Bezel plus a hollow keystone: an authority, never evidenced activity. + context.strokeStyle = `${hue}66`; context.beginPath(); context.arc(point.x, point.y, radius * 1.5, 0, Math.PI * 2); context.stroke(); + context.beginPath(); context.moveTo(point.x, point.y - radius * .32); context.lineTo(point.x + radius * .32, point.y); context.lineTo(point.x, point.y + radius * .32); context.lineTo(point.x - radius * .32, point.y); context.closePath(); context.stroke(); + } + if (marker === 'recent') { context.fillStyle = hue; context.beginPath(); context.arc(point.x, point.y, radius * .34, 0, Math.PI * 2); context.fill(); } + if (marker === 'uncertain') { context.strokeStyle = hue; context.lineWidth = 1.2; context.beginPath(); context.moveTo(point.x - radius * .46, point.y); context.lineTo(point.x + radius * .46, point.y); context.stroke(); } if (node.key === this.selected) { context.strokeStyle = '#d2fff0'; context.lineWidth = 1.2; context.beginPath(); context.arc(point.x, point.y, radius + 5, 0, Math.PI * 2); context.stroke(); diff --git a/swarm.html b/swarm.html index b7be622..0f92322 100644 --- a/swarm.html +++ b/swarm.html @@ -40,12 +40,14 @@
A wider mind.

Orbit the recorded world. Enter a source. Follow its work.

-
    -
  • unexpired lease · recent heartbeat
  • -
  • uncertain evidence
  • -
  • retired · reserved · stale
  • -
  • authority plate
  • +
      +
    • authority plate · not worker activity
    • +
    • observed · no running lease
    • +
    • running lease · recent heartbeat
    • +
    • work unconfirmed · or outcome unknown
    • +
    • retained · retired, reserved or stale
    +

    Shapes, not motion: recorded observations only, never host or worker health.

LIVE TRACKING
@@ -53,7 +55,7 @@ - + diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index ccc779b..09e62cc 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -5,13 +5,14 @@ import { performance } from 'node:perf_hooks'; import ts from 'typescript'; const generated = new URL('../.vite/swarm-tests/', import.meta.url); await mkdir(generated, { recursive: true }); -for (const name of ['model', 'preview', 'transport']) { +for (const name of ['model', 'preview', 'transport', 'renderer']) { const source = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); await writeFile(new URL(`${name}.mjs`, generated), ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText); } const { parseSwarm, SwarmIndex, nodeKey, activities, selectionFragment, selectionFromFragment } = await import(new URL('model.mjs', generated)); const { swarmPreview } = await import(new URL('preview.mjs', generated)); const { readSwarmJson } = await import(new URL('transport.mjs', generated)); +const { markerOf, activityEvidence } = await import(new URL('renderer.mjs', generated)); test('deep links bind a reused registry alias to its exact factory and reject ambiguous/missing authority', () => { const swarm = swarmPreview(), source = swarm.sources[0], selected = { sourceId: source.id, kind: 'cell', id: 'root' }; @@ -125,6 +126,37 @@ test('an unknown external outcome remains uncertain after operational completion state.effects[0].state = 'succeeded'; assert.equal(activities(source, now).get(task.owner), 'idle'); }); + +test('static markers distinguish unconfirmed running work, retained lifecycles and unresolved effects', () => { + const node = change => { + const input = swarmPreview(), source = input.sources[0], state = source.snapshot.snapshot; + change?.(source, state); + const parsed = parseSwarm(input), index = new SwarmIndex(parsed); + return index.nodeFor({ sourceId: source.id, kind: 'cell', id: 'cell-1' }); + }; + const marker = change => markerOf(node(change)); + const pausedEvidence = activityEvidence(node((_source, state) => { state.control.status = 'paused'; })); + assert.match(pausedEvidence, /running-work evidence unconfirmed/); + assert.doesNotMatch(pausedEvidence, /owned external outcome unknown/); + assert.equal(marker(), 'recent'); + assert.equal(marker((_source, state) => { state.control.status = 'paused'; }), 'uncertain'); + assert.equal(marker((_source, state) => { state.tasks[0].leaseExpiry = new Date(Date.now() - 1000).toISOString(); }), 'uncertain'); + assert.equal(marker((_source, state) => { state.cells[1].status = 'retired'; }), 'quiet'); + assert.equal(marker((_source, state) => { state.cells[1].status = 'reserved'; }), 'quiet'); + assert.equal(marker(source => { source.status = 'stale'; }), 'quiet'); + assert.equal(marker((_source, state) => { state.tasks[0].status = 'completed'; }), 'idle'); + assert.equal(marker((source, state) => { + source.status = 'stale'; state.control.status = 'paused'; state.cells[1].status = 'retired'; state.tasks[0].status = 'completed'; + state.effects.push({ key: 'owned-unknown', kind: 'retire', state: 'unknown', owner: 'cell-1', taskId: 'implementation', + scope: 'cleanup', scopeId: 'cell-1', ownerEpoch: 1, controlEpoch: 3, createdAt: source.snapshot.observedAt, + updatedAt: source.snapshot.observedAt, requestDigest: 'b'.repeat(64) }); + }), 'uncertain'); + assert.equal(marker((source, state) => { + state.effects.push({ key: 'other-owner-unknown', kind: 'retire', state: 'unknown', owner: 'cell-2', taskId: 'retire-operation', + scope: 'cleanup', scopeId: 'cell-2', ownerEpoch: 1, controlEpoch: 3, createdAt: source.snapshot.observedAt, + updatedAt: source.snapshot.observedAt, requestDigest: 'b'.repeat(64) }); + }), 'recent'); +}); test('consumer drops private extensions and fails closed on capabilities/identity/duplicate records', () => { const input = swarmPreview(); input.token = 'private'; input.sources[0].origin = 'private'; input.sources[0].snapshot.snapshot.tasks[2].candidate.path = 'private'; input.sources[0].snapshot.snapshot.paidBudget = { token: 'private' }; assert.equal(JSON.stringify(parseSwarm(input)).includes('private'), false); From bca677965a221153ab6917620982cd1cbd3c9c94 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:53:12 -0500 Subject: [PATCH 5/9] Add expanded Observatory analytics and worker detail instruments --- docs/OBSERVATORY_INSIGHTS.md | 13 +++ package.json | 2 +- src/swarm/insights-model.ts | 98 ++++++++++++++++++++++ src/swarm/insights-preview.ts | 28 +++++++ src/swarm/insights.css | 16 ++++ src/swarm/insights.ts | 153 ++++++++++++++++++++++++++++++++++ src/swarm/main.ts | 9 +- test/insights.test.mjs | 46 ++++++++++ 8 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 docs/OBSERVATORY_INSIGHTS.md create mode 100644 src/swarm/insights-model.ts create mode 100644 src/swarm/insights-preview.ts create mode 100644 src/swarm/insights.css create mode 100644 src/swarm/insights.ts create mode 100644 test/insights.test.mjs diff --git a/docs/OBSERVATORY_INSIGHTS.md b/docs/OBSERVATORY_INSIGHTS.md new file mode 100644 index 0000000..122008c --- /dev/null +++ b/docs/OBSERVATORY_INSIGHTS.md @@ -0,0 +1,13 @@ +# Observatory instruments + +The expanded instruments below the spatial field provide analytics, a timestamped timeline, FLUJO worker nodes, conversations with tool calls/results, and separate Modal/Fly inventory. The sticky navigation jumps between sections and filters them by registered authority. Conversations open at the newest message; refresh preserves a reader's position and expanded tool calls when the transcript is unchanged. + +The existing `brain-swarm/1` exact-origin/frame/channel transport carries an optional `model.observatory` detail envelope. `insights-model.ts` is the positive allowlist shared verbatim with Brain Online's `factory-observatory.ts`. It requires `schemaVersion: 1`, `scope: factory-observatory`, `commands: false`, and sources matching the controller registry's exact `(sourceId, factoryId)`. Unknown extensions are stripped. Malformed/unregistered detail is erased without fabricating worker facts. A standalone same-origin swarm bridge can include this same field. `?preview=1` supplies explicitly labeled sample detail; failed live reads never load samples. + +Machine identities include provider, nullable account, app, run and resource under their registered source/factory. Retained receipts are historical; a refreshed local journal cannot establish fresh cloud health. Paid reservations are displayed separately and never converted into machine identities. Unknown paid spend remains unknown, and logical cell allocation is separate from the paid ledger. Reported conversation token totals can overlap across nested runs. + +Worker registration is a grouping mechanism. Null cell/machine bindings remain visibly unbound. Flow definitions display node labels, types and edges only. The current position comes from the conversation's `currentNodeId`; it is labeled reported running only when the conversation reports `running` and the worker observation is recent. Definitions do not imply node execution or completion. A completed conversation's position stays historical. This view has no execution controls. + +Detail bounds are 32 sources, 128 resources/source, 16 workers/source, 12 conversations/worker, 100 display messages/conversation, 512 nodes/flow, and 2,048 edges/flow. Message/tool-argument shortening is explicitly marked. Private conversation access failures erase transcripts and topology. Controller history starts with the current connection's cursor; snapshots do not reconstruct earlier controller events. + +Validation: `npm run build` and `npm run test:swarm` include namespace, graph-binding, private-field, transcript-denial, byte/window and unknown-spend checks, alongside the existing 10,000-cell and renderer evidence qualification. diff --git a/package.json b/package.json index 3c861fc..28cdfcd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "standalone": "npm run build && npm install --prefix manager && npm run standalone --prefix manager", "release": "node scripts/release.mjs", "test:release": "node --test test/release-arguments.test.mjs", - "test:swarm": "node --test test/swarm.test.mjs", + "test:swarm": "node --test test/swarm.test.mjs test/insights.test.mjs", "dockerbuild": "gh workflow run brain-release.yml -R flujo-app/brain --ref main" }, "keywords": [ diff --git a/src/swarm/insights-model.ts b/src/swarm/insights-model.ts new file mode 100644 index 0000000..f3c0a7b --- /dev/null +++ b/src/swarm/insights-model.ts @@ -0,0 +1,98 @@ +/** Versioned read-only detail projection. Shared verbatim with the private host. */ +export interface Operation { id: string; label: string; state: string; observedAt: string } +export interface Machine { key: string; provider: 'modal' | 'fly'; accountId: string | null; appId: string | null; resourceId: string | null; runId: string | null; cellId: string | null; evidenceDigest: string | null; label: string; kind: 'machine' | 'app' | 'run'; state: string; freshness: 'retained' | 'provider_observed' | 'unavailable'; observedAt: string; region: string | null; operations: Operation[] } +export interface FlowNode { id: string; label: string; type: string } +export interface Flow { id: string; name: string; nodes: FlowNode[]; edges: Array<{ source: string; target: string }> } +export interface Message { id: string; role: 'user' | 'assistant' | 'tool'; content: string; truncated: boolean; at: string | null; nodeId: string | null; tools: Array<{ id: string; name: string; arguments: string }> } +export interface Conversation { id: string; title: string; status: string; flowId: string | null; currentNodeId: string | null; updatedAt: string; tokens: number | null; estimatedCostUsd: number | null; totalMessages: number; truncated: boolean; messages: Message[] } +export interface Worker { id: string; label: string; cellId: string | null; machineKey: string | null; availability: 'observed' | 'unavailable'; observedAt: string; conversationStatus: 'observed' | 'unavailable' | 'not_configured'; conversations: Conversation[]; flows: Flow[] } +export interface Budget { revision: number; limitCents: number; heldCents: number; availableCents: number; meteredCents: number | null; incomplete: boolean; reservations: Array<{ id: string; provider: string; state: string; heldCents: number }> } +export interface ObservationSource { sourceId: string; factoryId: string; observedAt: string; modalStatus: 'observed' | 'unavailable' | 'not_configured'; flyStatus: 'observed' | 'unavailable' | 'not_configured'; machines: Machine[]; workers: Worker[]; budget: Budget | null } +export interface Observatory { schemaVersion: 1; scope: 'factory-observatory'; commands: false; observedAt: string; sources: ObservationSource[] } +const record = (v: unknown): Record => { if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('Invalid detail observation.'); return v as Record; }; +const text = (v: unknown, max = 128): string => { if (typeof v !== 'string' || !v.length || v.length > max) throw new Error('Invalid detail text.'); return v; }; +const body = (v: unknown, max: number): string => { if (typeof v !== 'string' || v.length > max) throw new Error('Detail content exceeds its bound.'); return v; }; +const integer = (v: unknown): number => { if (!Number.isSafeInteger(v) || (v as number) < 0) throw new Error('Invalid detail count.'); return v as number; }; +const nullable = (v: unknown): string | null => v === null ? null : text(v); +const time = (v: unknown): string => { const s = text(v, 64); if (!/^\d{4}-\d{2}-\d{2}T/.test(s) || !Number.isFinite(Date.parse(s))) throw new Error('Invalid detail time.'); return s; }; +const choice = (v: unknown, values: readonly T[]): T => { if (!values.includes(v as T)) throw new Error('Invalid detail category.'); return v as T; }; +const list = (v: unknown, max: number): unknown[] => { if (!Array.isArray(v) || v.length > max) throw new Error('Detail observation exceeds its bound.'); return v; }; +const unique = (ids: string[]) => { if (new Set(ids).size !== ids.length) throw new Error('Duplicate detail identity.'); }; +const status = (v: unknown) => choice(v, ['observed', 'unavailable', 'not_configured']); +export const machineKey = (provider: string, accountId: string | null, runId: string | null, resourceId: string | null, appId: string | null = null): string => JSON.stringify([provider, accountId, appId, runId, resourceId]); + +/** Unknown fields, worker URLs, credentials, system prompts and arbitrary node properties never cross this boundary. */ +export function parseObservatory(value: unknown, allowed: Array<{ id: string; factoryId: string }>): Observatory { + const input = record(value); + if (input.schemaVersion !== 1 || input.scope !== 'factory-observatory' || input.commands !== false) throw new Error('Unsupported detail authority.'); + const sources = list(input.sources, 32).map(v => { + const s = record(v), sourceId = text(s.sourceId), factoryId = text(s.factoryId); + if (!allowed.some(a => a.id === sourceId && a.factoryId === factoryId)) throw new Error('Detail source is not registered.'); + const machines = list(s.machines, 128).map(v => { + const m = record(v), provider = choice(m.provider, ['modal', 'fly']), accountId = nullable(m.accountId), appId = nullable(m.appId), resourceId = nullable(m.resourceId), runId = nullable(m.runId); + const key = body(m.key, 512); + if ((!resourceId && !runId) || key !== machineKey(provider, accountId, runId, resourceId, appId)) throw new Error('Invalid machine namespace.'); + const evidenceDigest = nullable(m.evidenceDigest); if (evidenceDigest !== null && !/^[a-f0-9]{64}$/.test(evidenceDigest)) throw new Error('Invalid machine evidence.'); + const operations = list(m.operations, 1000).map(v => { const o = record(v); return { id: text(o.id), label: text(o.label), state: text(o.state), observedAt: time(o.observedAt) }; }); + unique(operations.map(o => o.id)); + return { key, provider, accountId, appId, resourceId, runId, cellId: nullable(m.cellId), evidenceDigest, label: text(m.label), kind: choice(m.kind, ['machine', 'app', 'run']), state: text(m.state), freshness: choice(m.freshness, ['retained', 'provider_observed', 'unavailable']), observedAt: time(m.observedAt), region: nullable(m.region), operations }; + }); + unique(machines.map(m => m.key)); + const workers = list(s.workers, 16).map(v => { + const w = record(v), flows = list(w.flows, 32).map(v => { + const f = record(v), nodes = list(f.nodes, 512).map(v => { const n = record(v); return { id: text(n.id), label: text(n.label), type: text(n.type) }; }); + unique(nodes.map(n => n.id)); + const edges = list(f.edges, 2048).map(v => { const e = record(v); const edge = { source: text(e.source), target: text(e.target) }; if (!nodes.some(n => n.id === edge.source) || !nodes.some(n => n.id === edge.target)) throw new Error('Unbound flow edge.'); return edge; }); + return { id: text(f.id), name: text(f.name), nodes, edges }; + }); + unique(flows.map(f => f.id)); + const conversations = list(w.conversations, 12).map(v => { + const c = record(v), messages = list(c.messages, 100).map(v => { + const m = record(v), tools = list(m.tools, 64).map(v => { const t = record(v); return { id: text(t.id), name: text(t.name), arguments: body(t.arguments, 4096) }; }); + if (typeof m.truncated !== 'boolean') throw new Error('Invalid message window.'); + return { id: text(m.id), role: choice(m.role, ['user', 'assistant', 'tool']), content: body(m.content, 16384), truncated: m.truncated, at: m.at === null ? null : time(m.at), nodeId: nullable(m.nodeId), tools }; + }); + unique(messages.map(m => m.id)); + const totalMessages = integer(c.totalMessages); + if (typeof c.truncated !== 'boolean' || totalMessages < messages.length) throw new Error('Invalid transcript window.'); + const cost = c.estimatedCostUsd; + if (cost !== null && (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0)) throw new Error('Invalid estimated cost.'); + return { id: text(c.id), title: text(c.title, 256), status: text(c.status), flowId: nullable(c.flowId), currentNodeId: nullable(c.currentNodeId), updatedAt: time(c.updatedAt), tokens: c.tokens === null ? null : integer(c.tokens), estimatedCostUsd: cost as number | null, totalMessages, truncated: c.truncated, messages }; + }); + unique(conversations.map(c => c.id)); + const availability = choice(w.availability, ['observed', 'unavailable']), conversationStatus = status(w.conversationStatus); + if (((availability === 'unavailable' || conversationStatus !== 'observed') && conversations.length) || (availability === 'unavailable' && flows.length)) throw new Error('Unavailable transcript must be erased.'); + return { id: text(w.id), label: text(w.label), cellId: nullable(w.cellId), machineKey: w.machineKey === null ? null : body(w.machineKey, 512), availability, observedAt: time(w.observedAt), conversationStatus, conversations, flows }; + }); + unique(workers.map(w => w.id)); + let budget: Budget | null = null; + if (s.budget !== null) { + const b = record(s.budget), limitCents = integer(b.limitCents), heldCents = integer(b.heldCents), availableCents = integer(b.availableCents); + if (availableCents !== Math.max(0, limitCents - heldCents) || typeof b.incomplete !== 'boolean') throw new Error('Invalid paid budget.'); + const reservations = list(b.reservations, 1000).map(v => { const r = record(v); return { id: text(r.id), provider: text(r.provider), state: text(r.state), heldCents: integer(r.heldCents) }; }); + unique(reservations.map(r => r.id)); + if (reservations.reduce((n, r) => n + r.heldCents, 0) !== heldCents) throw new Error('Inconsistent paid reservations.'); + budget = { revision: integer(b.revision), limitCents, heldCents, availableCents, meteredCents: b.meteredCents === null ? null : integer(b.meteredCents), incomplete: b.incomplete, reservations }; + } + return { sourceId, factoryId, observedAt: time(s.observedAt), modalStatus: status(s.modalStatus), flyStatus: status(s.flyStatus), machines, workers, budget }; + }); + unique(sources.map(s => s.sourceId)); + return { schemaVersion: 1, scope: 'factory-observatory', commands: false, observedAt: time(input.observedAt), sources }; +} + +/** Independent paid-ledger floors; unavailable private detail is never retained. */ +export class ObservationFloors { + private paid = new Map(); + reset() { this.paid.clear(); } + accept(value: unknown, allowed: Array<{ id: string; factoryId: string }>): Observatory { + const keys = new Set(allowed.map(s => JSON.stringify([s.id, s.factoryId]))); + for (const key of this.paid.keys()) if (!keys.has(key)) this.paid.delete(key); + const next = parseObservatory(value, allowed); + for (const s of next.sources) if (s.budget) { + const key = JSON.stringify([s.sourceId, s.factoryId]), floor = this.paid.get(key) ?? -1; + if (s.budget.revision < floor) s.budget = null; + else this.paid.set(key, s.budget.revision); + } + return next; + } +} diff --git a/src/swarm/insights-preview.ts b/src/swarm/insights-preview.ts new file mode 100644 index 0000000..40b0685 --- /dev/null +++ b/src/swarm/insights-preview.ts @@ -0,0 +1,28 @@ +import { machineKey, type Observatory } from './insights-model'; + +/** Explicit design data only. Never used after a failed live read. */ +export function observatoryPreview(sources: Array<{ id: string; factoryId: string }>): Observatory { + const now = new Date().toISOString(), earlier = new Date(Date.now() - 120000).toISOString(); + return { schemaVersion: 1, scope: 'factory-observatory', commands: false, observedAt: now, + sources: sources.map((s, i) => { + const flyKey = machineKey('fly', 'sample-account/sample-app', null, 'sample-machine'); + return { sourceId: s.id, factoryId: s.factoryId, observedAt: now, modalStatus: 'observed', flyStatus: 'observed', + budget: { revision: 12, limitCents: 10000, heldCents: 4200, availableCents: 5800, meteredCents: null, incomplete: true, reservations: [{ id: 'sample-modal-hold', provider: 'modal', state: 'started', heldCents: 2500 }, { id: 'sample-fly-hold', provider: 'fly', state: 'started', heldCents: 1700 }] }, + machines: [ + { key: machineKey('modal', null, 'sample-run', 'sample-modal-app'), provider: 'modal', accountId: null, appId: null, cellId: null, evidenceDigest: null, resourceId: 'sample-modal-app', runId: 'sample-run', label: 'Model inference', kind: 'app', state: 'current state unknown', freshness: 'retained', observedAt: now, region: null, operations: [{ id: 'deploy', label: 'deploy', state: 'succeeded', observedAt: earlier }, { id: 'generation', label: 'flujo-generation', state: i === 2 ? 'unknown' : 'succeeded', observedAt: now }] }, + { key: flyKey, provider: 'fly', accountId: 'sample-account/sample-app', appId: null, cellId: null, evidenceDigest: null, resourceId: 'sample-machine', runId: null, label: 'Development worker', kind: 'machine', state: i === 1 ? 'stopped' : 'started', freshness: 'provider_observed', observedAt: now, region: 'iad', operations: [] }, + ], + workers: [{ id: 'sample-worker', label: 'Implementation worker', cellId: null, machineKey: flyKey, availability: 'observed', observedAt: now, conversationStatus: 'observed', + flows: [{ id: 'sample-flow', name: 'Plan → build → review', nodes: [{ id: 'start', label: 'Receive goal', type: 'start' }, { id: 'build', label: 'Implement change', type: 'process' }, { id: 'review', label: 'Review result', type: 'process' }, { id: 'finish', label: 'Deliver', type: 'finish' }], edges: [{ source: 'start', target: 'build' }, { source: 'build', target: 'review' }, { source: 'review', target: 'finish' }] }], + conversations: [{ id: 'sample-conversation', title: 'Build the Observatory details', status: i === 1 ? 'completed' : 'running', flowId: 'sample-flow', currentNodeId: 'build', updatedAt: now, tokens: 18420, estimatedCostUsd: 0.82, totalMessages: 4, truncated: false, + messages: [ + { id: 'm1', role: 'user', truncated: false, content: 'Show analytics, the timeline, worker nodes, conversations and both providers in one readable view.', at: earlier, nodeId: null, tools: [] }, + { id: 'm2', role: 'assistant', truncated: false, content: 'I’m checking the source contracts and the worker’s current flow before adding the detail views.', at: earlier, nodeId: 'build', tools: [{ id: 'tool1', name: 'Bash', arguments: '{"command":"npm run build"}' }] }, + { id: 'm3', role: 'tool', truncated: false, content: 'Build passed. The worker node and transcript stay scoped to this conversation.', at: now, nodeId: 'build', tools: [] }, + { id: 'm4', role: 'assistant', truncated: false, content: 'The new views are ready for visual verification. Modal receipts remain historical; the Fly machine has a separate provider observation.', at: now, nodeId: 'build', tools: [] }, + ] }], + }], + }; + }), + }; +} diff --git a/src/swarm/insights.css b/src/swarm/insights.css new file mode 100644 index 0000000..f282195 --- /dev/null +++ b/src/swarm/insights.css @@ -0,0 +1,16 @@ +.swarm-shell{height:68vh;min-height:620px} +.observatory-insights{position:relative;z-index:21;padding:40px clamp(18px,4vw,64px) 64px;border-top:1px solid var(--rule);background:linear-gradient(145deg,#0a111c,#070a11 55%);color:var(--text)} +.insights-heading{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:24px}.insights-heading h1{font:400 clamp(30px,4vw,46px)/1.1 var(--serif);margin:9px 0 0;letter-spacing:-.03em}.insight-kicker{font:9px/1.6 var(--mono);letter-spacing:.12em;color:var(--signal);margin:0;text-transform:uppercase}.insight-freshness{font:10px/1.8 var(--mono);color:var(--muted);max-width:450px;text-align:right} +.insights-nav{display:flex;flex-wrap:wrap;gap:8px;position:sticky;top:0;z-index:30;background:#080d16ed;backdrop-filter:blur(12px);padding:14px 0;margin-bottom:20px;border-bottom:1px solid var(--rule)}.insights-nav button{font:11px var(--mono);padding:10px 14px} +.insights-nav select{margin-left:auto;padding:9px 12px;max-width:240px;font:11px var(--mono)} +.insight-section{scroll-margin-top:70px;padding:24px 0;border-bottom:1px solid var(--rule)}.insight-section h2{font:400 27px var(--serif);margin:0 0 8px}.insight-subtitle,.insight-meta,.insight-empty{font:11px/1.7 var(--mono);color:var(--muted);margin:8px 0 16px}.insight-empty{padding:18px;border:1px dashed var(--rule);color:var(--quiet)}.insight-content h3{font:400 20px var(--serif);margin:0 0 12px}.insight-content h4{font:400 18px var(--serif);margin:12px 0}.insight-content code{font:10px/1.6 var(--mono);color:var(--quiet);overflow-wrap:anywhere}.insight-content small{display:block;color:var(--quiet);font:10px/1.6 var(--mono)} +.metrics-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.metric-card,.budget-card,.worker-card,.machine-card,.reservation-card{border:1px solid var(--rule);background:#0c152080;border-radius:6px;padding:20px}.metric-card strong{display:block;font:400 32px var(--serif);margin:10px 0;color:#effaff}.metric-card small{font:10px/1.5 var(--mono);color:var(--muted)} +.budget-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:12px;margin-top:16px}.budget-total{font:22px var(--serif)}.budget-card meter{width:100%;height:10px;accent-color:var(--signal)}.insight-warning{color:var(--warn)!important;font:11px/1.7 var(--mono)} +.insight-timeline{list-style:none;padding:0;margin:0;max-height:430px;overflow:auto;scrollbar-width:thin}.insight-timeline li{display:grid;grid-template-columns:190px minmax(0,1fr);gap:22px;padding:14px 0;border-bottom:1px solid var(--rule)}.insight-timeline time{font:10px/1.8 var(--mono);color:var(--quiet)}.insight-timeline strong{font:12px/1.6 var(--mono);font-weight:500}.insight-timeline p{font:12px/1.7 system-ui;margin:7px 0 0;color:var(--muted);overflow-wrap:anywhere} +.worker-card{margin:12px 0}.worker-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.status-pill{font:10px var(--mono);border:1px solid var(--rule);padding:6px 10px;color:var(--muted);border-radius:20px}.worker-current{font:17px var(--serif)}.flow-nodes{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.flow-node{border:1px solid var(--rule);padding:18px;background:#080e18;display:grid;gap:7px;border-radius:5px}.flow-node strong{font:15px/1.4 system-ui}.node-current{border-color:var(--signal);background:#10313b55;box-shadow:inset 3px 0 var(--signal)} +.insight-conversations input{max-width:420px;margin-bottom:18px}.conversation-layout{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.conversation-list{max-height:600px;overflow:auto}.conversation-list button{width:100%;padding:16px;text-align:left;margin-bottom:8px;font:13px/1.5 system-ui}.conversation-list small{margin-top:6px}.transcript{border:1px solid var(--rule);border-radius:6px;max-height:720px;overflow:auto;background:#080e18;padding:24px;scrollbar-width:thin}.transcript>header{border-bottom:1px solid var(--rule);padding-bottom:12px;margin-bottom:16px}.transcript-message{padding:18px 0;border-bottom:1px solid var(--rule)}.transcript-message header{display:flex;gap:14px;justify-content:space-between;align-items:baseline}.transcript-message strong{font:10px var(--mono);text-transform:uppercase;color:var(--signal)}.message-user strong{color:#c8bcff}.message-tool strong{color:#e0bb80}.message-content{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.8 system-ui;color:#d1dbe9}.transcript-tool{border:1px solid var(--rule);padding:12px;margin-top:12px;background:#101b2a;border-radius:4px}.transcript-tool summary{font:11px var(--mono);cursor:pointer}.transcript-tool pre{font:11px/1.6 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);margin-bottom:0} +.provider-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.provider-column{min-width:0}.provider-column>h3{display:flex;align-items:center;gap:10px}.provider-column>h3:before{content:'';width:9px;height:9px;border-radius:50%;background:#a8c697}.provider-fly>h3:before{background:#b1a4eb}.machine-card,.reservation-card{margin-bottom:12px}.machine-state{font:13px var(--mono);color:#d1dbe9}.machine-card details{margin-top:16px;border-top:1px solid var(--rule);padding-top:14px}.machine-card summary{font:11px var(--mono);cursor:pointer}.machine-card .insight-meta{margin:8px 0}.reservation-card{background:#11162255;border-style:dashed} +@media(max-width:1200px){.metrics-grid{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:800px){.swarm-shell{height:auto;min-height:0}.observatory-insights{padding-top:28px}.insights-heading{display:block}.insight-freshness{text-align:left;max-width:none}.conversation-layout{grid-template-columns:1fr}.conversation-list{max-height:260px}.provider-grid{grid-template-columns:1fr}.insight-timeline li{grid-template-columns:1fr;gap:5px}.worker-heading{align-items:start;flex-wrap:wrap}.transcript{padding:18px}.transcript-message header{display:block}.insights-nav{top:0}.insights-nav button{padding:9px 10px}} +@media(max-width:500px){.metrics-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-card{padding:14px}.flow-nodes{grid-template-columns:1fr}.insights-heading h1{font-size:34px}} +@media(max-width:800px){.insight-section{scroll-margin-top:160px}.insights-nav select{max-width:100%}} diff --git a/src/swarm/insights.ts b/src/swarm/insights.ts new file mode 100644 index 0000000..c192d1b --- /dev/null +++ b/src/swarm/insights.ts @@ -0,0 +1,153 @@ +import './insights.css'; +import { ObservationFloors, type Observatory, type ObservationSource, type Worker, type Conversation } from './insights-model'; +import { activities, type Swarm, type Selection } from './model'; + +const el = (tag: T, text?: string, cls?: string): HTMLElementTagNameMap[T] => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; }; +const btn = (label: string, action: () => void) => { const b = el('button', label); b.type = 'button'; b.onclick = action; return b; }; +const time = (v: string) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(v)); +const money = (c: number) => `$${(c / 100).toFixed(2)}`; +const key = (s: ObservationSource, w: Worker, c: Conversation) => JSON.stringify([s.sourceId, s.factoryId, w.id, c.id]); +const empty = (): Observatory => ({ schemaVersion: 1, scope: 'factory-observatory', commands: false, observedAt: new Date().toISOString(), sources: [] }); + +export function mountInsights(onSelect: (selection: Selection | null) => void) { + let swarm: Swarm, selection: Selection | null = null, detail = empty(), chosen: string | null = null, filter = '', signature = '', transcriptSignature = '', sourceSignature = '', transcriptKey: string | null = null; + const floors = new ObservationFloors(); + const root = el('section', undefined, 'observatory-insights'); root.id = 'observatory-insights'; root.setAttribute('aria-label', 'Observatory analytics and worker detail'); + const header = el('header', undefined, 'insights-heading'), title = el('div'); title.append(el('p', 'OBSERVATORY / INSTRUMENTS', 'insight-kicker'), el('h1', 'The work, in focus.')); + const freshness = el('p', 'Waiting for observations', 'insight-freshness'); freshness.setAttribute('role', 'status'); header.append(title, freshness); + const nav = el('nav', undefined, 'insights-nav'); nav.setAttribute('aria-label', 'Observation views'); + for (const [id, label] of [['analytics', 'Analytics'], ['history', 'Timeline'], ['workers', 'Worker nodes'], ['conversations', 'Conversations'], ['machines', 'Modal + Fly']]) nav.append(btn(label, () => document.getElementById(`insights-${id}`)?.scrollIntoView({ behavior: 'auto', block: 'start' }))); + const sourcePicker = el('select'); sourcePicker.setAttribute('aria-label', 'Observation source'); sourcePicker.onchange = () => onSelect(sourcePicker.value ? { sourceId: sourcePicker.value, kind: 'source', id: sourcePicker.value } : null); nav.append(sourcePicker); + const panels = new Map(); + for (const [id, label, note] of [ + ['analytics', 'Analytics', 'Observed work, usage and budget. Missing measurements remain unknown.'], + ['history', 'Timeline', 'Controller events, provider receipts and transcript timestamps keep their own provenance.'], + ['workers', 'Inside the worker', 'Flow structure and the conversation’s recorded execution position.'], + ['conversations', 'Conversations & transcript', 'Read the actual conversation, including tool calls and results.'], + ['machines', 'Machines & provider resources', 'Modal and Fly observations are independent of cell lifecycle and task leases.'], + ]) { const section = el('section', undefined, `insight-section insight-${id}`); section.id = `insights-${id}`; section.append(el('h2', label), el('p', note, 'insight-subtitle')); const content = el('div', undefined, 'insight-content'); panels.set(id, content); section.append(content); root.append(section); } + root.prepend(header, nav); + document.querySelector('#swarm-app > footer')!.before(root); + const shortcut = btn('Instruments ↓', () => root.scrollIntoView({ behavior: 'auto' })); document.getElementById('swarm-find')!.after(shortcut); + const search = el('input'); search.type = 'search'; search.placeholder = 'Find a conversation'; search.setAttribute('aria-label', 'Find a conversation'); search.oninput = () => { filter = search.value; paintConversations(); }; + const conversationContent = panels.get('conversations')!, conversationBody = el('div', undefined, 'conversation-layout'); conversationContent.append(search, conversationBody); + function scoped() { return detail.sources.filter(s => !selection || s.sourceId === selection.sourceId); } + function allConversations() { return scoped().flatMap(s => s.workers.flatMap(w => w.conversations.map(c => ({ s, w, c, key: key(s, w, c) })))); } + function currentConversation() { const list = allConversations(); return list.find(c => c.key === chosen) ?? list[0]; } + function scopeLabel(s: ObservationSource) { return swarm.sources.find(a => a.id === s.sourceId)?.label ?? s.sourceId; } + function recent(observedAt: string) { const age = Date.now() - Date.parse(observedAt); return swarm.sample || (age >= 0 && age <= 15000); } + function card(label: string, value: string, note: string) { const c = el('article', undefined, 'metric-card'); c.append(el('p', label, 'insight-kicker'), el('strong', value), el('small', note)); return c; } + function paintAnalytics() { + const content = panels.get('analytics')!; content.replaceChildren(); + const sources = swarm.sources.filter(s => !selection || s.id === selection.sourceId), details = scoped(); + const tasks = sources.flatMap(s => s.snapshot?.snapshot.tasks ?? []), effects = sources.flatMap(s => s.snapshot?.snapshot.effects ?? []); + const activity = sources.flatMap(s => [...activities(s, Date.now(), swarm.sample).values()]); + const conversations = allConversations(), knownTokens = conversations.filter(v => v.c.tokens !== null); + const metrics = el('div', undefined, 'metrics-grid'); + metrics.append(card('Sources', String(sources.length), `${sources.filter(s => s.status !== 'observed').length} stale or unavailable`), + card('Recent leased cells', String(activity.filter(a => a === 'recent').length), `${activity.length} recorded cells · host health separate`), + card('Tasks', String(tasks.length), `${tasks.filter(t => t.status === 'running').length} running · ${tasks.filter(t => ['delivered', 'completed'].includes(t.status)).length} delivered / operation completed`), + card('Unknown effects', String(effects.filter(e => e.state === 'unknown').length), 'External outcomes require reconciliation'), + card('Conversations', String(conversations.length), `${conversations.filter(v => v.c.status === 'running' && recent(v.w.observedAt)).length} recently reported running · ${details.reduce((n, s) => n + s.workers.length, 0)} registered workers`), + card('Reported tokens', knownTokens.length ? knownTokens.reduce((n, v) => n + v.c.tokens!, 0).toLocaleString() : 'Unknown', `Sum of ${knownTokens.length} reported totals; nested runs may overlap`)); content.append(metrics); + const budgets = el('div', undefined, 'budget-grid'); + for (const s of details) if (s.budget) { + const b = s.budget, panel = el('article', undefined, 'budget-card'); panel.append(el('h3', `${scopeLabel(s)} · paid budget`)); + const meter = el('meter'); meter.min = 0; meter.max = Math.max(1, b.limitCents, b.heldCents); meter.value = b.heldCents; meter.setAttribute('aria-label', 'Committed paid budget'); + panel.append(el('p', `${money(b.heldCents)} held / ${money(b.limitCents)} limit`, 'budget-total'), meter, el('p', `${money(b.availableCents)} available · ${b.meteredCents === null ? 'final spend unknown' : `${money(b.meteredCents)} metered`}${b.incomplete ? ' · billing incomplete' : ''}`, 'insight-meta')); + if (b.heldCents > b.limitCents) panel.append(el('p', `${money(b.heldCents - b.limitCents)} over committed`, 'insight-warning')); + panel.append(el('small', `Paid ledger revision ${b.revision}. Logical cell allocation is a separate measure.`)); budgets.append(panel); + } + if (!budgets.childElementCount) budgets.append(el('p', 'Paid budget observations are unavailable.', 'insight-empty')); content.append(budgets); + } + function paintHistory() { + const content = panels.get('history')!, rows: Array<{ at: string; label: string; source: string; subject: string }> = []; + for (const s of swarm.sources.filter(s => !selection || s.id === selection.sourceId)) for (const e of s.events) rows.push({ at: e.observedAt, label: e.type, source: `${s.label} / controller #${e.seq}`, subject: e.subject }); + for (const s of scoped()) for (const m of s.machines) for (const o of m.operations) rows.push({ at: o.observedAt, label: `${o.label} · ${o.state}`, source: `${scopeLabel(s)} / ${m.provider} receipt`, subject: m.runId ?? m.resourceId ?? '' }); + for (const { s, w, c } of allConversations()) for (const m of c.messages) if (m.at) rows.push({ at: m.at, label: `${m.role}${m.tools.length ? ` · ${m.tools.map(t => t.name).join(', ')}` : ''}`, source: `${scopeLabel(s)} / ${w.label} / transcript`, subject: m.content.slice(0, 180) || 'Tool call' }); + rows.sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); content.replaceChildren(); + if (!rows.length) { content.append(el('p', 'No timestamped history has been observed. Snapshot state does not reconstruct earlier events.', 'insight-empty')); return; } + const list = el('ol', undefined, 'insight-timeline'); + for (const row of rows.slice(0, 200)) { const item = el('li'); const stamp = el('time', time(row.at)); stamp.dateTime = row.at; const text = el('div'); text.append(el('strong', row.label), el('small', row.source), el('p', row.subject)); item.append(stamp, text); list.append(item); } + content.append(el('p', `${Math.min(rows.length, 200)} newest timestamped records${rows.length > 200 ? ` of ${rows.length}` : ''} · controller history starts when this view connects`, 'insight-meta'), list); + } + function paintWorkers() { + const content = panels.get('workers')!; content.replaceChildren(); const selected = currentConversation(); + for (const s of scoped()) for (const w of s.workers) { + const c = selected?.w === w ? selected.c : w.conversations[0], f = w.flows.find(f => f.id === c?.flowId), section = el('article', undefined, 'worker-card'); + const heading = el('div', undefined, 'worker-heading'); heading.append(el('h3', w.label), el('span', `${w.availability === 'observed' && !recent(w.observedAt) ? 'stale observation' : w.availability} · ${scopeLabel(s)}`, 'status-pill')); section.append(heading); + section.append(el('p', `${w.cellId ? `Registered cell binding: ${w.cellId}` : 'Unbound to a FACTORY cell'} · ${w.machineKey ? 'registered machine binding' : 'machine binding unavailable'}`, 'insight-meta')); + if (!c) section.append(el('p', w.conversationStatus === 'not_configured' ? 'No conversation IDs are registered for this worker.' : 'Conversation access unavailable. Transcript and execution position cleared.', 'insight-empty')); + else { + section.append(el('p', `${c.title} · ${c.status}`, 'worker-current'), el('p', `Flow: ${f?.name ?? c.flowId ?? 'unknown'} · recorded position: ${c.currentNodeId ?? 'unknown'}`, 'insight-meta')); + if (!f) section.append(el('p', 'Flow structure unavailable. No node topology or execution history is inferred.', 'insight-empty')); + else { + const graph = el('div', undefined, 'flow-nodes'); graph.setAttribute('aria-label', `${f.name} execution nodes`); + for (const n of f.nodes) { const at = c.currentNodeId === n.id, node = el('article', undefined, `flow-node${at ? ' node-current' : ''}`); const incoming = f.edges.filter(e => e.target === n.id).map(e => f.nodes.find(n => n.id === e.source)?.label ?? e.source); + node.append(el('span', at ? c.status === 'running' && w.availability === 'observed' && recent(w.observedAt) ? 'REPORTED RUNNING HERE' : 'LAST RECORDED POSITION' : 'FLOW DEFINITION', 'insight-kicker'), el('strong', n.label), el('small', n.type), el('code', n.id)); + if (incoming.length) node.append(el('small', `← ${incoming.join(', ')}`)); graph.append(node); + } section.append(graph, el('p', 'The current position comes from the conversation. Other nodes are definitions; completion and idle state are not inferred.', 'insight-meta')); + } + } content.append(section); + } + if (!content.childElementCount) content.append(el('p', 'No FLUJO workers are registered with this view. Machine-to-worker and cell-to-worker relationships remain unbound.', 'insight-empty')); + } + function paintConversations() { + const selected = currentConversation(), stamp = JSON.stringify([allConversations().map(v => [v.key, v.c]), selected?.key, filter]); + if (stamp === transcriptSignature) return; transcriptSignature = stamp; + const previousTranscript = conversationBody.querySelector('.transcript'), oldScroll = previousTranscript?.scrollTop ?? 0, atEnd = !previousTranscript || previousTranscript.scrollHeight - previousTranscript.clientHeight - previousTranscript.scrollTop < 40; + conversationBody.replaceChildren(); + const list = el('div', undefined, 'conversation-list'); + for (const v of allConversations().filter(v => `${v.c.title} ${v.c.id} ${v.w.label}`.toLowerCase().includes(filter.toLowerCase()))) { + const b = btn(v.c.title, () => { chosen = v.key; paintConversations(); paintWorkers(); }); b.setAttribute('aria-pressed', String(v.key === selected?.key)); b.append(el('small', `${v.c.status} · ${v.w.label}`)); list.append(b); + } + if (!list.childElementCount) list.append(el('p', filter ? 'No matching conversations.' : 'No accessible conversations observed.', 'insight-empty')); + conversationBody.append(list); + if (!selected) return; + const { s, w, c } = selected, transcript = el('article', undefined, 'transcript'); const h = el('header'); h.append(el('h3', c.title), el('p', `${scopeLabel(s)} / ${w.label} · ${c.status} · updated ${time(c.updatedAt)}`, 'insight-meta'), el('code', c.id), el('p', `${c.messages.length} displayed / ${c.totalMessages} recorded messages${c.truncated ? ' · bounded window' : ''}${c.tokens !== null ? ` · ${c.tokens.toLocaleString()} tokens` : ''}${c.estimatedCostUsd !== null ? ` · $${c.estimatedCostUsd.toFixed(3)} estimated model cost` : ''}`, 'insight-meta'), btn('Latest message ↓', () => { transcript.scrollTop = transcript.scrollHeight; })); transcript.append(h); + for (const m of c.messages) { const message = el('section', undefined, `transcript-message message-${m.role}`); const label = el('header'); label.append(el('strong', m.role), el('small', [m.at ? time(m.at) : 'time not recorded', m.nodeId ? `node ${m.nodeId}` : ''].filter(Boolean).join(' · '))); message.append(label); + if (m.content) message.append(el('p', m.content, 'message-content')); + if (m.truncated) message.append(el('p', 'Long message or tool arguments shortened to the display bound.', 'insight-warning')); + for (const t of m.tools) { const tool = el('details', undefined, 'transcript-tool'); tool.open = true; tool.append(el('summary', `Tool call · ${t.name}`), el('pre', t.arguments || 'Arguments not recorded')); message.append(tool); } transcript.append(message); + } + if (!c.messages.length) transcript.append(el('p', 'No displayable messages recorded.', 'insight-empty')); conversationBody.append(transcript); transcript.scrollTop = selected.key !== transcriptKey || atEnd ? transcript.scrollHeight : oldScroll; transcriptKey = selected.key; + } + function paintMachines() { + const content = panels.get('machines')!; content.replaceChildren(); const grid = el('div', undefined, 'provider-grid'); + for (const provider of ['modal', 'fly'] as const) { + const column = el('section', undefined, `provider-column provider-${provider}`), machineRows = scoped().flatMap(s => s.machines.filter(m => m.provider === provider).map(m => ({ s, m }))); + column.append(el('h3', provider === 'modal' ? 'Modal' : 'Fly.io'), el('p', `${machineRows.length} ${provider === 'modal' ? 'registered apps / runs' : 'machine observations'}`, 'insight-meta')); + for (const { s, m } of machineRows) { const c = el('article', undefined, 'machine-card'); c.append(el('div', `${m.freshness === 'provider_observed' ? recent(m.observedAt) ? 'PROVIDER OBSERVATION' : 'STALE PROVIDER OBSERVATION' : m.freshness === 'retained' ? 'HISTORICAL RECEIPTS' : 'UNAVAILABLE'} · ${scopeLabel(s)}`, 'insight-kicker'), el('h4', m.label), el('p', m.state, 'machine-state'), el('code', m.resourceId ?? m.runId ?? ''), el('p', `${m.kind} · ${m.region ?? 'region unknown'} · ${m.accountId ?? 'account identity not supplied'}`, 'insight-meta')); + if (m.appId) c.append(el('p', `App: ${m.appId}${m.runId ? ` · run ${m.runId}` : ''}${m.cellId ? ` · recorded cell ${m.cellId}` : ''}`, 'insight-meta')); + c.append(el('small', `${m.freshness === 'retained' ? m.provider === 'modal' ? 'Local journal read' : 'Retained receipt timestamp' : 'Provider read'} ${time(m.observedAt)}${m.freshness === 'retained' ? '. Current container count and machine health are unknown.' : '. Provider state does not establish worker health.'}`)); + if (m.operations.length) { const receipts = el('details'); receipts.open = true; receipts.append(el('summary', `${m.operations.length} recorded operations`)); for (const o of m.operations) receipts.append(el('p', `${o.label} · ${o.state}`, o.state === 'unknown' ? 'insight-warning' : 'insight-meta')); c.append(receipts); } column.append(c); + if (m.evidenceDigest) { const evidence = el('details'); evidence.open = true; evidence.append(el('summary', 'Evidence digest'), el('code', m.evidenceDigest)); c.append(evidence); } + } + for (const s of scoped()) { + const reservations = s.budget?.reservations.filter(r => r.provider === provider) ?? []; + if (reservations.length) { const holds = el('article', undefined, 'reservation-card'); holds.append(el('h4', `${scopeLabel(s)} · paid reservations`)); for (const r of reservations) holds.append(el('p', `${r.id} · ${r.state} · ${money(r.heldCents)} held`, 'insight-meta')); holds.append(el('small', 'A reservation is spending evidence. It does not identify or prove a running machine.')); column.append(holds); } + const observed = provider === 'modal' ? s.modalStatus : s.flyStatus; + if (observed !== 'observed') column.append(el('p', `${scopeLabel(s)} · ${observed === 'not_configured' ? 'no machine observer configured' : 'machine observations unavailable'}`, 'insight-empty')); + } + if (!machineRows.length) column.append(el('p', 'Machine identity and current state are unknown until an owned provider observation is connected.', 'insight-empty')); grid.append(column); + } content.append(grid); + } + function paint() { paintAnalytics(); paintHistory(); paintWorkers(); paintConversations(); paintMachines(); } + return { + observe(value: unknown, next: Swarm) { + swarm = next; + try { detail = floors.accept(value === undefined ? empty() : value, next.sources); } + catch { detail = empty(); freshness.textContent = 'Detail observation rejected · private detail cleared'; } + }, + render(next: Swarm, nextSelection: Selection | null) { + swarm = next; selection = nextSelection; + const sourceStamp = JSON.stringify(next.sources.map(s => [s.id, s.factoryId, s.label])); + if (sourceStamp !== sourceSignature) { sourceSignature = sourceStamp; sourcePicker.replaceChildren(); const all = el('option', 'All registered sources'); all.value = ''; sourcePicker.append(all); for (const s of next.sources) { const o = el('option', s.label); o.value = s.id; sourcePicker.append(o); } } + sourcePicker.value = selection?.sourceId ?? ''; + detail.sources = detail.sources.filter(s => next.sources.some(a => a.id === s.sourceId && a.factoryId === s.factoryId)); + const selected = currentConversation(); if (chosen && !allConversations().some(v => v.key === chosen)) chosen = selected?.key ?? null; + const sources = scoped(); freshness.textContent = `${swarm.sample ? 'Sample data / design preview' : 'Live read-only / 5-second refresh'} · ${selection ? swarm.sources.find(s => s.id === selection!.sourceId)?.label ?? 'source' : 'all registered sources'} · ${sources.length ? `detail read ${time(detail.observedAt)}` : 'worker detail unavailable'}`; + const stamp = JSON.stringify([swarm, detail, selection]); if (stamp !== signature) { signature = stamp; paint(); } + }, + }; +} diff --git a/src/swarm/main.ts b/src/swarm/main.ts index b9feb87..cbc3ddf 100644 --- a/src/swarm/main.ts +++ b/src/swarm/main.ts @@ -4,6 +4,8 @@ import { activities, nodeKey, parseSwarm, selectionFragment, selectionFromFragme import { readSwarmJson } from './transport'; import { activityEvidence, markerLegend, markerOf, markerTitle, SwarmRenderer, sourceColor, type Marker } from './renderer'; import { swarmPreview } from './preview'; +import { mountInsights } from './insights'; +import { observatoryPreview } from './insights-preview'; const $ = (id: string): HTMLElement => document.getElementById(id)!; const element = (tag: T, text?: string, className?: string): HTMLElementTagNameMap[T] => { @@ -20,6 +22,7 @@ const timestamp = (value: string): string => new Intl.DateTimeFormat(undefined, const taskLabel = (status: string) => status === 'completed' ? 'operation completed' : status; const empty = (): Swarm => ({ schemaVersion: 1, scope: 'operator-source-registry', observedAt: new Date().toISOString(), commands: false, sources: [] }); const query = new URLSearchParams(location.search), embedded = query.get('embed') === '1', channel = query.get('channel') ?? ''; +const insights = mountInsights(next => select(next, false)); const preview = !embedded && query.get('preview') === '1'; let swarm = empty(), index = new SwarmIndex(swarm), selection: Selection | null = null, page = 0, frameSequence = 0, lastStats = 0; let renderer: SwarmRenderer; @@ -214,7 +217,7 @@ function redraw(): void { const focus = focusNode(), visible = index.visible(focus?.key ?? null, page); renderer?.setNodes(visible, focus?.key ?? null); document.body.classList.toggle('has-selection', !!selection); - paintNavigation(); paintInspector(); paintTracking(); + paintNavigation(); paintInspector(); paintTracking(); insights.render(swarm, selection); document.body.classList.toggle('field-empty', !swarm.sources.length); const accented = selection ? index.sources.get(selection.sourceId) : null; document.body.style.setProperty('--accent', accented ? sourceColor(accented.id) : '#7fd1de'); @@ -234,6 +237,7 @@ function observe(value: unknown): void { if (!source.snapshot || (old.snapshot && source.snapshot.revision < old.snapshot.revision)) return { ...source, snapshot: old.snapshot, status: old.snapshot ? 'stale' : 'unavailable', events: old.events }; return source; }); + insights.observe((value as { observatory?: unknown }).observatory, next); swarm = next; index = new SwarmIndex(next); if (selection && (previous.get(selection.sourceId)?.factoryId !== index.sources.get(selection.sourceId)?.factoryId || !validSelection(selection))) select(null, false); if (firstObservation) { const wanted = selectionHash(); if (wanted && validSelection(wanted)) selection = wanted; firstObservation = false; } @@ -297,7 +301,8 @@ if (embedded) { } } else if (preview) { const count = Number(query.get('cells') ?? 54); - observe(swarmPreview(Number.isSafeInteger(count) && count >= 3 && count <= 10000 ? count : 54)); + const sample = swarmPreview(Number.isSafeInteger(count) && count >= 3 && count <= 10000 ? count : 54); + observe({ ...sample, observatory: observatoryPreview(sample.sources) }); } else { // Same-origin registered bridge only; never accept a remote destination/token. const sourcePath = query.get('source') ?? '/api/factory/swarm'; diff --git a/test/insights.test.mjs b/test/insights.test.mjs new file mode 100644 index 0000000..a5a0c09 --- /dev/null +++ b/test/insights.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import ts from 'typescript'; +const generated = new URL('../.vite/insights-tests/', import.meta.url); +await mkdir(generated, { recursive: true }); +for (const name of ['insights-model', 'insights-preview']) { + const code = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); + const result = ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace("'./insights-model'", "'./insights-model.mjs'"); + await writeFile(new URL(`${name}.mjs`, generated), result); +} +const { parseObservatory, ObservationFloors } = await import(new URL('insights-model.mjs', generated)); +const { observatoryPreview } = await import(new URL('insights-preview.mjs', generated)); +const sources = [{ id: 'one', factoryId: 'factory-one' }, { id: 'two', factoryId: 'factory-two' }]; +test('reused worker, node, conversation and resource IDs stay independent under exact source/factory authority', () => { + const data = parseObservatory(observatoryPreview(sources), sources); + assert.equal(data.sources[0].workers[0].id, data.sources[1].workers[0].id); + assert.notEqual(data.sources[0].factoryId, data.sources[1].factoryId); + const swapped = structuredClone(data); swapped.sources[0].factoryId = 'factory-two'; assert.throws(() => parseObservatory(swapped, sources), /not registered/); + const duplicate = structuredClone(data); duplicate.sources.push(duplicate.sources[0]); assert.throws(() => parseObservatory(duplicate, sources), /Duplicate/); + const forged = structuredClone(data); forged.sources[0].machines[0].key = data.sources[0].machines[1].key; assert.throws(() => parseObservatory(forged, sources), /namespace/); +}); +test('paid floors survive failed detail and repeated rollback, then reset on authority replacement or access erasure', () => { + const floors = new ObservationFloors(), data = observatoryPreview(sources); + assert.equal(floors.accept(data, sources).sources[0].budget.revision, 12); + data.sources[0].budget.revision = 11; + assert.equal(floors.accept(data, sources).sources[0].budget, null); + assert.equal(floors.accept({ ...data, sources: [] }, sources).sources.length, 0); + assert.equal(floors.accept(data, sources).sources[0].budget, null); + floors.accept({ ...data, sources: [] }, []); + assert.equal(floors.accept(data, sources).sources[0].budget.revision, 11); + floors.reset(); assert.equal(floors.accept(data, sources).sources[0].budget.revision, 11); +}); +test('positive projection strips private extensions and rejects system messages, executable authority and unbound graph edges', () => { + const data = observatoryPreview(sources); data.token = 'PRIVATE'; data.sources[0].workers[0].origin = 'PRIVATE'; data.sources[0].workers[0].flows[0].nodes[0].properties = { prompt: 'PRIVATE' }; + assert.equal(JSON.stringify(parseObservatory(data, sources)).includes('PRIVATE'), false); + data.commands = true; assert.throws(() => parseObservatory(data, sources)); data.commands = false; + data.sources[0].workers[0].conversations[0].messages[0].role = 'system'; assert.throws(() => parseObservatory(data, sources)); + data.sources[0].workers[0].conversations[0].messages[0].role = 'user'; data.sources[0].workers[0].flows[0].edges.push({ source: 'foreign-node', target: 'build' }); assert.throws(() => parseObservatory(data, sources), /Unbound/); +}); +test('failed transcript access erases content, and windows/paid holds remain bounded without converting unknown spend to zero', () => { + const data = observatoryPreview(sources); assert.equal(parseObservatory(data, sources).sources[0].budget.meteredCents, null); + const w = data.sources[0].workers[0]; w.conversationStatus = 'unavailable'; assert.throws(() => parseObservatory(data, sources), /erased/); w.conversations = []; assert.equal(parseObservatory(data, sources).sources[0].workers[0].conversations.length, 0); + w.flows[0].nodes = Array.from({ length: 513 }, (_, i) => ({ id: String(i), label: String(i), type: 'process' })); assert.throws(() => parseObservatory(data, sources), /bound/); + w.flows = []; data.sources[0].budget.heldCents = 11000; data.sources[0].budget.availableCents = 0; data.sources[0].budget.reservations[0].heldCents = 9300; assert.equal(parseObservatory(data, sources).sources[0].budget.heldCents, 11000); +}); From 576a7d6c5bc614c705467166834b908dedfebbed Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:39:26 -0500 Subject: [PATCH 6/9] Refresh Observatory evidence on lease and freshness expiry --- docs/OBSERVATORY_INSIGHTS.md | 4 +- package-lock.json | 535 ++++++++++++++++++++++++++++++++++- package.json | 3 +- src/swarm/insights.ts | 27 +- src/swarm/main.ts | 1 + test/insights-dom.test.mjs | 57 ++++ 6 files changed, 616 insertions(+), 11 deletions(-) create mode 100644 test/insights-dom.test.mjs diff --git a/docs/OBSERVATORY_INSIGHTS.md b/docs/OBSERVATORY_INSIGHTS.md index 122008c..ae5d1a1 100644 --- a/docs/OBSERVATORY_INSIGHTS.md +++ b/docs/OBSERVATORY_INSIGHTS.md @@ -2,6 +2,8 @@ The expanded instruments below the spatial field provide analytics, a timestamped timeline, FLUJO worker nodes, conversations with tool calls/results, and separate Modal/Fly inventory. The sticky navigation jumps between sections and filters them by registered authority. Conversations open at the newest message; refresh preserves a reader's position and expanded tool calls when the transcript is unchanged. +The two-second expiry tick refreshes time-sensitive analytics and worker/provider freshness when their effective evidence changes, even without a new transport frame or changed data object. One captured clock drives the panels. Lease expiry and the 15-second freshness boundary invalidate these panels without rebuilding the timeline or transcript, preserving scroll and tool expansion. Repeated ticks with unchanged evidence do not repaint. + The existing `brain-swarm/1` exact-origin/frame/channel transport carries an optional `model.observatory` detail envelope. `insights-model.ts` is the positive allowlist shared verbatim with Brain Online's `factory-observatory.ts`. It requires `schemaVersion: 1`, `scope: factory-observatory`, `commands: false`, and sources matching the controller registry's exact `(sourceId, factoryId)`. Unknown extensions are stripped. Malformed/unregistered detail is erased without fabricating worker facts. A standalone same-origin swarm bridge can include this same field. `?preview=1` supplies explicitly labeled sample detail; failed live reads never load samples. Machine identities include provider, nullable account, app, run and resource under their registered source/factory. Retained receipts are historical; a refreshed local journal cannot establish fresh cloud health. Paid reservations are displayed separately and never converted into machine identities. Unknown paid spend remains unknown, and logical cell allocation is separate from the paid ledger. Reported conversation token totals can overlap across nested runs. @@ -10,4 +12,4 @@ Worker registration is a grouping mechanism. Null cell/machine bindings remain v Detail bounds are 32 sources, 128 resources/source, 16 workers/source, 12 conversations/worker, 100 display messages/conversation, 512 nodes/flow, and 2,048 edges/flow. Message/tool-argument shortening is explicitly marked. Private conversation access failures erase transcripts and topology. Controller history starts with the current connection's cursor; snapshots do not reconstruct earlier controller events. -Validation: `npm run build` and `npm run test:swarm` include namespace, graph-binding, private-field, transcript-denial, byte/window and unknown-spend checks, alongside the existing 10,000-cell and renderer evidence qualification. +Validation: `npm run build` and `npm run test:swarm` include namespace, graph-binding, private-field, transcript-denial, byte/window and unknown-spend checks, alongside the existing 10,000-cell and renderer evidence qualification. A JSDOM regression runs the actual instrument module through lease expiry with the same observation objects, then worker/provider freshness expiry on an idle tick; it verifies transcript/timeline identity, scroll and tool expansion are preserved. diff --git a/package-lock.json b/package-lock.json index 2df3070..ebae3d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,144 @@ "version": "0.3.15", "license": "MIT", "dependencies": { - "three": "^0.169.0" + "three": "0.169.0" }, "devDependencies": { - "@types/three": "^0.169.0", + "@types/three": "0.169.0", + "jsdom": "26.1.0", "typescript": "^5.5.4", "vite": "^5.4.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -847,6 +977,82 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -908,6 +1114,114 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/meshoptimizer": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", @@ -915,6 +1229,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -934,6 +1255,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -970,6 +1311,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1015,6 +1366,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1025,12 +1403,65 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/three": { "version": "0.169.0", "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", "license": "MIT" }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1104,6 +1535,106 @@ "optional": true } } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 28cdfcd..ef2a0c2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "standalone": "npm run build && npm install --prefix manager && npm run standalone --prefix manager", "release": "node scripts/release.mjs", "test:release": "node --test test/release-arguments.test.mjs", - "test:swarm": "node --test test/swarm.test.mjs test/insights.test.mjs", + "test:swarm": "node --test test/swarm.test.mjs test/insights.test.mjs test/insights-dom.test.mjs", "dockerbuild": "gh workflow run brain-release.yml -R flujo-app/brain --ref main" }, "keywords": [ @@ -24,6 +24,7 @@ "license": "MIT", "devDependencies": { "@types/three": "0.169.0", + "jsdom": "26.1.0", "typescript": "^5.5.4", "vite": "^5.4.0" }, diff --git a/src/swarm/insights.ts b/src/swarm/insights.ts index c192d1b..1b5662a 100644 --- a/src/swarm/insights.ts +++ b/src/swarm/insights.ts @@ -10,7 +10,7 @@ const key = (s: ObservationSource, w: Worker, c: Conversation) => JSON.stringify const empty = (): Observatory => ({ schemaVersion: 1, scope: 'factory-observatory', commands: false, observedAt: new Date().toISOString(), sources: [] }); export function mountInsights(onSelect: (selection: Selection | null) => void) { - let swarm: Swarm, selection: Selection | null = null, detail = empty(), chosen: string | null = null, filter = '', signature = '', transcriptSignature = '', sourceSignature = '', transcriptKey: string | null = null; + let swarm: Swarm, selection: Selection | null = null, detail = empty(), chosen: string | null = null, filter = '', signature = '', temporalSignature = '', transcriptSignature = '', sourceSignature = '', transcriptKey: string | null = null, clock = Date.now(); const floors = new ObservationFloors(); const root = el('section', undefined, 'observatory-insights'); root.id = 'observatory-insights'; root.setAttribute('aria-label', 'Observatory analytics and worker detail'); const header = el('header', undefined, 'insights-heading'), title = el('div'); title.append(el('p', 'OBSERVATORY / INSTRUMENTS', 'insight-kicker'), el('h1', 'The work, in focus.')); @@ -35,16 +35,22 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { function allConversations() { return scoped().flatMap(s => s.workers.flatMap(w => w.conversations.map(c => ({ s, w, c, key: key(s, w, c) })))); } function currentConversation() { const list = allConversations(); return list.find(c => c.key === chosen) ?? list[0]; } function scopeLabel(s: ObservationSource) { return swarm.sources.find(a => a.id === s.sourceId)?.label ?? s.sourceId; } - function recent(observedAt: string) { const age = Date.now() - Date.parse(observedAt); return swarm.sample || (age >= 0 && age <= 15000); } + function recent(observedAt: string) { const age = clock - Date.parse(observedAt); return swarm.sample || (age >= 0 && age <= 15000); } + function timeSignature() { + return JSON.stringify([ + swarm.sources.filter(s => !selection || s.id === selection.sourceId).map(s => [s.status === 'observed' && !!s.snapshot && recent(s.snapshot.observedAt), [...activities(s, clock, swarm.sample).values()].filter(a => a === 'recent').length]), + scoped().map(s => [s.workers.map(w => recent(w.observedAt)), s.machines.map(m => recent(m.observedAt))]), + ]); + } function card(label: string, value: string, note: string) { const c = el('article', undefined, 'metric-card'); c.append(el('p', label, 'insight-kicker'), el('strong', value), el('small', note)); return c; } function paintAnalytics() { const content = panels.get('analytics')!; content.replaceChildren(); const sources = swarm.sources.filter(s => !selection || s.id === selection.sourceId), details = scoped(); const tasks = sources.flatMap(s => s.snapshot?.snapshot.tasks ?? []), effects = sources.flatMap(s => s.snapshot?.snapshot.effects ?? []); - const activity = sources.flatMap(s => [...activities(s, Date.now(), swarm.sample).values()]); + const activity = sources.flatMap(s => [...activities(s, clock, swarm.sample).values()]); const conversations = allConversations(), knownTokens = conversations.filter(v => v.c.tokens !== null); const metrics = el('div', undefined, 'metrics-grid'); - metrics.append(card('Sources', String(sources.length), `${sources.filter(s => s.status !== 'observed').length} stale or unavailable`), + metrics.append(card('Sources', String(sources.length), `${sources.filter(s => s.status !== 'observed' || !s.snapshot || !recent(s.snapshot.observedAt)).length} stale or unavailable`), card('Recent leased cells', String(activity.filter(a => a === 'recent').length), `${activity.length} recorded cells · host health separate`), card('Tasks', String(tasks.length), `${tasks.filter(t => t.status === 'running').length} running · ${tasks.filter(t => ['delivered', 'completed'].includes(t.status)).length} delivered / operation completed`), card('Unknown effects', String(effects.filter(e => e.state === 'unknown').length), 'External outcomes require reconciliation'), @@ -99,7 +105,7 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { conversationBody.replaceChildren(); const list = el('div', undefined, 'conversation-list'); for (const v of allConversations().filter(v => `${v.c.title} ${v.c.id} ${v.w.label}`.toLowerCase().includes(filter.toLowerCase()))) { - const b = btn(v.c.title, () => { chosen = v.key; paintConversations(); paintWorkers(); }); b.setAttribute('aria-pressed', String(v.key === selected?.key)); b.append(el('small', `${v.c.status} · ${v.w.label}`)); list.append(b); + const b = btn(v.c.title, () => { clock = Date.now(); chosen = v.key; paintConversations(); paintWorkers(); }); b.setAttribute('aria-pressed', String(v.key === selected?.key)); b.append(el('small', `${v.c.status} · ${v.w.label}`)); list.append(b); } if (!list.childElementCount) list.append(el('p', filter ? 'No matching conversations.' : 'No accessible conversations observed.', 'insight-empty')); conversationBody.append(list); @@ -140,14 +146,21 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { catch { detail = empty(); freshness.textContent = 'Detail observation rejected · private detail cleared'; } }, render(next: Swarm, nextSelection: Selection | null) { - swarm = next; selection = nextSelection; + swarm = next; selection = nextSelection; clock = Date.now(); const sourceStamp = JSON.stringify(next.sources.map(s => [s.id, s.factoryId, s.label])); if (sourceStamp !== sourceSignature) { sourceSignature = sourceStamp; sourcePicker.replaceChildren(); const all = el('option', 'All registered sources'); all.value = ''; sourcePicker.append(all); for (const s of next.sources) { const o = el('option', s.label); o.value = s.id; sourcePicker.append(o); } } sourcePicker.value = selection?.sourceId ?? ''; detail.sources = detail.sources.filter(s => next.sources.some(a => a.id === s.sourceId && a.factoryId === s.factoryId)); const selected = currentConversation(); if (chosen && !allConversations().some(v => v.key === chosen)) chosen = selected?.key ?? null; const sources = scoped(); freshness.textContent = `${swarm.sample ? 'Sample data / design preview' : 'Live read-only / 5-second refresh'} · ${selection ? swarm.sources.find(s => s.id === selection!.sourceId)?.label ?? 'source' : 'all registered sources'} · ${sources.length ? `detail read ${time(detail.observedAt)}` : 'worker detail unavailable'}`; - const stamp = JSON.stringify([swarm, detail, selection]); if (stamp !== signature) { signature = stamp; paint(); } + const stamp = JSON.stringify([swarm, detail, selection]), temporal = timeSignature(); + if (stamp !== signature) { signature = stamp; temporalSignature = temporal; paint(); } + else if (temporal !== temporalSignature) { temporalSignature = temporal; paintAnalytics(); paintWorkers(); paintMachines(); } + }, + tick() { + if (!swarm || !signature) return; + clock = Date.now(); const temporal = timeSignature(); + if (temporal !== temporalSignature) { temporalSignature = temporal; paintAnalytics(); paintWorkers(); paintMachines(); } }, }; } diff --git a/src/swarm/main.ts b/src/swarm/main.ts index cbc3ddf..b20b088 100644 --- a/src/swarm/main.ts +++ b/src/swarm/main.ts @@ -285,6 +285,7 @@ const expiryTimer = setInterval(() => { for (const cell of source.snapshot?.snapshot.cells ?? []) { const node = index.nodes.get(nodeKey(source, 'cell', cell.id)); if (node && node.activity !== (activity.get(cell.id) ?? 'idle')) { node.activity = activity.get(cell.id) ?? 'idle'; changed = true; } } } if (changed) redraw(); + else insights.tick(); }, 2000); if (embedded) { diff --git a/test/insights-dom.test.mjs b/test/insights-dom.test.mjs new file mode 100644 index 0000000..f3727d2 --- /dev/null +++ b/test/insights-dom.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { JSDOM } from 'jsdom'; +import ts from 'typescript'; + +const generated = new URL('../.vite/insights-dom-tests/', import.meta.url); +await mkdir(generated, { recursive: true }); +for (const name of ['insights', 'insights-model', 'model', 'preview', 'insights-preview']) { + const code = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); + const result = ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText + .replace(/import '\.\/insights\.css';/, '') + .replace(/from '(\.\/[^']+)'/g, "from '$1.mjs'"); + await writeFile(new URL(`${name}.mjs`, generated), result); +} +const { mountInsights } = await import(new URL('insights.mjs', generated)); +const { swarmPreview } = await import(new URL('preview.mjs', generated)); +const { observatoryPreview } = await import(new URL('insights-preview.mjs', generated)); +const { activities } = await import(new URL('model.mjs', generated)); + +test('same-object render expires leased analytics; idle ticks expire worker/provider freshness without rebuilding transcripts', t => { + const originalDocument = globalThis.document, originalNow = Date.now, base = originalNow(); let now = base; + const dom = new JSDOM('
'); + globalThis.document = dom.window.document; Date.now = () => now; + t.after(() => { globalThis.document = originalDocument; Date.now = originalNow; dom.window.close(); }); + const swarm = swarmPreview(9); swarm.sample = false; + for (const s of swarm.sources) { + s.snapshot.observedAt = new Date(base).toISOString(); + for (const c of s.snapshot.snapshot.cells) c.heartbeat = s.snapshot.observedAt; + for (const task of s.snapshot.snapshot.tasks) if (task.status === 'running') task.leaseExpiry = new Date(base + 1000).toISOString(); + } + const detail = observatoryPreview(swarm.sources); + for (const s of detail.sources) { + for (const w of s.workers) w.observedAt = new Date(base).toISOString(); + for (const m of s.machines) m.observedAt = new Date(base).toISOString(); + } + const view = mountInsights(() => {}), selection = { sourceId: swarm.sources[0].id, kind: 'source', id: swarm.sources[0].id }; + const metric = label => [...document.querySelectorAll('.metric-card')].find(c => c.querySelector('p').textContent === label); + view.observe(detail, swarm); view.render(swarm, selection); + assert.equal(metric('Recent leased cells').querySelector('strong').textContent, '1'); + assert.match(document.querySelector('#insights-workers').textContent, /REPORTED RUNNING HERE/); + const transcript = document.querySelector('.transcript'), tool = transcript.querySelector('details'), timeline = document.querySelector('.insight-timeline'); tool.open = false; transcript.scrollTop = 23; + now = base + 2000; view.render(swarm, selection); + assert.equal(activities(swarm.sources[0], now).get('cell-1'), 'uncertain'); + assert.equal(metric('Recent leased cells').querySelector('strong').textContent, '0'); + now = base + 15001; view.tick(); + assert.match(metric('Sources').querySelector('small').textContent, /1 stale or unavailable/); + assert.match(document.querySelector('#insights-workers').textContent, /stale observation/); + assert.match(document.querySelector('#insights-workers').textContent, /LAST RECORDED POSITION/); + assert.doesNotMatch(document.querySelector('#insights-workers').textContent, /REPORTED RUNNING HERE/); + assert.match(document.querySelector('#insights-machines').textContent, /STALE PROVIDER OBSERVATION/); + assert.equal(document.querySelector('.transcript'), transcript); + assert.equal(document.querySelector('.insight-timeline'), timeline); + assert.equal(tool.open, false); assert.equal(transcript.scrollTop, 23); + const metrics = document.querySelector('.metrics-grid'); now++; view.tick(); + assert.equal(document.querySelector('.metrics-grid'), metrics, 'unchanged temporal evidence does not repaint'); +}); From 5d8a510b3bdddbdbfc421ccc96149764e9e1d68f Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:31:09 -0500 Subject: [PATCH 7/9] Give O Observatory an authored visual interface --- docs/OBSERVATORY_INSIGHTS.md | 12 +- src/swarm/insights.css | 238 ++++++++++++++++++++++++++-- src/swarm/insights.ts | 296 ++++++++++++++++++++++++++--------- src/swarm/main.ts | 51 +++--- src/swarm/marks.ts | 179 +++++++++++++++++++++ src/swarm/observatory.css | 243 ++++++++++++++-------------- src/swarm/swarm.css | 73 ++++++++- swarm.html | 39 +++-- test/insights-dom.test.mjs | 37 ++++- test/swarm.test.mjs | 10 +- 10 files changed, 910 insertions(+), 268 deletions(-) create mode 100644 src/swarm/marks.ts diff --git a/docs/OBSERVATORY_INSIGHTS.md b/docs/OBSERVATORY_INSIGHTS.md index ae5d1a1..9fa59fe 100644 --- a/docs/OBSERVATORY_INSIGHTS.md +++ b/docs/OBSERVATORY_INSIGHTS.md @@ -1,6 +1,6 @@ # Observatory instruments -The expanded instruments below the spatial field provide analytics, a timestamped timeline, FLUJO worker nodes, conversations with tool calls/results, and separate Modal/Fly inventory. The sticky navigation jumps between sections and filters them by registered authority. Conversations open at the newest message; refresh preserves a reader's position and expanded tool calls when the transcript is unchanged. +The expanded instruments below the spatial field provide analytics, a timestamped timeline, FLUJO worker nodes, conversations with tool calls/results, and separate Modal/Fly inventory. The sticky navigation jumps between sections; the scope picker filters them by registered authority. Section navigation preserves the authority URL fragment. Conversations open at the newest message; refresh preserves a reader's position and expanded tool calls when the transcript is unchanged. The two-second expiry tick refreshes time-sensitive analytics and worker/provider freshness when their effective evidence changes, even without a new transport frame or changed data object. One captured clock drives the panels. Lease expiry and the 15-second freshness boundary invalidate these panels without rebuilding the timeline or transcript, preserving scroll and tool expansion. Repeated ticks with unchanged evidence do not repaint. @@ -13,3 +13,13 @@ Worker registration is a grouping mechanism. Null cell/machine bindings remain v Detail bounds are 32 sources, 128 resources/source, 16 workers/source, 12 conversations/worker, 100 display messages/conversation, 512 nodes/flow, and 2,048 edges/flow. Message/tool-argument shortening is explicitly marked. Private conversation access failures erase transcripts and topology. Controller history starts with the current connection's cursor; snapshots do not reconstruct earlier controller events. Validation: `npm run build` and `npm run test:swarm` include namespace, graph-binding, private-field, transcript-denial, byte/window and unknown-spend checks, alongside the existing 10,000-cell and renderer evidence qualification. A JSDOM regression runs the actual instrument module through lease expiry with the same observation objects, then worker/provider freshness expiry on an idle tick; it verifies transcript/timeline identity, scroll and tool expansion are preserved. + +## O visual redesign + +The O is a mouth. Its SVG mark shares the field's deterministic authority palette. The visual language combines serif readings, monospace identities, glass surfaces, measured rules and a numbered section spine. All five observation sections remain expanded. Tool calls, operation receipts and evidence digests start expanded; longer interpretation notes live in Evidence drawers. + +The dial shows counted recent leases, the budget rule shows recorded paid holds, the timeline ribbon shows record density in three separate provenance lanes, and flow diagrams show recorded nodes and edges. These marks do not introduce health, inferred execution, reconstructed history or new controls. Node tooltips and the accessible graph description preserve full labels, types and identities. Cyclic or disconnected definitions retain every node within the SVG bounds. Completed operations remain separate from delivered software in the task reading. + +The requested FLUJO flow `6b0b98fd-aa5a-4d8a-bdf5-510bb70a7e03` (`Claude_Opus_Agent`) authored the presentation candidate in conversation `c46dc824-9cc3-45a8-9c19-eabab529f9d8`. Parent review repaired section navigation, task-status labels, graph bounds, receipt expansion and responsive readings before acceptance. The flow definition was not changed. + +The redesign builds successfully and passes 20 viewer checks. Browser qualification covers source-preserving navigation, the actual two registered FLUJO conversations (including the design run's six-node graph and 100/135-message window), the recorded $100 held/$0 available/incomplete paid ledger, expanded historical Modal/Fly receipts, and a 390px viewport without page overflow. The private local qualification harness is separate from shipped source; release and runtime adoption remain the host owner's responsibility. The shared observation schema, transport, source parsing, server redaction and read budgets are unchanged. diff --git a/src/swarm/insights.css b/src/swarm/insights.css index f282195..6ecd5f6 100644 --- a/src/swarm/insights.css +++ b/src/swarm/insights.css @@ -1,16 +1,222 @@ -.swarm-shell{height:68vh;min-height:620px} -.observatory-insights{position:relative;z-index:21;padding:40px clamp(18px,4vw,64px) 64px;border-top:1px solid var(--rule);background:linear-gradient(145deg,#0a111c,#070a11 55%);color:var(--text)} -.insights-heading{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:24px}.insights-heading h1{font:400 clamp(30px,4vw,46px)/1.1 var(--serif);margin:9px 0 0;letter-spacing:-.03em}.insight-kicker{font:9px/1.6 var(--mono);letter-spacing:.12em;color:var(--signal);margin:0;text-transform:uppercase}.insight-freshness{font:10px/1.8 var(--mono);color:var(--muted);max-width:450px;text-align:right} -.insights-nav{display:flex;flex-wrap:wrap;gap:8px;position:sticky;top:0;z-index:30;background:#080d16ed;backdrop-filter:blur(12px);padding:14px 0;margin-bottom:20px;border-bottom:1px solid var(--rule)}.insights-nav button{font:11px var(--mono);padding:10px 14px} -.insights-nav select{margin-left:auto;padding:9px 12px;max-width:240px;font:11px var(--mono)} -.insight-section{scroll-margin-top:70px;padding:24px 0;border-bottom:1px solid var(--rule)}.insight-section h2{font:400 27px var(--serif);margin:0 0 8px}.insight-subtitle,.insight-meta,.insight-empty{font:11px/1.7 var(--mono);color:var(--muted);margin:8px 0 16px}.insight-empty{padding:18px;border:1px dashed var(--rule);color:var(--quiet)}.insight-content h3{font:400 20px var(--serif);margin:0 0 12px}.insight-content h4{font:400 18px var(--serif);margin:12px 0}.insight-content code{font:10px/1.6 var(--mono);color:var(--quiet);overflow-wrap:anywhere}.insight-content small{display:block;color:var(--quiet);font:10px/1.6 var(--mono)} -.metrics-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px}.metric-card,.budget-card,.worker-card,.machine-card,.reservation-card{border:1px solid var(--rule);background:#0c152080;border-radius:6px;padding:20px}.metric-card strong{display:block;font:400 32px var(--serif);margin:10px 0;color:#effaff}.metric-card small{font:10px/1.5 var(--mono);color:var(--muted)} -.budget-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:12px;margin-top:16px}.budget-total{font:22px var(--serif)}.budget-card meter{width:100%;height:10px;accent-color:var(--signal)}.insight-warning{color:var(--warn)!important;font:11px/1.7 var(--mono)} -.insight-timeline{list-style:none;padding:0;margin:0;max-height:430px;overflow:auto;scrollbar-width:thin}.insight-timeline li{display:grid;grid-template-columns:190px minmax(0,1fr);gap:22px;padding:14px 0;border-bottom:1px solid var(--rule)}.insight-timeline time{font:10px/1.8 var(--mono);color:var(--quiet)}.insight-timeline strong{font:12px/1.6 var(--mono);font-weight:500}.insight-timeline p{font:12px/1.7 system-ui;margin:7px 0 0;color:var(--muted);overflow-wrap:anywhere} -.worker-card{margin:12px 0}.worker-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.status-pill{font:10px var(--mono);border:1px solid var(--rule);padding:6px 10px;color:var(--muted);border-radius:20px}.worker-current{font:17px var(--serif)}.flow-nodes{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.flow-node{border:1px solid var(--rule);padding:18px;background:#080e18;display:grid;gap:7px;border-radius:5px}.flow-node strong{font:15px/1.4 system-ui}.node-current{border-color:var(--signal);background:#10313b55;box-shadow:inset 3px 0 var(--signal)} -.insight-conversations input{max-width:420px;margin-bottom:18px}.conversation-layout{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.conversation-list{max-height:600px;overflow:auto}.conversation-list button{width:100%;padding:16px;text-align:left;margin-bottom:8px;font:13px/1.5 system-ui}.conversation-list small{margin-top:6px}.transcript{border:1px solid var(--rule);border-radius:6px;max-height:720px;overflow:auto;background:#080e18;padding:24px;scrollbar-width:thin}.transcript>header{border-bottom:1px solid var(--rule);padding-bottom:12px;margin-bottom:16px}.transcript-message{padding:18px 0;border-bottom:1px solid var(--rule)}.transcript-message header{display:flex;gap:14px;justify-content:space-between;align-items:baseline}.transcript-message strong{font:10px var(--mono);text-transform:uppercase;color:var(--signal)}.message-user strong{color:#c8bcff}.message-tool strong{color:#e0bb80}.message-content{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.8 system-ui;color:#d1dbe9}.transcript-tool{border:1px solid var(--rule);padding:12px;margin-top:12px;background:#101b2a;border-radius:4px}.transcript-tool summary{font:11px var(--mono);cursor:pointer}.transcript-tool pre{font:11px/1.6 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);margin-bottom:0} -.provider-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.provider-column{min-width:0}.provider-column>h3{display:flex;align-items:center;gap:10px}.provider-column>h3:before{content:'';width:9px;height:9px;border-radius:50%;background:#a8c697}.provider-fly>h3:before{background:#b1a4eb}.machine-card,.reservation-card{margin-bottom:12px}.machine-state{font:13px var(--mono);color:#d1dbe9}.machine-card details{margin-top:16px;border-top:1px solid var(--rule);padding-top:14px}.machine-card summary{font:11px var(--mono);cursor:pointer}.machine-card .insight-meta{margin:8px 0}.reservation-card{background:#11162255;border-style:dashed} -@media(max-width:1200px){.metrics-grid{grid-template-columns:repeat(3,minmax(0,1fr))}} -@media(max-width:800px){.swarm-shell{height:auto;min-height:0}.observatory-insights{padding-top:28px}.insights-heading{display:block}.insight-freshness{text-align:left;max-width:none}.conversation-layout{grid-template-columns:1fr}.conversation-list{max-height:260px}.provider-grid{grid-template-columns:1fr}.insight-timeline li{grid-template-columns:1fr;gap:5px}.worker-heading{align-items:start;flex-wrap:wrap}.transcript{padding:18px}.transcript-message header{display:block}.insights-nav{top:0}.insights-nav button{padding:9px 10px}} -@media(max-width:500px){.metrics-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-card{padding:14px}.flow-nodes{grid-template-columns:1fr}.insights-heading h1{font-size:34px}} -@media(max-width:800px){.insight-section{scroll-margin-top:160px}.insights-nav select{max-width:100%}} +/* THE DECK — the instruments below the field. + * One long page with a numbered spine, never tabs: the reading order is the + * order of evidence. Figures are serif, identities and labels are mono, and + * the careful compliance wording lives in Evidence drawers instead of the + * reading flow. Every chart is drawn from observed records in SVG. */ +.swarm-shell{height:72vh;min-height:620px} +.observatory-insights{position:relative;z-index:21;border-top:1px solid var(--rule);color:var(--text); + background:linear-gradient(180deg,#080d16,#06090f 40%,#05070c)} +.observatory-insights::before{content:'';position:absolute;inset:0 0 auto;height:420px;pointer-events:none; + background:radial-gradient(70% 100% at 12% 0%,rgba(127,209,222,.09),transparent 62%),radial-gradient(60% 90% at 86% 4%,rgba(155,184,232,.07),transparent 60%)} + +/* ── Head: the O, the scope, the readings ───────────────────────────────── */ +.deck-head{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center; + gap:clamp(16px,2.4vw,28px);padding:clamp(34px,5vw,58px) clamp(18px,4vw,64px) 0} +.deck-mark{display:grid;place-items:center;width:56px;height:56px} +.deck-ident{min-width:0} +.deck-ident .insight-kicker{margin:0 0 9px} +.deck-ident h1{font:400 clamp(28px,3.6vw,44px)/1 var(--serif);letter-spacing:-.035em;margin:0;color:#eef4fb;overflow-wrap:anywhere} +.insight-kicker{font:var(--kicker);letter-spacing:.2em;color:var(--accent);margin:0;text-transform:uppercase} +.insight-freshness{font:10px/1.7 var(--mono);letter-spacing:.06em;color:var(--quiet);margin:10px 0 0} +.deck-scope{display:grid;gap:7px;justify-items:end;font:9px var(--mono);letter-spacing:.16em;color:var(--faint);text-transform:uppercase} +.deck-scope select{padding:9px 11px;max-width:260px;font:11px var(--mono);color:var(--muted)} + +/* The readings band: one lead dial, five hairline-separated figures. */ +.deck-vitals{grid-column:1/-1;margin-top:clamp(24px,3vw,38px)} +.metrics-grid{display:grid;grid-template-columns:248px repeat(5,minmax(0,1fr));border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)} +.metric-card{padding:22px 20px;border-left:1px solid var(--rule);min-width:0;transition:background .2s var(--ease)} +.metric-card:first-child{border-left:0;padding-left:0} +.metric-card:hover{background:rgba(127,209,222,.035)} +.metric-card .insight-kicker{color:var(--quiet);letter-spacing:.14em} +.metric-card strong{display:block;font:400 clamp(30px,3.2vw,42px)/1 var(--serif);letter-spacing:-.03em;margin:14px 0 10px;color:#f0f7ff;font-weight:400;overflow-wrap:anywhere} +.metric-card small{display:block;font:9px/1.7 var(--mono);letter-spacing:.07em;color:var(--faint);text-transform:uppercase} +.metric-lead{display:grid;grid-template-rows:auto auto auto;align-content:start} +.vital-dial{position:relative;width:132px;height:132px;display:grid;place-items:center;margin:14px 0 12px} +.vital-dial .dial-art{position:absolute;inset:0;width:100%;height:100%} +.vital-dial strong{margin:0;font-size:clamp(34px,3.6vw,46px);text-shadow:0 2px 26px #04060b} + +/* ── Body: numbered spine + the long page ───────────────────────────────── */ +.deck-body{display:grid;grid-template-columns:166px minmax(0,1fr);gap:clamp(22px,4vw,58px);padding:clamp(26px,4vw,46px) clamp(18px,4vw,64px) 76px;position:relative} +.deck-spine{position:sticky;top:20px;align-self:start;padding-left:1px} +.deck-spine::before{content:'VIEWS';display:block;font:9px var(--mono);letter-spacing:.2em;color:var(--faint);padding:0 0 12px 13px} +.deck-spine ol{list-style:none;margin:0;padding:0;display:grid;gap:1px} +.spine-link{display:grid;grid-template-columns:auto minmax(0,1fr);gap:11px;align-items:baseline;padding:12px 10px 12px 13px; + border-left:1px solid var(--rule);color:var(--muted);font:400 13px/1.2 var(--serif);letter-spacing:.01em; + transition:color .16s var(--ease),background .16s var(--ease),border-color .16s var(--ease),padding-left .16s var(--ease)} +.spine-link:hover{padding-left:17px} +.spine-link i{font-style:normal;font:9px var(--mono);letter-spacing:.1em;color:var(--faint)} +.spine-link:hover{color:var(--text);border-left-color:var(--rule-strong);background:rgba(127,209,222,.04)} +.spine-link[aria-current]{color:#eafcff;border-left-color:var(--accent);background:linear-gradient(90deg,rgba(127,209,222,.13),transparent 86%)} +.spine-link[aria-current] i{color:var(--accent)} +.deck-sections{min-width:0} +.insight-section{padding:30px 0 44px;border-top:1px solid var(--rule);scroll-margin-top:20px} +.insight-section:first-child{border-top:0;padding-top:4px} +.section-head{margin-bottom:22px} +.section-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 16px} +.section-numeral{font:9px var(--mono);letter-spacing:.2em;color:var(--faint)} +.insight-section h2{font:400 clamp(24px,2.7vw,34px)/1 var(--serif);letter-spacing:-.035em;margin:0;color:#eef4fb} +.insight-subtitle{font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--quiet);margin:0} +.insight-meta{font:10px/1.7 var(--mono);color:var(--quiet);margin:0} +.insight-empty{font:10px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);border:1px dashed var(--rule);border-radius:3px;padding:16px;margin:12px 0} +.insight-warning{color:var(--warn);font:10px/1.7 var(--mono)} +.insight-content h3{font:400 18px var(--serif);margin:0;color:#e4edf7} +.insight-content h4{font:400 17px var(--serif);margin:10px 0 6px;color:#e4edf7} +.insight-content code{font:10px/1.6 var(--mono);color:var(--quiet);overflow-wrap:anywhere} + +/* ── 01 Ledger ──────────────────────────────────────────────────────────── */ +.budget-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,330px),1fr));gap:18px} +.budget-card{border:1px solid var(--rule);background:linear-gradient(180deg,rgba(14,20,31,.7),rgba(9,13,21,.7));border-radius:4px;padding:22px;position:relative;overflow:hidden} +.budget-card::before{content:'';position:absolute;left:0;top:0;width:36px;height:1px;background:var(--accent);opacity:.8} +.budget-heading{display:flex;flex-wrap:wrap;align-items:center;gap:10px} +.budget-heading h3{margin-right:auto} +.budget-total{font:400 clamp(26px,2.6vw,34px)/1 var(--serif);letter-spacing:-.03em;color:#f0f7ff;margin:16px 0 12px} +.budget-art{display:block;width:100%;height:32px} +.budget-legend{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;margin:14px 0 0;font:10px var(--mono)} +.budget-legend dt{font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin:0 0 5px} +.budget-legend dd{margin:0 10px 0 0;color:#dce7f4} + +/* ── 02 Timeline ────────────────────────────────────────────────────────── */ +.ribbon{border:1px solid var(--rule);border-radius:3px;background:rgba(9,13,21,.6);padding:12px 12px 6px} +.ribbon-art{display:block;width:100%;height:84px} +.ribbon-axis{display:flex;justify-content:space-between;gap:12px;font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin:8px 2px 0} +.ribbon-legend{display:flex;flex-wrap:wrap;gap:8px 20px;list-style:none;margin:14px 0 22px;padding:0;font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--quiet)} +.ribbon-legend li{display:flex;align-items:center;gap:8px} +.ribbon-legend i{width:9px;height:9px;border-radius:2px;background:var(--signal)} +.ribbon-legend .lane-1 i{background:#9ed7a6} +.ribbon-legend .lane-2 i{background:#b6a6ea} +.insight-timeline{list-style:none;padding:0;margin:0;max-height:480px;overflow:auto} +.insight-timeline li{display:grid;grid-template-columns:14px 78px minmax(0,1fr);gap:16px;padding:11px 0;border-bottom:1px solid var(--rule);position:relative} +.insight-timeline li::before{content:'';width:7px;height:7px;border-radius:50%;background:var(--signal);margin-top:5px;opacity:.85} +.insight-timeline .lane-1::before{background:#9ed7a6} +.insight-timeline .lane-2::before{background:#b6a6ea} +.insight-timeline time{font:10px/1.7 var(--mono);letter-spacing:.06em;color:var(--faint);cursor:help} +.insight-timeline strong{display:block;font:11px/1.6 var(--mono);font-weight:400;color:#dde7f3;letter-spacing:.04em} +.insight-timeline small{display:block;font:9px/1.7 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-top:3px} +.insight-timeline p{font:12.5px/1.65 var(--sans);margin:6px 0 0;color:var(--muted);overflow-wrap:anywhere} + +/* ── 03 Flow ────────────────────────────────────────────────────────────── */ +.worker-card{border:1px solid var(--rule);border-radius:4px;background:rgba(10,15,24,.6);padding:22px;margin-bottom:16px} +.worker-heading{display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:12px} +.worker-title h3{margin:0} +.worker-title .insight-meta{margin-top:5px;letter-spacing:.12em;text-transform:uppercase;font-size:9px} +.worker-chips{display:flex;flex-wrap:wrap;gap:7px;justify-content:flex-end} +.worker-current{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;margin:18px 0 4px;padding-top:16px;border-top:1px solid var(--rule)} +.worker-current strong{font:400 19px/1.3 var(--serif);color:#eaf2fa;letter-spacing:-.015em} +.worker-flow-name{font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--quiet)} +.flow-nodes{margin:16px 0 10px;padding:14px 2px 6px;overflow-x:auto;border-top:1px solid var(--rule)} +.flow-art{display:block;max-width:100%;height:auto;min-width:420px} +.flow-art-label{font:13px var(--sans);fill:#dde7f3} +.flow-art-type{font:8px var(--mono);letter-spacing:.18em;fill:var(--faint)} +.flow-art-badge{font:8px var(--mono);letter-spacing:.18em;fill:var(--accent)} +.flow-art-node.is-current .flow-art-label{fill:#f2fbff} +.flow-foot{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint)} + +/* ── 04 Transcript ──────────────────────────────────────────────────────── */ +.insight-conversations input[type=search]{max-width:420px;margin-bottom:18px;font:11px var(--mono);padding:10px 12px} +.conversation-layout{display:grid;grid-template-columns:252px minmax(0,1fr);gap:18px;align-items:start} +.conversation-list{max-height:640px;overflow:auto;display:grid;gap:1px} +.conversation-list button{width:100%;padding:14px 14px 14px 15px;text-align:left;font:13px/1.45 var(--sans);color:var(--muted); + border:0;border-left:1px solid var(--rule);border-radius:0;background:transparent} +.conversation-list button small{display:block;margin-top:6px;font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint)} +.conversation-list button:hover{background:rgba(127,209,222,.05);border-left-color:var(--rule-strong);color:var(--text)} +.conversation-list button[aria-pressed=true]{color:#eafcff;border-left-color:var(--accent);background:linear-gradient(90deg,rgba(127,209,222,.12),transparent 88%)} +.transcript{border:1px solid var(--rule);border-radius:4px;max-height:720px;overflow:auto;background:linear-gradient(180deg,rgba(11,16,26,.85),rgba(7,11,18,.85));padding:24px 26px} +.transcript>header{border-bottom:1px solid var(--rule);padding-bottom:14px;margin-bottom:6px;position:sticky;top:-24px;background:#080c14;z-index:2} +.transcript>header h3{font:400 21px var(--serif);letter-spacing:-.02em} +.transcript>header .insight-meta{margin:6px 0 10px} +.transcript-chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:10px} +.transcript>header code{display:block;margin-bottom:10px} +.transcript>header button{font:9px var(--mono);letter-spacing:.14em;text-transform:uppercase;padding:6px 10px} +.transcript-message{padding:18px 0 18px 16px;border-bottom:1px solid var(--rule);position:relative} +.transcript-message::before{content:'';position:absolute;left:0;top:22px;bottom:22px;width:1px;background:var(--rule-strong);opacity:.7} +.message-assistant::before{background:var(--signal)} +.message-user::before{background:#c8bcff} +.message-tool::before{background:#e0bb80} +.transcript-message header{display:flex;gap:14px;justify-content:space-between;align-items:baseline} +.transcript-message strong{font:9px var(--mono);letter-spacing:.18em;text-transform:uppercase;color:var(--signal);font-weight:400} +.message-user strong{color:#c8bcff} +.message-tool strong{color:#e0bb80} +.transcript-message header small{font:9px var(--mono);letter-spacing:.08em;color:var(--faint)} +.message-content{white-space:pre-wrap;overflow-wrap:anywhere;font:13.5px/1.8 var(--sans);color:#d4dde9;margin:10px 0 0} +.transcript-tool{border:1px solid var(--rule);border-left:2px solid #e0bb8099;padding:11px 13px;margin-top:12px;background:rgba(16,24,37,.75);border-radius:3px} +.transcript-tool summary{font:9px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted)} +.transcript-tool pre{font:11px/1.65 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;color:var(--quiet);margin:10px 0 0} + +/* ── 05 Providers ───────────────────────────────────────────────────────── */ +.provider-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px} +.provider-column{min-width:0} +.provider-head{display:flex;align-items:baseline;gap:12px;border-bottom:1px solid var(--rule);padding-bottom:12px;margin-bottom:14px} +.provider-head h3{display:flex;align-items:center;gap:10px} +.provider-head h3::before{content:'';width:10px;height:10px;border-radius:2px;transform:rotate(45deg);background:#a8c697} +.provider-fly .provider-head h3::before{background:#b1a4eb;border-radius:50%;transform:none} +.provider-head .insight-meta{margin-left:auto;letter-spacing:.12em;text-transform:uppercase;font-size:9px} +.machine-card,.reservation-card{border:1px solid var(--rule);border-radius:4px;background:rgba(11,16,26,.62);padding:18px;margin-bottom:12px} +/* A provider receipt is a slip: a perforated top edge, then the figures. */ +.machine-card{position:relative;padding-top:20px} +.machine-card::before{content:'';position:absolute;left:0;right:0;top:0;height:3px; + background:repeating-linear-gradient(90deg,var(--rule-strong) 0 4px,transparent 4px 9px);opacity:.75} +.machine-card.is-retained::before{opacity:.4} +.machine-top{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px} +.machine-scope{font:9px var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-left:auto} +.machine-card h4{margin:0 0 6px} +.machine-state{font:11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:#d7e2f0;margin:0 0 8px} +.machine-facts{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:12px 0 0;padding:0;font:9px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--quiet)} +.machine-facts li{border:1px solid var(--rule);border-radius:2px;padding:4px 7px;overflow-wrap:anywhere} +.machine-card>small,.reservation-card>small{display:block;margin-top:12px;font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint)} +.machine-receipts{margin-top:14px;border-top:1px solid var(--rule);padding-top:12px} +.machine-receipts summary{font:9px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--quiet)} +.machine-receipts p{display:flex;justify-content:space-between;gap:12px;margin:8px 0 0;font:10px var(--mono)} +.machine-receipts p b{font-weight:400;color:#d7e2f0} +.machine-receipts code{display:block;margin-top:8px} +.reservation-card{border-style:dashed;background:rgba(14,18,28,.45)} +.reservation-card p{display:flex;justify-content:space-between;gap:12px;margin:8px 0 0;font:10px var(--mono);color:var(--quiet)} +.reservation-card p b{font-weight:400;color:#d7e2f0} + +/* ── Responsive framing ─────────────────────────────────────────────────── */ +@media(max-width:1280px){ + .metrics-grid{grid-template-columns:220px repeat(3,minmax(0,1fr))} + .metric-lead{grid-row:span 2} + .metric-card:nth-child(6){grid-column:span 2} + .metric-card:nth-child(n+5){border-top:1px solid var(--rule)} +} +@media(max-width:1000px){ + .deck-body{grid-template-columns:minmax(0,1fr);gap:18px} + .deck-spine{position:sticky;top:0;z-index:30;margin:0 -16px;padding:10px 16px; + background:rgba(7,10,17,.92);backdrop-filter:blur(12px);border-bottom:1px solid var(--rule)} + .deck-spine::before{display:none} + .deck-spine ol{grid-auto-flow:column;grid-auto-columns:max-content;overflow:auto;gap:6px} + .spine-link{border-left:0;border-bottom:1px solid var(--rule);padding:8px 12px;white-space:nowrap} + .spine-link[aria-current]{border-left:0;border-bottom-color:var(--accent);background:rgba(127,209,222,.1)} + .insight-section{scroll-margin-top:66px} + .conversation-layout{grid-template-columns:1fr} + .conversation-list{max-height:240px} + .provider-grid{grid-template-columns:1fr} +} +@media(max-width:800px){ + .swarm-shell{height:auto;min-height:0} + .deck-head{grid-template-columns:auto minmax(0,1fr);gap:14px} + .deck-scope{grid-column:1/-1;justify-items:start;margin-top:4px} + .deck-scope select{max-width:100%;width:100%} + .metrics-grid{grid-template-columns:repeat(2,minmax(0,1fr))} + .metric-card{padding:16px 14px;border-top:1px solid var(--rule)} + .metric-card:nth-child(odd){border-left:0;padding-left:0} + .metric-lead{grid-column:1/-1;grid-row:auto} + .metric-card:nth-child(6){grid-column:auto} + .vital-dial{width:112px;height:112px} + .insight-timeline li{grid-template-columns:14px minmax(0,1fr);gap:6px 12px} + .insight-timeline time{grid-column:2} + .worker-chips{justify-content:flex-start} + .transcript{padding:18px} + .transcript>header{top:-18px} + .transcript-message header{display:block} + .budget-legend{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 0} +} +@media(max-width:520px){ + .deck-head{padding-top:28px} + .deck-ident h1{font-size:26px} + .metric-card{padding:14px 12px} + .metric-card strong{font-size:30px;margin:10px 0 8px} + .machine-facts{font-size:8.5px} + .worker-card,.budget-card,.machine-card,.reservation-card{padding:16px} + .ribbon-art{height:66px} +} diff --git a/src/swarm/insights.ts b/src/swarm/insights.ts index 1b5662a..80d2896 100644 --- a/src/swarm/insights.ts +++ b/src/swarm/insights.ts @@ -1,34 +1,77 @@ import './insights.css'; import { ObservationFloors, type Observatory, type ObservationSource, type Worker, type Conversation } from './insights-model'; import { activities, type Swarm, type Selection } from './model'; +import { arcDial, budgetRule, flowDiagram, oMark, ribbon, sourceHue, type Segment } from './marks'; const el = (tag: T, text?: string, cls?: string): HTMLElementTagNameMap[T] => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; }; const btn = (label: string, action: () => void) => { const b = el('button', label); b.type = 'button'; b.onclick = action; return b; }; const time = (v: string) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(v)); +const atTime = (v: string) => new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' }).format(new Date(v)); const money = (c: number) => `$${(c / 100).toFixed(2)}`; const key = (s: ObservationSource, w: Worker, c: Conversation) => JSON.stringify([s.sourceId, s.factoryId, w.id, c.id]); const empty = (): Observatory => ({ schemaVersion: 1, scope: 'factory-observatory', commands: false, observedAt: new Date().toISOString(), sources: [] }); +/** Short, factual badge. The long wording lives in its tooltip and in the + * section's Evidence drawer, never in the reading flow. */ +const chip = (text: string, tone?: string, title?: string) => { const s = el('span', text, tone ? `chip chip-${tone}` : 'chip'); if (title) s.title = title; return s; }; +const drawer = (summary: string, lines: string[]) => { const d = el('details', undefined, 'evidence-drawer'); d.append(el('summary', summary)); for (const line of lines) d.append(el('p', line)); return d; }; +const pad = (n: number) => String(n).padStart(2, '0'); + +/** Section order is the reading order: the deck is one long page, never tabs. */ +const SECTIONS: Array<[string, string, string, string[]]> = [ + ['analytics', 'Ledger', 'Paid budget per registered authority.', ['Held, limit and metered figures come from the paid ledger at its own revision. A logical cell allocation is a separate measure.', 'Final paid billing is not established by this contract.']], + ['history', 'Timeline', 'Controller events, provider receipts and transcript stamps.', ['Each lane keeps its own provenance and is not merged into a single source of truth.', 'Controller history starts when this view connects; snapshot state does not reconstruct earlier events.']], + ['workers', 'Flow', 'Recorded flow structure and the reported execution position.', ['The marked node comes from the conversation. Every other node is flow definition: completion, idle state and worker health are not inferred.', 'Machine-to-worker and cell-to-worker relationships stay unbound unless they were recorded.']], + ['conversations', 'Transcript', 'The recorded conversation, including tool calls.', ['Messages and tool arguments are displayed inside a bounded window; a shortened record is marked where it occurs.']], + ['machines', 'Providers', 'Modal and Fly observations.', ['Provider observations are independent of cell lifecycle and task leases, and provider state does not establish worker health.', 'A reservation is spending evidence. It does not identify or prove a running machine.']], +]; export function mountInsights(onSelect: (selection: Selection | null) => void) { let swarm: Swarm, selection: Selection | null = null, detail = empty(), chosen: string | null = null, filter = '', signature = '', temporalSignature = '', transcriptSignature = '', sourceSignature = '', transcriptKey: string | null = null, clock = Date.now(); const floors = new ObservationFloors(); - const root = el('section', undefined, 'observatory-insights'); root.id = 'observatory-insights'; root.setAttribute('aria-label', 'Observatory analytics and worker detail'); - const header = el('header', undefined, 'insights-heading'), title = el('div'); title.append(el('p', 'OBSERVATORY / INSTRUMENTS', 'insight-kicker'), el('h1', 'The work, in focus.')); - const freshness = el('p', 'Waiting for observations', 'insight-freshness'); freshness.setAttribute('role', 'status'); header.append(title, freshness); - const nav = el('nav', undefined, 'insights-nav'); nav.setAttribute('aria-label', 'Observation views'); - for (const [id, label] of [['analytics', 'Analytics'], ['history', 'Timeline'], ['workers', 'Worker nodes'], ['conversations', 'Conversations'], ['machines', 'Modal + Fly']]) nav.append(btn(label, () => document.getElementById(`insights-${id}`)?.scrollIntoView({ behavior: 'auto', block: 'start' }))); - const sourcePicker = el('select'); sourcePicker.setAttribute('aria-label', 'Observation source'); sourcePicker.onchange = () => onSelect(sourcePicker.value ? { sourceId: sourcePicker.value, kind: 'source', id: sourcePicker.value } : null); nav.append(sourcePicker); - const panels = new Map(); - for (const [id, label, note] of [ - ['analytics', 'Analytics', 'Observed work, usage and budget. Missing measurements remain unknown.'], - ['history', 'Timeline', 'Controller events, provider receipts and transcript timestamps keep their own provenance.'], - ['workers', 'Inside the worker', 'Flow structure and the conversation’s recorded execution position.'], - ['conversations', 'Conversations & transcript', 'Read the actual conversation, including tool calls and results.'], - ['machines', 'Machines & provider resources', 'Modal and Fly observations are independent of cell lifecycle and task leases.'], - ]) { const section = el('section', undefined, `insight-section insight-${id}`); section.id = `insights-${id}`; section.append(el('h2', label), el('p', note, 'insight-subtitle')); const content = el('div', undefined, 'insight-content'); panels.set(id, content); section.append(content); root.append(section); } - root.prepend(header, nav); + const root = el('section', undefined, 'observatory-insights'); root.id = 'observatory-insights'; root.setAttribute('aria-label', 'Observatory instruments'); + const head = el('header', undefined, 'deck-head'); + const markSlot = el('div', undefined, 'deck-mark'); + const ident = el('div', undefined, 'deck-ident'); + const scopeTitle = el('h1', 'All registered sources'); + const freshness = el('p', 'Waiting for observations', 'insight-freshness'); freshness.setAttribute('role', 'status'); + ident.append(el('p', 'OBSERVATORY INSTRUMENTS', 'insight-kicker'), scopeTitle, freshness); + const sourcePicker = el('select'); sourcePicker.setAttribute('aria-label', 'Observation source'); + sourcePicker.onchange = () => onSelect(sourcePicker.value ? { sourceId: sourcePicker.value, kind: 'source', id: sourcePicker.value } : null); + const scope = el('label', undefined, 'deck-scope'); scope.append(el('span', 'SCOPE'), sourcePicker); + const vitals = el('div', undefined, 'deck-vitals'); + head.append(markSlot, ident, scope, vitals); + const body = el('div', undefined, 'deck-body'); + const spine = el('nav', undefined, 'deck-spine'); spine.setAttribute('aria-label', 'Observation views'); + const spineList = el('ol'); spine.append(spineList); + const column = el('div', undefined, 'deck-sections'); + const panels = new Map(), links = new Map(); + for (const [index, [id, label, note, evidence]] of SECTIONS.entries()) { + const link = el('a', undefined, 'spine-link'); link.href = `#insights-${id}`; + // The URL fragment belongs to the authority selection, not section navigation. + link.onclick = event => { event.preventDefault(); document.getElementById(`insights-${id}`)!.scrollIntoView({ block: 'start' }); }; + link.append(el('i', pad(index + 1)), el('span', label)); + const item = el('li'); item.append(link); spineList.append(item); links.set(id, link); + const section = el('section', undefined, `insight-section insight-${id}`); section.id = `insights-${id}`; + const header = el('header', undefined, 'section-head'); + const line = el('div', undefined, 'section-line'); + line.append(el('span', pad(index + 1), 'section-numeral'), el('h2', label), el('p', note, 'insight-subtitle')); + header.append(line, drawer('Evidence', evidence)); + const content = el('div', undefined, 'insight-content'); panels.set(id, content); + section.append(header, content); column.append(section); + } + body.append(spine, column); root.append(head, body); document.querySelector('#swarm-app > footer')!.before(root); - const shortcut = btn('Instruments ↓', () => root.scrollIntoView({ behavior: 'auto' })); document.getElementById('swarm-find')!.after(shortcut); + const shortcut = btn('Instruments', () => root.scrollIntoView({ block: 'start' })); shortcut.id = 'swarm-instruments'; + document.getElementById('swarm-find')!.after(shortcut); + if (typeof IntersectionObserver === 'function') { + const watcher = new IntersectionObserver(entries => { + for (const entry of entries) { + const link = links.get(entry.target.id.replace('insights-', '')); + if (link && entry.isIntersecting) { for (const other of links.values()) other.removeAttribute('aria-current'); link.setAttribute('aria-current', 'true'); } + } + }, { rootMargin: '-15% 0px -70% 0px' }); + for (const id of links.keys()) watcher.observe(document.getElementById(`insights-${id}`)!); + } const search = el('input'); search.type = 'search'; search.placeholder = 'Find a conversation'; search.setAttribute('aria-label', 'Find a conversation'); search.oninput = () => { filter = search.value; paintConversations(); }; const conversationContent = panels.get('conversations')!, conversationBody = el('div', undefined, 'conversation-layout'); conversationContent.append(search, conversationBody); function scoped() { return detail.sources.filter(s => !selection || s.sourceId === selection.sourceId); } @@ -36,67 +79,111 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { function currentConversation() { const list = allConversations(); return list.find(c => c.key === chosen) ?? list[0]; } function scopeLabel(s: ObservationSource) { return swarm.sources.find(a => a.id === s.sourceId)?.label ?? s.sourceId; } function recent(observedAt: string) { const age = clock - Date.parse(observedAt); return swarm.sample || (age >= 0 && age <= 15000); } + function sources() { return swarm.sources.filter(s => !selection || s.id === selection.sourceId); } function timeSignature() { return JSON.stringify([ - swarm.sources.filter(s => !selection || s.id === selection.sourceId).map(s => [s.status === 'observed' && !!s.snapshot && recent(s.snapshot.observedAt), [...activities(s, clock, swarm.sample).values()].filter(a => a === 'recent').length]), + sources().map(s => [s.status === 'observed' && !!s.snapshot && recent(s.snapshot.observedAt), [...activities(s, clock, swarm.sample).values()].filter(a => a === 'recent').length]), scoped().map(s => [s.workers.map(w => recent(w.observedAt)), s.machines.map(m => recent(m.observedAt))]), ]); } - function card(label: string, value: string, note: string) { const c = el('article', undefined, 'metric-card'); c.append(el('p', label, 'insight-kicker'), el('strong', value), el('small', note)); return c; } + /** One reading: label, figure, and the shortest honest qualifier. */ + function card(label: string, value: string, note: string, extra = '') { const c = el('article', undefined, `metric-card${extra ? ` ${extra}` : ''}`); c.append(el('p', label, 'insight-kicker'), el('strong', value), el('small', note)); return c; } function paintAnalytics() { const content = panels.get('analytics')!; content.replaceChildren(); - const sources = swarm.sources.filter(s => !selection || s.id === selection.sourceId), details = scoped(); - const tasks = sources.flatMap(s => s.snapshot?.snapshot.tasks ?? []), effects = sources.flatMap(s => s.snapshot?.snapshot.effects ?? []); - const activity = sources.flatMap(s => [...activities(s, clock, swarm.sample).values()]); + const observed = sources(), details = scoped(); + const tasks = observed.flatMap(s => s.snapshot?.snapshot.tasks ?? []), effects = observed.flatMap(s => s.snapshot?.snapshot.effects ?? []); + const activity = observed.flatMap(s => [...activities(s, clock, swarm.sample).values()]); + const leased = activity.filter(a => a === 'recent').length; const conversations = allConversations(), knownTokens = conversations.filter(v => v.c.tokens !== null); - const metrics = el('div', undefined, 'metrics-grid'); - metrics.append(card('Sources', String(sources.length), `${sources.filter(s => s.status !== 'observed' || !s.snapshot || !recent(s.snapshot.observedAt)).length} stale or unavailable`), - card('Recent leased cells', String(activity.filter(a => a === 'recent').length), `${activity.length} recorded cells · host health separate`), - card('Tasks', String(tasks.length), `${tasks.filter(t => t.status === 'running').length} running · ${tasks.filter(t => ['delivered', 'completed'].includes(t.status)).length} delivered / operation completed`), - card('Unknown effects', String(effects.filter(e => e.state === 'unknown').length), 'External outcomes require reconciliation'), - card('Conversations', String(conversations.length), `${conversations.filter(v => v.c.status === 'running' && recent(v.w.observedAt)).length} recently reported running · ${details.reduce((n, s) => n + s.workers.length, 0)} registered workers`), - card('Reported tokens', knownTokens.length ? knownTokens.reduce((n, v) => n + v.c.tokens!, 0).toLocaleString() : 'Unknown', `Sum of ${knownTokens.length} reported totals; nested runs may overlap`)); content.append(metrics); - const budgets = el('div', undefined, 'budget-grid'); + const stale = observed.filter(s => s.status !== 'observed' || !s.snapshot || !recent(s.snapshot.observedAt)).length; + const grid = el('div', undefined, 'metrics-grid'); + const leadCard = card('Recent leased cells', String(leased), `of ${activity.length} recorded cells · records, not host health`, 'metric-lead'); + const dial = el('div', undefined, 'vital-dial'); dial.append(arcDial(leased, activity.length), leadCard.querySelector('strong')!); + leadCard.insertBefore(dial, leadCard.querySelector('small')); + grid.append(leadCard, + card('Sources', String(observed.length), `${stale} stale or unavailable`), + card('Tasks', String(tasks.length), `${tasks.filter(t => t.status === 'running').length} running · ${tasks.filter(t => t.status === 'delivered').length} delivered · ${tasks.filter(t => t.status === 'completed').length} completed`), + card('Unknown effects', String(effects.filter(e => e.state === 'unknown').length), 'awaiting reconciliation'), + card('Conversations', String(conversations.length), `${conversations.filter(v => v.c.status === 'running' && recent(v.w.observedAt)).length} reported running · ${details.reduce((n, s) => n + s.workers.length, 0)} workers`), + card('Reported tokens', knownTokens.length ? knownTokens.reduce((n, v) => n + v.c.tokens!, 0).toLocaleString() : 'Unknown', `${knownTokens.length} reported totals · nested runs may overlap`)); + vitals.replaceChildren(grid); + const ledger = el('div', undefined, 'budget-grid'); for (const s of details) if (s.budget) { - const b = s.budget, panel = el('article', undefined, 'budget-card'); panel.append(el('h3', `${scopeLabel(s)} · paid budget`)); - const meter = el('meter'); meter.min = 0; meter.max = Math.max(1, b.limitCents, b.heldCents); meter.value = b.heldCents; meter.setAttribute('aria-label', 'Committed paid budget'); - panel.append(el('p', `${money(b.heldCents)} held / ${money(b.limitCents)} limit`, 'budget-total'), meter, el('p', `${money(b.availableCents)} available · ${b.meteredCents === null ? 'final spend unknown' : `${money(b.meteredCents)} metered`}${b.incomplete ? ' · billing incomplete' : ''}`, 'insight-meta')); + const b = s.budget, panel = el('article', undefined, 'budget-card'); + const heading = el('div', undefined, 'budget-heading'); + heading.append(el('h3', scopeLabel(s)), chip(`REV ${b.revision}`, 'quiet', 'Paid ledger revision')); + if (b.incomplete) heading.append(chip('BILLING INCOMPLETE', 'warn', 'Some charges have not been reported yet.')); + panel.append(heading, el('p', money(b.heldCents), 'budget-total'), budgetRule(b.heldCents, b.limitCents, b.meteredCents)); + const legend = el('dl', undefined, 'budget-legend'); + for (const [term, value] of [['held', money(b.heldCents)], ['limit', money(b.limitCents)], ['available', money(b.availableCents)], ['metered', b.meteredCents === null ? 'unknown' : money(b.meteredCents)]] as Array<[string, string]>) { + const reading = el('div'); reading.append(el('dt', term), el('dd', value)); legend.append(reading); + } + panel.append(legend); if (b.heldCents > b.limitCents) panel.append(el('p', `${money(b.heldCents - b.limitCents)} over committed`, 'insight-warning')); - panel.append(el('small', `Paid ledger revision ${b.revision}. Logical cell allocation is a separate measure.`)); budgets.append(panel); + ledger.append(panel); } - if (!budgets.childElementCount) budgets.append(el('p', 'Paid budget observations are unavailable.', 'insight-empty')); content.append(budgets); + if (!ledger.childElementCount) ledger.append(el('p', 'No paid budget observation.', 'insight-empty')); + content.append(ledger); } + const LANES = ['var(--signal)', '#9ed7a6', '#b6a6ea'], LANE_NAMES = ['controller', 'provider receipt', 'transcript']; function paintHistory() { - const content = panels.get('history')!, rows: Array<{ at: string; label: string; source: string; subject: string }> = []; - for (const s of swarm.sources.filter(s => !selection || s.id === selection.sourceId)) for (const e of s.events) rows.push({ at: e.observedAt, label: e.type, source: `${s.label} / controller #${e.seq}`, subject: e.subject }); - for (const s of scoped()) for (const m of s.machines) for (const o of m.operations) rows.push({ at: o.observedAt, label: `${o.label} · ${o.state}`, source: `${scopeLabel(s)} / ${m.provider} receipt`, subject: m.runId ?? m.resourceId ?? '' }); - for (const { s, w, c } of allConversations()) for (const m of c.messages) if (m.at) rows.push({ at: m.at, label: `${m.role}${m.tools.length ? ` · ${m.tools.map(t => t.name).join(', ')}` : ''}`, source: `${scopeLabel(s)} / ${w.label} / transcript`, subject: m.content.slice(0, 180) || 'Tool call' }); + const content = panels.get('history')!, rows: Array<{ at: string; lane: 0 | 1 | 2; label: string; source: string; subject: string }> = []; + for (const s of sources()) for (const e of s.events) rows.push({ at: e.observedAt, lane: 0, label: e.type, source: `${s.label} · controller #${e.seq}`, subject: e.subject }); + for (const s of scoped()) for (const m of s.machines) for (const o of m.operations) rows.push({ at: o.observedAt, lane: 1, label: `${o.label} · ${o.state}`, source: `${scopeLabel(s)} · ${m.provider} receipt`, subject: m.runId ?? m.resourceId ?? '' }); + for (const { s, w, c } of allConversations()) for (const m of c.messages) if (m.at) rows.push({ at: m.at, lane: 2, label: `${m.role}${m.tools.length ? ` · ${m.tools.map(t => t.name).join(', ')}` : ''}`, source: `${scopeLabel(s)} · ${w.label}`, subject: m.content.slice(0, 180) || 'Tool call' }); rows.sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); content.replaceChildren(); - if (!rows.length) { content.append(el('p', 'No timestamped history has been observed. Snapshot state does not reconstruct earlier events.', 'insight-empty')); return; } + if (!rows.length) { content.append(el('p', 'No timestamped record observed.', 'insight-empty')); return; } + const chart = el('div', undefined, 'ribbon'); + chart.append(ribbon(rows.map(r => ({ at: Date.parse(r.at), lane: r.lane })), LANES)); + const axis = el('div', undefined, 'ribbon-axis'); + axis.append(el('span', atTime(rows[rows.length - 1]!.at)), el('span', `${rows.length} records`), el('span', atTime(rows[0]!.at))); + const legend = el('ul', undefined, 'ribbon-legend'); + for (const [index, name] of LANE_NAMES.entries()) { const item = el('li', undefined, `lane-${index}`); item.append(el('i'), el('span', `${name} · ${rows.filter(r => r.lane === index).length}`)); legend.append(item); } + content.append(chart, axis, legend); const list = el('ol', undefined, 'insight-timeline'); - for (const row of rows.slice(0, 200)) { const item = el('li'); const stamp = el('time', time(row.at)); stamp.dateTime = row.at; const text = el('div'); text.append(el('strong', row.label), el('small', row.source), el('p', row.subject)); item.append(stamp, text); list.append(item); } - content.append(el('p', `${Math.min(rows.length, 200)} newest timestamped records${rows.length > 200 ? ` of ${rows.length}` : ''} · controller history starts when this view connects`, 'insight-meta'), list); + for (const row of rows.slice(0, 200)) { + // Every record sits inside the observed window, so the clock carries the + // reading and the full stamp stays available on the element itself. + const item = el('li', undefined, `lane-${row.lane}`); const stamp = el('time', atTime(row.at)); stamp.dateTime = row.at; stamp.title = time(row.at); + const text = el('div'); text.append(el('strong', row.label), el('small', row.source), el('p', row.subject)); + item.append(stamp, text); list.append(item); + } + if (rows.length > 200) content.append(el('p', `newest 200 of ${rows.length}`, 'insight-meta')); + content.append(list); } function paintWorkers() { const content = panels.get('workers')!; content.replaceChildren(); const selected = currentConversation(); for (const s of scoped()) for (const w of s.workers) { const c = selected?.w === w ? selected.c : w.conversations[0], f = w.flows.find(f => f.id === c?.flowId), section = el('article', undefined, 'worker-card'); - const heading = el('div', undefined, 'worker-heading'); heading.append(el('h3', w.label), el('span', `${w.availability === 'observed' && !recent(w.observedAt) ? 'stale observation' : w.availability} · ${scopeLabel(s)}`, 'status-pill')); section.append(heading); - section.append(el('p', `${w.cellId ? `Registered cell binding: ${w.cellId}` : 'Unbound to a FACTORY cell'} · ${w.machineKey ? 'registered machine binding' : 'machine binding unavailable'}`, 'insight-meta')); - if (!c) section.append(el('p', w.conversationStatus === 'not_configured' ? 'No conversation IDs are registered for this worker.' : 'Conversation access unavailable. Transcript and execution position cleared.', 'insight-empty')); + const fresh = w.availability === 'observed' && recent(w.observedAt); + const heading = el('div', undefined, 'worker-heading'); + const title = el('div', undefined, 'worker-title'); + title.append(el('h3', w.label), el('p', scopeLabel(s), 'insight-meta')); + const marks = el('div', undefined, 'worker-chips'); + marks.append(chip(w.availability === 'observed' && !recent(w.observedAt) ? 'stale observation' : w.availability, fresh ? 'live' : 'quiet', `Worker observation read ${time(w.observedAt)}`)); + marks.append(chip(w.cellId ? `cell ${w.cellId}` : 'no cell binding', 'quiet', 'Recorded FACTORY cell binding')); + marks.append(chip(w.machineKey ? 'machine bound' : 'no machine binding', 'quiet', 'Recorded machine binding')); + heading.append(title, marks); section.append(heading); + if (!c) section.append(el('p', w.conversationStatus === 'not_configured' ? 'No conversation registered.' : 'Conversation access unavailable · transcript and position cleared.', 'insight-empty')); else { - section.append(el('p', `${c.title} · ${c.status}`, 'worker-current'), el('p', `Flow: ${f?.name ?? c.flowId ?? 'unknown'} · recorded position: ${c.currentNodeId ?? 'unknown'}`, 'insight-meta')); - if (!f) section.append(el('p', 'Flow structure unavailable. No node topology or execution history is inferred.', 'insight-empty')); + const current = el('div', undefined, 'worker-current'); + current.append(el('strong', c.title), chip(c.status, c.status === 'running' && fresh ? 'live' : 'quiet'), el('span', `${f?.name ?? c.flowId ?? 'flow unknown'}`, 'worker-flow-name')); + section.append(current); + if (!f) section.append(el('p', 'Flow structure unavailable · no topology inferred.', 'insight-empty')); else { - const graph = el('div', undefined, 'flow-nodes'); graph.setAttribute('aria-label', `${f.name} execution nodes`); - for (const n of f.nodes) { const at = c.currentNodeId === n.id, node = el('article', undefined, `flow-node${at ? ' node-current' : ''}`); const incoming = f.edges.filter(e => e.target === n.id).map(e => f.nodes.find(n => n.id === e.source)?.label ?? e.source); - node.append(el('span', at ? c.status === 'running' && w.availability === 'observed' && recent(w.observedAt) ? 'REPORTED RUNNING HERE' : 'LAST RECORDED POSITION' : 'FLOW DEFINITION', 'insight-kicker'), el('strong', n.label), el('small', n.type), el('code', n.id)); - if (incoming.length) node.append(el('small', `← ${incoming.join(', ')}`)); graph.append(node); - } section.append(graph, el('p', 'The current position comes from the conversation. Other nodes are definitions; completion and idle state are not inferred.', 'insight-meta')); + const badge = c.currentNodeId ? c.status === 'running' && fresh ? 'REPORTED RUNNING HERE' : 'LAST RECORDED POSITION' : null; + const graph = el('div', undefined, 'flow-nodes'); graph.setAttribute('role', 'img'); + graph.setAttribute('aria-label', `${f.name}: ${f.nodes.length} recorded nodes, ${f.edges.length} recorded edges${c.currentNodeId ? `, reported position ${c.currentNodeId}` : ''}. Nodes: ${f.nodes.map(n => `${n.label} (${n.type}, ${n.id})`).join('; ')}. Edges: ${f.edges.map(e => `${e.source} to ${e.target}`).join('; ')}`); + graph.append(flowDiagram(f, c.currentNodeId, badge)); + const foot = el('p', undefined, 'flow-foot'); + foot.append(chip(badge ?? 'FLOW DEFINITION', badge === 'REPORTED RUNNING HERE' ? 'live' : 'quiet', 'Position is reported by the conversation, not measured from the worker.'), + el('span', `${f.nodes.length} nodes · ${f.edges.length} edges · position ${c.currentNodeId ?? 'unknown'}`)); + section.append(graph, foot); } - } content.append(section); + } + content.append(section); } - if (!content.childElementCount) content.append(el('p', 'No FLUJO workers are registered with this view. Machine-to-worker and cell-to-worker relationships remain unbound.', 'insight-empty')); + if (!content.childElementCount) content.append(el('p', 'No FLUJO worker registered with this view.', 'insight-empty')); } function paintConversations() { const selected = currentConversation(), stamp = JSON.stringify([allConversations().map(v => [v.key, v.c]), selected?.key, filter]); @@ -105,40 +192,88 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { conversationBody.replaceChildren(); const list = el('div', undefined, 'conversation-list'); for (const v of allConversations().filter(v => `${v.c.title} ${v.c.id} ${v.w.label}`.toLowerCase().includes(filter.toLowerCase()))) { - const b = btn(v.c.title, () => { clock = Date.now(); chosen = v.key; paintConversations(); paintWorkers(); }); b.setAttribute('aria-pressed', String(v.key === selected?.key)); b.append(el('small', `${v.c.status} · ${v.w.label}`)); list.append(b); + const b = btn(v.c.title, () => { clock = Date.now(); chosen = v.key; paintConversations(); paintWorkers(); }); + b.setAttribute('aria-pressed', String(v.key === selected?.key)); + b.append(el('small', `${v.c.status} · ${v.w.label}`)); list.append(b); } - if (!list.childElementCount) list.append(el('p', filter ? 'No matching conversations.' : 'No accessible conversations observed.', 'insight-empty')); + if (!list.childElementCount) list.append(el('p', filter ? 'No matching conversation.' : 'No accessible conversation observed.', 'insight-empty')); conversationBody.append(list); if (!selected) return; - const { s, w, c } = selected, transcript = el('article', undefined, 'transcript'); const h = el('header'); h.append(el('h3', c.title), el('p', `${scopeLabel(s)} / ${w.label} · ${c.status} · updated ${time(c.updatedAt)}`, 'insight-meta'), el('code', c.id), el('p', `${c.messages.length} displayed / ${c.totalMessages} recorded messages${c.truncated ? ' · bounded window' : ''}${c.tokens !== null ? ` · ${c.tokens.toLocaleString()} tokens` : ''}${c.estimatedCostUsd !== null ? ` · $${c.estimatedCostUsd.toFixed(3)} estimated model cost` : ''}`, 'insight-meta'), btn('Latest message ↓', () => { transcript.scrollTop = transcript.scrollHeight; })); transcript.append(h); - for (const m of c.messages) { const message = el('section', undefined, `transcript-message message-${m.role}`); const label = el('header'); label.append(el('strong', m.role), el('small', [m.at ? time(m.at) : 'time not recorded', m.nodeId ? `node ${m.nodeId}` : ''].filter(Boolean).join(' · '))); message.append(label); + const { s, w, c } = selected, transcript = el('article', undefined, 'transcript'), h = el('header'); + const chips = el('div', undefined, 'transcript-chips'); + chips.append(chip(c.status, c.status === 'running' && recent(w.observedAt) ? 'live' : 'quiet'), chip(`${c.messages.length}/${c.totalMessages} messages`, 'quiet', 'Displayed messages of the recorded total')); + if (c.truncated) chips.append(chip('bounded window', 'warn', 'Older messages are outside the display bound.')); + if (c.tokens !== null) chips.append(chip(`${c.tokens.toLocaleString()} tokens`, 'quiet', 'Reported by the conversation')); + if (c.estimatedCostUsd !== null) chips.append(chip(`$${c.estimatedCostUsd.toFixed(3)} est.`, 'quiet', 'Estimated model cost, not paid billing')); + h.append(el('h3', c.title), el('p', `${scopeLabel(s)} · ${w.label} · updated ${time(c.updatedAt)}`, 'insight-meta'), chips, el('code', c.id), btn('Latest ↓', () => { transcript.scrollTop = transcript.scrollHeight; })); + transcript.append(h); + for (const m of c.messages) { + const message = el('section', undefined, `transcript-message message-${m.role}`), label = el('header'); + label.append(el('strong', m.role), el('small', [m.at ? atTime(m.at) : 'time not recorded', m.nodeId ? `node ${m.nodeId}` : ''].filter(Boolean).join(' · '))); + message.append(label); if (m.content) message.append(el('p', m.content, 'message-content')); - if (m.truncated) message.append(el('p', 'Long message or tool arguments shortened to the display bound.', 'insight-warning')); - for (const t of m.tools) { const tool = el('details', undefined, 'transcript-tool'); tool.open = true; tool.append(el('summary', `Tool call · ${t.name}`), el('pre', t.arguments || 'Arguments not recorded')); message.append(tool); } transcript.append(message); + if (m.truncated) message.append(el('p', 'shortened to the display bound', 'insight-warning')); + for (const t of m.tools) { const tool = el('details', undefined, 'transcript-tool'); tool.open = true; tool.append(el('summary', `Tool · ${t.name}`), el('pre', t.arguments || 'Arguments not recorded')); message.append(tool); } + transcript.append(message); } - if (!c.messages.length) transcript.append(el('p', 'No displayable messages recorded.', 'insight-empty')); conversationBody.append(transcript); transcript.scrollTop = selected.key !== transcriptKey || atEnd ? transcript.scrollHeight : oldScroll; transcriptKey = selected.key; + if (!c.messages.length) transcript.append(el('p', 'No displayable message recorded.', 'insight-empty')); + conversationBody.append(transcript); + transcript.scrollTop = selected.key !== transcriptKey || atEnd ? transcript.scrollHeight : oldScroll; transcriptKey = selected.key; } function paintMachines() { const content = panels.get('machines')!; content.replaceChildren(); const grid = el('div', undefined, 'provider-grid'); for (const provider of ['modal', 'fly'] as const) { const column = el('section', undefined, `provider-column provider-${provider}`), machineRows = scoped().flatMap(s => s.machines.filter(m => m.provider === provider).map(m => ({ s, m }))); - column.append(el('h3', provider === 'modal' ? 'Modal' : 'Fly.io'), el('p', `${machineRows.length} ${provider === 'modal' ? 'registered apps / runs' : 'machine observations'}`, 'insight-meta')); - for (const { s, m } of machineRows) { const c = el('article', undefined, 'machine-card'); c.append(el('div', `${m.freshness === 'provider_observed' ? recent(m.observedAt) ? 'PROVIDER OBSERVATION' : 'STALE PROVIDER OBSERVATION' : m.freshness === 'retained' ? 'HISTORICAL RECEIPTS' : 'UNAVAILABLE'} · ${scopeLabel(s)}`, 'insight-kicker'), el('h4', m.label), el('p', m.state, 'machine-state'), el('code', m.resourceId ?? m.runId ?? ''), el('p', `${m.kind} · ${m.region ?? 'region unknown'} · ${m.accountId ?? 'account identity not supplied'}`, 'insight-meta')); - if (m.appId) c.append(el('p', `App: ${m.appId}${m.runId ? ` · run ${m.runId}` : ''}${m.cellId ? ` · recorded cell ${m.cellId}` : ''}`, 'insight-meta')); - c.append(el('small', `${m.freshness === 'retained' ? m.provider === 'modal' ? 'Local journal read' : 'Retained receipt timestamp' : 'Provider read'} ${time(m.observedAt)}${m.freshness === 'retained' ? '. Current container count and machine health are unknown.' : '. Provider state does not establish worker health.'}`)); - if (m.operations.length) { const receipts = el('details'); receipts.open = true; receipts.append(el('summary', `${m.operations.length} recorded operations`)); for (const o of m.operations) receipts.append(el('p', `${o.label} · ${o.state}`, o.state === 'unknown' ? 'insight-warning' : 'insight-meta')); c.append(receipts); } column.append(c); - if (m.evidenceDigest) { const evidence = el('details'); evidence.open = true; evidence.append(el('summary', 'Evidence digest'), el('code', m.evidenceDigest)); c.append(evidence); } + const head = el('div', undefined, 'provider-head'); + head.append(el('h3', provider === 'modal' ? 'Modal' : 'Fly.io'), el('p', `${machineRows.length} ${provider === 'modal' ? 'apps / runs' : 'machines'}`, 'insight-meta')); + column.append(head); + for (const { s, m } of machineRows) { + const live = m.freshness === 'provider_observed', fresh = live && recent(m.observedAt); + const c = el('article', undefined, `machine-card${m.freshness === 'retained' ? ' is-retained' : ''}`); + const top = el('div', undefined, 'machine-top'); + top.append(chip(live ? fresh ? 'PROVIDER OBSERVATION' : 'STALE PROVIDER OBSERVATION' : m.freshness === 'retained' ? 'HISTORICAL RECEIPTS' : 'UNAVAILABLE', fresh ? 'live' : m.freshness === 'retained' ? 'hist' : 'quiet', + m.freshness === 'retained' ? `${m.provider === 'modal' ? 'Local journal read' : 'Retained receipt timestamp'} ${time(m.observedAt)}. Current container count and machine health are unknown.` : `Provider read ${time(m.observedAt)}. Provider state does not establish worker health.`), el('span', scopeLabel(s), 'machine-scope')); + c.append(top, el('h4', m.label), el('p', m.state, 'machine-state'), el('code', m.resourceId ?? m.runId ?? '')); + const facts = el('ul', undefined, 'machine-facts'); + for (const fact of [m.kind, m.region ?? 'region unknown', m.accountId ?? 'account not supplied', m.appId ? `app ${m.appId}` : '', m.runId ? `run ${m.runId}` : '', m.cellId ? `cell ${m.cellId}` : ''].filter(Boolean)) facts.append(el('li', fact)); + c.append(facts, el('small', `${m.freshness === 'retained' ? m.provider === 'modal' ? 'journal read' : 'retained receipt' : 'provider read'} ${time(m.observedAt)}`)); + if (m.operations.length) { + const receipts = el('details', undefined, 'machine-receipts'); receipts.open = true; receipts.append(el('summary', `${m.operations.length} recorded operations`)); + for (const o of m.operations) { const row = el('p', undefined, o.state === 'unknown' ? 'insight-warning' : 'insight-meta'); row.append(el('span', o.label), el('b', o.state)); receipts.append(row); } + c.append(receipts); + } + if (m.evidenceDigest) { const evidence = el('details', undefined, 'machine-receipts'); evidence.open = true; evidence.append(el('summary', 'Evidence digest'), el('code', m.evidenceDigest)); c.append(evidence); } + column.append(c); } for (const s of scoped()) { const reservations = s.budget?.reservations.filter(r => r.provider === provider) ?? []; - if (reservations.length) { const holds = el('article', undefined, 'reservation-card'); holds.append(el('h4', `${scopeLabel(s)} · paid reservations`)); for (const r of reservations) holds.append(el('p', `${r.id} · ${r.state} · ${money(r.heldCents)} held`, 'insight-meta')); holds.append(el('small', 'A reservation is spending evidence. It does not identify or prove a running machine.')); column.append(holds); } + if (reservations.length) { + const holds = el('article', undefined, 'reservation-card'); holds.append(el('h4', `${scopeLabel(s)} · reservations`)); + for (const r of reservations) { const row = el('p', undefined, 'insight-meta'); row.append(el('span', r.id), el('b', `${money(r.heldCents)} · ${r.state}`)); holds.append(row); } + holds.append(el('small', 'spending evidence · not proof of a running machine')); column.append(holds); + } const observed = provider === 'modal' ? s.modalStatus : s.flyStatus; if (observed !== 'observed') column.append(el('p', `${scopeLabel(s)} · ${observed === 'not_configured' ? 'no machine observer configured' : 'machine observations unavailable'}`, 'insight-empty')); } - if (!machineRows.length) column.append(el('p', 'Machine identity and current state are unknown until an owned provider observation is connected.', 'insight-empty')); grid.append(column); - } content.append(grid); + if (!machineRows.length) column.append(el('p', 'Machine identity and current state unknown until an owned provider observation is connected.', 'insight-empty')); + grid.append(column); + } + content.append(grid); + } + /** The O is the registry: one arc per registered authority, in the same + * deterministic hue the field uses. The mouth core is filled only when a + * running lease was counted in the current scope. */ + function paintMark() { + const segments: Segment[] = swarm.sources.map(s => ({ color: sourceHue(s.id), broken: s.status !== 'observed' })); + const leased = sources().some(s => [...activities(s, clock, swarm.sample).values()].some(a => a === 'recent')); + const mark = oMark(segments, leased, 56); + mark.setAttribute('role', 'img'); + mark.removeAttribute('aria-hidden'); + mark.setAttribute('aria-label', `${swarm.sources.length} registered authorities · ${leased ? 'a running lease is recorded in scope' : 'no running lease recorded in scope'}`); + markSlot.replaceChildren(mark); } - function paint() { paintAnalytics(); paintHistory(); paintWorkers(); paintConversations(); paintMachines(); } + function paint() { paintMark(); paintAnalytics(); paintHistory(); paintWorkers(); paintConversations(); paintMachines(); } + function temporal() { paintMark(); paintAnalytics(); paintWorkers(); paintMachines(); } return { observe(value: unknown, next: Swarm) { swarm = next; @@ -148,19 +283,26 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { render(next: Swarm, nextSelection: Selection | null) { swarm = next; selection = nextSelection; clock = Date.now(); const sourceStamp = JSON.stringify(next.sources.map(s => [s.id, s.factoryId, s.label])); - if (sourceStamp !== sourceSignature) { sourceSignature = sourceStamp; sourcePicker.replaceChildren(); const all = el('option', 'All registered sources'); all.value = ''; sourcePicker.append(all); for (const s of next.sources) { const o = el('option', s.label); o.value = s.id; sourcePicker.append(o); } } + if (sourceStamp !== sourceSignature) { + sourceSignature = sourceStamp; sourcePicker.replaceChildren(); + const all = el('option', 'All registered sources'); all.value = ''; sourcePicker.append(all); + for (const s of next.sources) { const o = el('option', s.label); o.value = s.id; sourcePicker.append(o); } + } sourcePicker.value = selection?.sourceId ?? ''; detail.sources = detail.sources.filter(s => next.sources.some(a => a.id === s.sourceId && a.factoryId === s.factoryId)); const selected = currentConversation(); if (chosen && !allConversations().some(v => v.key === chosen)) chosen = selected?.key ?? null; - const sources = scoped(); freshness.textContent = `${swarm.sample ? 'Sample data / design preview' : 'Live read-only / 5-second refresh'} · ${selection ? swarm.sources.find(s => s.id === selection!.sourceId)?.label ?? 'source' : 'all registered sources'} · ${sources.length ? `detail read ${time(detail.observedAt)}` : 'worker detail unavailable'}`; - const stamp = JSON.stringify([swarm, detail, selection]), temporal = timeSignature(); - if (stamp !== signature) { signature = stamp; temporalSignature = temporal; paint(); } - else if (temporal !== temporalSignature) { temporalSignature = temporal; paintAnalytics(); paintWorkers(); paintMachines(); } + const scopeName = selection ? swarm.sources.find(s => s.id === selection!.sourceId)?.label ?? 'source' : 'All registered sources'; + scopeTitle.textContent = scopeName; + root.style.setProperty('--accent', selection ? sourceHue(selection.sourceId) : '#7fd1de'); + freshness.textContent = `${swarm.sample ? 'Sample data · design preview' : 'Live read-only · 5s'} · ${scoped().length ? `detail read ${atTime(detail.observedAt)}` : 'worker detail unavailable'}`; + const stamp = JSON.stringify([swarm, detail, selection]), signatureTime = timeSignature(); + if (stamp !== signature) { signature = stamp; temporalSignature = signatureTime; paint(); } + else if (signatureTime !== temporalSignature) { temporalSignature = signatureTime; temporal(); } }, tick() { if (!swarm || !signature) return; - clock = Date.now(); const temporal = timeSignature(); - if (temporal !== temporalSignature) { temporalSignature = temporal; paintAnalytics(); paintWorkers(); paintMachines(); } + clock = Date.now(); const signatureTime = timeSignature(); + if (signatureTime !== temporalSignature) { temporalSignature = signatureTime; temporal(); } }, }; } diff --git a/src/swarm/main.ts b/src/swarm/main.ts index b20b088..8e35c89 100644 --- a/src/swarm/main.ts +++ b/src/swarm/main.ts @@ -6,6 +6,7 @@ import { activityEvidence, markerLegend, markerOf, markerTitle, SwarmRenderer, s import { swarmPreview } from './preview'; import { mountInsights } from './insights'; import { observatoryPreview } from './insights-preview'; +import { oMark } from './marks'; const $ = (id: string): HTMLElement => document.getElementById(id)!; const element = (tag: T, text?: string, className?: string): HTMLElementTagNameMap[T] => { @@ -20,6 +21,11 @@ const markerChip = (marker: Marker, className: string): HTMLSpanElement => { }; const timestamp = (value: string): string => new Intl.DateTimeFormat(undefined, { timeZone: 'America/Bogota', dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value)); const taskLabel = (status: string) => status === 'completed' ? 'operation completed' : status; +/** Short badge in the flow; the careful wording lives in its tooltip. */ +const chip = (text: string, tone?: string, title?: string): HTMLSpanElement => { const node = element('span', text, tone ? `chip chip-${tone}` : 'chip'); if (title) node.title = title; return node; }; +/** Compliance and provenance prose, folded out of the reading flow. */ +const drawer = (summary: string, lines: string[]): HTMLDetailsElement => { const node = element('details', undefined, 'evidence-drawer'); node.append(element('summary', summary)); for (const line of lines) node.append(element('p', line)); return node; }; +const figure = (value: string, label: string): HTMLSpanElement => { const node = element('span', undefined, 'figure'); node.append(element('b', value), element('i', label)); return node; }; const empty = (): Swarm => ({ schemaVersion: 1, scope: 'operator-source-registry', observedAt: new Date().toISOString(), commands: false, sources: [] }); const query = new URLSearchParams(location.search), embedded = query.get('embed') === '1', channel = query.get('channel') ?? ''; const insights = mountInsights(next => select(next, false)); @@ -99,7 +105,7 @@ function paintNavigation(): void { item.title = `${source.factoryId} · ${markerLegend[marker]}`; sourceList.append(item); } const focus = focusNode(), children = focus ? index.children.get(focus.key) ?? [] : []; - $('branch-heading').textContent = focus ? `DELEGATION · ${focus.label} · ${children.length} children` : 'DELEGATION · select a source'; + $('branch-heading').textContent = focus ? `DELEGATION · ${children.length}` : 'DELEGATION'; $('branch-list').replaceChildren(); $('branch-pages').replaceChildren(); for (const key of children.slice(page * 128, (page + 1) * 128)) { const node = index.nodes.get(key)!; @@ -142,17 +148,14 @@ function paintInspector(): void { const container = $('swarm-inspect'); container.replaceChildren(); container.style.removeProperty('--accent'); if (!selection) { - container.append(element('p', 'THE OBSERVATORY', 'evidence-kicker'), element('h2', 'A constellation of work.'), - element('p', 'Move from registered authorities to their coordinators and delegated cells. Search reaches every recorded cell, task and external effect.'), - element('p', 'This view follows observations. It cannot start, stop or resume workers.'), - element('h3', 'Reading the markers')); + container.append(element('p', 'MARKERS', 'evidence-kicker'), element('h2', 'Select an authority.')); const guide = element('ul', undefined, 'marker-guide'); for (const marker of ['plate', 'idle', 'recent', 'uncertain', 'quiet'] as Marker[]) { const row = element('li'); row.append(markerChip(marker, 'guide-dot'), element('strong', markerTitle[marker]), element('small', markerLegend[marker])); guide.append(row); } - container.append(guide, element('p', 'Markers describe recorded observations only. They never establish host health, physical worker activity or quiescence, and shape rather than animation carries each state.', 'evidence-note')); + container.append(guide, drawer('Evidence rules', ['Markers describe recorded observations only. They never establish host health, physical worker activity or quiescence.', 'Shape rather than animation carries each state, so every marker stays readable with reduced motion.', 'This view follows observations. It cannot start, stop or resume workers.'])); return; } const source = index.sources.get(selection.sourceId)!; @@ -161,15 +164,15 @@ function paintInspector(): void { element('div', `${sourceStatus(source)} · ${source.label}`, 'source-state'), element('h2', selection.kind === 'source' ? source.label : selection.id)); evidenceList(container, [['Factory identity', source.factoryId], ['Registry source', source.id]]); const envelope = source.snapshot; - if (!envelope) { container.append(element('p', 'This registered source has no available observation. No cells or execution state are inferred.', 'evidence-note')); return; } + if (!envelope) { container.append(chip('no observation available', 'warn', 'No cells or execution state are inferred for this registered source.')); return; } const state = envelope.snapshot; - if (sourceStatus(source) !== 'observed') container.append(element('p', 'Retained observation. Activity is unconfirmed until this authority is read successfully again.', 'evidence-note')); + if (sourceStatus(source) !== 'observed') container.append(chip('retained observation', 'warn', 'Activity is unconfirmed until this authority is read successfully again.')); if (selection.kind === 'source') { container.append(element('p', state.control.mission)); evidenceList(container, [['Scope', envelope.scope], ['Observed build', envelope.buildRevision], ['Controller revision', String(envelope.revision)], ['Snapshot read', timestamp(envelope.observedAt)], ['Controller admission', `${state.control.status} · epoch ${state.control.epoch}`], ['Recorded work', `${state.tasks.length} tasks · ${state.effects.length} effects`], ['Worker quiescence', 'unverified']]); container.append(element('h3', 'Recorded tasks')); appendTasks(container, source, state.tasks); - container.append(element('p', 'Provider host identity, worker health and final paid billing are not established by this contract.', 'evidence-note')); + container.append(drawer('Evidence', ['Provider host identity, worker health and final paid billing are not established by this contract.', 'Worker quiescence is unverified: a recorded observation never proves a stopped worker.'])); } else if (selection.kind === 'cell') { const cell = state.cells.find(cell => cell.id === selection!.id)!; container.append(element('p', cell.purpose)); @@ -177,11 +180,11 @@ function paintInspector(): void { evidenceList(container, [['Role / lifecycle', `${cell.role} / ${cell.status}`], ['Recorded parent', cell.parentId ?? 'Local root'], ['Heartbeat', timestamp(cell.heartbeat)], ['Activity evidence', activityEvidence({ ...node, activity: activities(source, Date.now(), swarm.sample).get(cell.id) ?? 'idle' })], ['Logical allocation', `$${(cell.allocationCents / 100).toFixed(2)} · logical-allocation`], ['Worker quiescence', 'unverified']]); container.append(element('h3', 'Owned tasks')); appendTasks(container, source, state.tasks.filter(task => task.owner === cell.id)); container.append(element('h3', 'Owned external effects')); appendEffects(container, source, state.effects.filter(effect => effect.owner === cell.id)); - if (cell.parentId && !state.cells.some(parent => parent.id === cell.parentId)) container.append(element('p', 'The parent is absent from this observation. Its position does not prove a delegation link.', 'evidence-note')); + if (cell.parentId && !state.cells.some(parent => parent.id === cell.parentId)) container.append(chip('parent absent from this observation', 'warn', 'Its position does not prove a delegation link.')); } else if (selection.kind === 'task') { const task = state.tasks.find(task => task.id === selection!.id)!; - if (task.status === 'completed') container.append(element('p', 'Operation completed. Software review and delivery are not established by this status.', 'evidence-note')); - if (task.status === 'cancelled') container.append(element('p', 'Cancelled: abandoned work or unmet acceptance. Candidate and review hashes remain historical evidence.', 'evidence-note')); + if (task.status === 'completed') container.append(chip('operation completed', 'quiet', 'Software review and delivery are not established by this status.')); + if (task.status === 'cancelled') container.append(chip('cancelled', 'warn', 'Abandoned work or unmet acceptance. Candidate and review hashes remain historical evidence.')); evidenceList(container, [['Task status', taskLabel(task.status)], ['Project / branch', `${task.projectId} / ${task.branch}`], ['Attempt', String(task.attempt)], ['Lease expiry', task.leaseExpiry ? timestamp(task.leaseExpiry) : 'None recorded'], ['Specification SHA256', task.specDigest], ['Candidate SHA256', task.candidate?.sha256 ?? 'None recorded'], ['Review verdict', task.review ? task.review.accepted ? 'Recorded accepted review' : 'Recorded rejected review' : 'None recorded'], ['Review evidence SHA256', task.review?.evidenceDigest ?? 'None recorded'], ['Reviewed candidate SHA256', task.review?.candidateDigest ?? 'None recorded'], ['Reviewed spec SHA256', task.review?.specDigest ?? 'None recorded'], ['Review attempt', task.review ? String(task.review.attempt) : 'None recorded']]); if (task.owner) container.append(button(`Inspect owner · ${task.owner}`, () => select({ sourceId: source.id, kind: 'cell', id: task.owner! }))); appendEffects(container, source, state.effects.filter(effect => effect.taskId === task.id)); @@ -190,24 +193,24 @@ function paintInspector(): void { evidenceList(container, [['Kind / outcome', `${effect.kind} / ${effect.state}`], ['Scope', `${effect.scope} / ${effect.scopeId}`], ['Owner epochs', `${effect.ownerEpoch} / controller ${effect.controlEpoch}`], ['Created', timestamp(effect.createdAt)], ['Updated', timestamp(effect.updatedAt)], ['Request SHA256', effect.requestDigest]]); container.append(button(`Inspect owner · ${effect.owner}`, () => select({ sourceId: source.id, kind: 'cell', id: effect.owner }))); if (effect.taskId) container.append(button(`Inspect task · ${effect.taskId}`, () => select({ sourceId: source.id, kind: 'task', id: effect.taskId! }))); - if (effect.state === 'unknown') container.append(element('p', 'The external outcome is unknown. Do not infer success, retry eligibility or stopped workers.', 'evidence-note')); + if (effect.state === 'unknown') container.append(chip('outcome unknown', 'warn', 'Do not infer success, retry eligibility or stopped workers.')); } - if (index.issues.length) container.append(element('p', index.issues.slice(0, 3).join('. '), 'evidence-note')); + if (index.issues.length) container.append(drawer('Observation issues', index.issues.slice(0, 3))); } function appendTasks(container: HTMLElement, source: Source, tasks: NonNullable['snapshot']['tasks']): void { - if (!tasks.length) container.append(element('p', 'No tasks recorded.')); + if (!tasks.length) container.append(element('p', 'No task recorded.', 'evidence-empty')); for (const task of tasks.slice(0, 50)) container.append(button(`${task.id} · ${taskLabel(task.status)}`, () => select({ sourceId: source.id, kind: 'task', id: task.id }))); - if (tasks.length > 50) container.append(element('p', `${tasks.length} tasks indexed. Search reaches each full identity.`)); + if (tasks.length > 50) container.append(element('p', `${tasks.length} tasks indexed · search reaches each identity`, 'evidence-empty')); } function appendEffects(container: HTMLElement, source: Source, effects: NonNullable['snapshot']['effects']): void { for (const effect of effects.slice(0, 50)) container.append(button(`${effect.key} · ${effect.state}`, () => select({ sourceId: source.id, kind: 'effect', id: effect.key }))); - if (effects.length > 50) container.append(element('p', `${effects.length} effects indexed. Search reaches each full identity.`)); + if (effects.length > 50) container.append(element('p', `${effects.length} effects indexed · search reaches each identity`, 'evidence-empty')); } function paintTracking(): void { const source = selection ? index.sources.get(selection.sourceId) : null; $('tracking-scope').textContent = source ? `${source.label} · recorded controller events` : 'Choose a source'; const container = $('tracking-list'); container.replaceChildren(); - if (!source?.events.length) { container.append(element('p', 'No event metadata has been collected in this view. Current topology comes from snapshots; events do not reconstruct missing history.')); return; } + if (!source?.events.length) { container.append(element('p', 'No controller event collected in this view.', 'evidence-empty')); return; } for (const event of source.events.slice(-30).reverse()) { const row = element('div', undefined, 'tracking-row'); const time = element('time', timestamp(event.observedAt)); time.dateTime = event.observedAt; row.append(time, element('span', `${event.seq} · ${event.type}`), element('span', event.subject)); container.append(row); @@ -222,9 +225,17 @@ function redraw(): void { const accented = selection ? index.sources.get(selection.sourceId) : null; document.body.style.setProperty('--accent', accented ? sourceColor(accented.id) : '#7fd1de'); const cells = swarm.sources.reduce((sum, source) => sum + (source.snapshot?.snapshot.cells.length ?? 0), 0), tasks = swarm.sources.reduce((sum, source) => sum + (source.snapshot?.snapshot.tasks.length ?? 0), 0); - $('swarm-counts').textContent = `${swarm.sources.length} registered sources · ${cells.toLocaleString()} recorded cells · ${tasks.toLocaleString()} tasks`; + const leased = [...index.nodes.values()].filter(node => node.cell && markerOf(node) === 'recent').length; + $('swarm-counts').replaceChildren(figure(String(swarm.sources.length), 'sources'), figure(cells.toLocaleString(), 'cells'), figure(tasks.toLocaleString(), 'tasks'), figure(String(leased), 'leased')); const stale = swarm.sources.filter(source => sourceStatus(source) !== 'observed').length; - $('swarm-status').textContent = swarm.sample ? `Design preview · sample data · ${cells.toLocaleString()} cells` : swarm.sources.length ? `Live tracking · five-second observations · ${stale} stale/unavailable sources` : 'Observation unavailable · no registered facts'; + // The masthead O carries the registry: one arc per authority, filled mouth + // only while a running lease is recorded somewhere in the swarm. + const mark = oMark(swarm.sources.map(source => ({ color: sourceColor(source.id), broken: sourceStatus(source) !== 'observed' })), leased > 0, 30); + mark.setAttribute('role', 'img'); + mark.removeAttribute('aria-hidden'); + mark.setAttribute('aria-label', `${swarm.sources.length} registered authorities · ${leased} cells with a running lease`); + $('brand-mark').replaceChildren(mark); + $('swarm-status').textContent = swarm.sample ? `Sample data · design preview · ${cells.toLocaleString()} cells` : swarm.sources.length ? `Live · 5s observations · ${stale} stale` : 'Observation unavailable · no registered facts'; $('swarm-status').classList.toggle('sample-notice', !!swarm.sample); } function observe(value: unknown): void { diff --git a/src/swarm/marks.ts b/src/swarm/marks.ts new file mode 100644 index 0000000..7526ef1 --- /dev/null +++ b/src/swarm/marks.ts @@ -0,0 +1,179 @@ +/** Local presentation helpers for the Observatory. + * + * Every mark here is drawn from data that was already observed and parsed: + * nothing invents a measurement, and nothing animates on its own. The one + * recurring form is the O — a ring that opens into a mouth. The Observatory + * only ever speaks about records, so the mouth is the brand. */ + +const NS = 'http://www.w3.org/2000/svg'; + +export const svgNode = (tag: K, attributes: Record = {}): SVGElementTagNameMap[K] => { + const node = document.createElementNS(NS, tag); + for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value)); + return node; +}; +const frame = (viewBox: string, className: string): SVGSVGElement => + svgNode('svg', { viewBox, class: className, focusable: 'false', 'aria-hidden': 'true', preserveAspectRatio: 'xMidYMid meet' }); +const polar = (cx: number, cy: number, r: number, turn: number) => + [cx + r * Math.cos(turn * Math.PI * 2 - Math.PI / 2), cy + r * Math.sin(turn * Math.PI * 2 - Math.PI / 2)] as const; +const arcPath = (cx: number, cy: number, r: number, from: number, to: number): string => { + const span = Math.max(0, Math.min(0.9999, to - from)); + const [x1, y1] = polar(cx, cy, r, from), [x2, y2] = polar(cx, cy, r, from + span); + return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${span > 0.5 ? 1 : 0} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`; +}; + +export interface Segment { color: string; broken?: boolean } + +/** The O: a registry ring that opens into a mouth. + * Each ring segment is one registered authority in its deterministic hue; a + * broken segment is a retained or unavailable record. The mouth core is filled + * only when the caller counted at least one running lease — it is a count, + * not a heartbeat, and it never animates by itself. */ +export function oMark(segments: Segment[], speaking: boolean, size = 44): SVGSVGElement { + const root = frame('0 0 64 64', 'o-mark'); + root.setAttribute('width', String(size)); + root.setAttribute('height', String(size)); + root.dataset.speaking = String(speaking); + const count = Math.max(1, segments.length), gap = segments.length > 1 ? Math.min(0.02, 0.35 / count) : 0; + for (let i = 0; i < count && segments.length; i++) { + const seg = segments[i]!; + const arc = svgNode('path', { d: arcPath(32, 32, 27, i / count + gap / 2, (i + 1) / count - gap / 2), stroke: seg.color, 'stroke-width': 2, fill: 'none', 'stroke-linecap': 'round' }); + if (seg.broken) { arc.setAttribute('stroke-dasharray', '2.4 3.4'); arc.setAttribute('opacity', '.6'); } + root.append(arc); + } + if (!segments.length) root.append(svgNode('circle', { cx: 32, cy: 32, r: 27, stroke: 'var(--rule-strong)', 'stroke-width': 1.5, fill: 'none', 'stroke-dasharray': '2 4' })); + root.append(svgNode('circle', { class: 'o-bezel', cx: 32, cy: 32, r: 21.5, stroke: 'var(--rule)', 'stroke-width': 1, fill: 'none' })); + // An open mouth, not an eye: a shallow upper lip over a deep lower one. + root.append(svgNode('path', { class: 'o-lip o-lip-upper', d: 'M 18.5 31 Q 32 26.4 45.5 31', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.7, 'stroke-linecap': 'round' })); + root.append(svgNode('path', { class: 'o-lip o-lip-lower', d: 'M 18.5 31 Q 32 44 45.5 31', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.7, 'stroke-linecap': 'round' })); + const core = svgNode('path', { class: 'o-core', d: 'M 25.6 36.6 Q 32 42.4 38.4 36.6', fill: speaking ? 'currentColor' : 'none', stroke: 'currentColor', 'stroke-width': 1.2, 'stroke-linecap': 'round', opacity: speaking ? '.9' : '.34' }); + root.append(core); + for (const [i, turn] of [-0.055, 0, 0.055].entries()) { + const [x1, y1] = polar(32, 32, 31, 0.25 + turn), [x2, y2] = polar(32, 32, 31 + (i === 1 ? 4.5 : 3), 0.25 + turn); + root.append(svgNode('line', { class: 'o-say', x1: x1.toFixed(2), y1: y1.toFixed(2), x2: x2.toFixed(2), y2: y2.toFixed(2), stroke: 'currentColor', 'stroke-width': 1, 'stroke-linecap': 'round', opacity: i === 1 ? '.5' : '.26' })); + } + return root; +} + +/** One reading drawn as an arc: a part of a counted whole, never health. */ +export function arcDial(value: number, total: number, tone = 'var(--accent)'): SVGSVGElement { + const root = frame('0 0 120 120', 'dial-art'); + const share = total > 0 ? Math.max(0, Math.min(1, value / total)) : 0; + root.append(svgNode('path', { d: arcPath(60, 60, 49, 0, 0.9999), fill: 'none', stroke: 'var(--rule-strong)', 'stroke-width': 5, opacity: '.3', 'stroke-linecap': 'round' })); + for (let i = 0; i < 48; i++) { + const [x1, y1] = polar(60, 60, 41, i / 48), [x2, y2] = polar(60, 60, i % 4 === 0 ? 35 : 38, i / 48); + root.append(svgNode('line', { x1: x1.toFixed(2), y1: y1.toFixed(2), x2: x2.toFixed(2), y2: y2.toFixed(2), stroke: i / 48 < share ? tone : 'var(--rule-strong)', 'stroke-width': i % 4 === 0 ? 1 : 0.6, opacity: i / 48 < share ? '.8' : '.34' })); + } + if (share > 0) root.append(svgNode('path', { d: arcPath(60, 60, 49, 0, Math.max(share, 0.005)), fill: 'none', stroke: tone, 'stroke-width': 5, 'stroke-linecap': 'round' })); + return root; +} + +/** Observed record density across the window that was actually observed. */ +export interface RibbonPoint { at: number; lane: 0 | 1 | 2 } +export function ribbon(points: RibbonPoint[], lanes: string[]): SVGSVGElement { + const width = 960, height = 80, columns = 72; + const root = frame(`0 0 ${width} ${height}`, 'ribbon-art'); + root.setAttribute('preserveAspectRatio', 'none'); + const times = points.map(point => point.at); + const min = Math.min(...times), max = Math.max(...times), span = Math.max(1, max - min); + const buckets = Array.from({ length: columns }, () => [0, 0, 0]); + for (const point of points) buckets[Math.min(columns - 1, Math.max(0, Math.round(((point.at - min) / span) * (columns - 1))))]![point.lane]++; + const peak = Math.max(1, ...buckets.map(bucket => bucket[0]! + bucket[1]! + bucket[2]!)), step = width / columns; + for (let i = 0; i <= 8; i++) root.append(svgNode('line', { x1: ((width / 8) * i).toFixed(2), y1: 4, x2: ((width / 8) * i).toFixed(2), y2: height - 8, stroke: 'var(--rule)', 'stroke-width': 1, opacity: i % 8 === 0 ? '.9' : '.55' })); + for (let i = 0; i < columns; i += 2) root.append(svgNode('line', { x1: (i * step + step / 2).toFixed(2), y1: height - 12, x2: (i * step + step / 2).toFixed(2), y2: height - 8, stroke: 'var(--rule)', 'stroke-width': 1, opacity: '.5' })); + for (let i = 0; i < columns; i++) { + let base = height - 9; + for (let lane = 0; lane < 3; lane++) { + const value = buckets[i]![lane]!; + if (!value) continue; + const bar = Math.max(3, (value / peak) * (height - 20)); + root.append(svgNode('rect', { x: (i * step + 1.2).toFixed(2), y: (base - bar).toFixed(2), width: Math.max(1.6, step - 2.4).toFixed(2), height: bar.toFixed(2), fill: lanes[lane]!, opacity: '.8' })); + base -= bar + 1.5; + } + } + root.append(svgNode('line', { x1: 0, y1: height - 8, x2: width, y2: height - 8, stroke: 'var(--rule-strong)', 'stroke-width': 1 })); + return root; +} + +/** The paid ledger drawn as one measured rule, not a progress bar. */ +export function budgetRule(heldCents: number, limitCents: number, meteredCents: number | null): SVGSVGElement { + const width = 600, height = 32, root = frame(`0 0 ${width} ${height}`, 'budget-art'); + root.setAttribute('preserveAspectRatio', 'none'); + const scale = Math.max(1, limitCents, heldCents), at = (cents: number) => Math.max(0, Math.min(width, (cents / scale) * width)); + root.append(svgNode('rect', { x: 0, y: 12, width, height: 10, fill: 'var(--plate-solid)', stroke: 'var(--rule)', 'stroke-width': 1 })); + root.append(svgNode('rect', { x: 0, y: 12, width: at(Math.min(heldCents, limitCents)).toFixed(2), height: 10, fill: 'var(--accent)', opacity: '.8' })); + if (heldCents > limitCents) root.append(svgNode('rect', { x: at(limitCents).toFixed(2), y: 12, width: Math.max(1, at(heldCents) - at(limitCents)).toFixed(2), height: 10, fill: 'var(--warn)', opacity: '.85' })); + for (let i = 0; i <= 20; i++) root.append(svgNode('line', { x1: ((width / 20) * i).toFixed(2), y1: 5, x2: ((width / 20) * i).toFixed(2), y2: i % 5 === 0 ? 10 : 8.5, stroke: 'var(--rule-strong)', 'stroke-width': 1 })); + root.append(svgNode('line', { x1: at(limitCents).toFixed(2), y1: 3, x2: at(limitCents).toFixed(2), y2: 29, stroke: 'var(--text)', 'stroke-width': 1, opacity: '.7' })); + if (meteredCents !== null) root.append(svgNode('line', { x1: at(meteredCents).toFixed(2), y1: 11, x2: at(meteredCents).toFixed(2), y2: 27, stroke: 'var(--signal)', 'stroke-width': 1.5, 'stroke-dasharray': '2 3' })); + return root; +} + +/** Recorded flow structure drawn as a real graph with its recorded edges. + * Only the node the conversation reports is marked; the rest are definitions. */ +export interface DiagramNode { id: string; label: string; type: string } +export interface DiagramFlow { nodes: DiagramNode[]; edges: Array<{ source: string; target: string }> } +export function flowDiagram(flow: DiagramFlow, currentId: string | null, currentBadge: string | null): SVGSVGElement { + const W = 168, H = 56, GX = 58, GY = 22, PAD = 16, TOP = 26; + const depth = new Map(flow.nodes.map(node => [node.id, 0])); + for (let pass = 0; pass < Math.min(flow.nodes.length, 48); pass++) { + let changed = false; + for (const edge of flow.edges) { + const next = (depth.get(edge.source) ?? 0) + 1; + if (depth.has(edge.target) && next > depth.get(edge.target)! && next < flow.nodes.length) { depth.set(edge.target, next); changed = true; } + } + if (!changed) break; + } + const columns = new Map(); + for (const node of flow.nodes) { const at = depth.get(node.id) ?? 0; columns.set(at, [...(columns.get(at) ?? []), node]); } + // Cycles and disconnected components can leave gaps in the bounded ranks. + const ranks = [...columns.keys()].sort((a, b) => a - b); + const rows = Math.max(1, ...[...columns.values()].map(column => column.length)); + const place = new Map(); + for (const [column, rank] of ranks.entries()) for (const [row, node] of columns.get(rank)!.entries()) { + const nodes = columns.get(rank)!; + place.set(node.id, { x: PAD + column * (W + GX), y: TOP + row * (H + GY) + ((rows - nodes.length) * (H + GY)) / 2 }); + } + const width = PAD * 2 + Math.max(0, columns.size - 1) * (W + GX) + W, height = TOP + rows * (H + GY) + PAD; + const root = frame(`0 0 ${width} ${height}`, 'flow-art'); + root.setAttribute('width', String(width)); + root.setAttribute('height', String(height)); + const id = `flow-arrow-${Math.random().toString(36).slice(2, 9)}`; + const marker = svgNode('marker', { id, viewBox: '0 0 8 8', refX: 7.5, refY: 4, markerWidth: 5, markerHeight: 5, orient: 'auto-start-reverse' }); + marker.append(svgNode('path', { d: 'M 0.5 1 L 7.5 4 L 0.5 7 z', fill: 'var(--rule-strong)' })); + const defs = svgNode('defs'); defs.append(marker); root.append(defs); + for (const edge of flow.edges) { + const a = place.get(edge.source), b = place.get(edge.target); + if (!a || !b) continue; + const x1 = a.x + W, y1 = a.y + H / 2, x2 = b.x, y2 = b.y + H / 2; + const d = x2 >= x1 + ? `M ${x1} ${y1} C ${x1 + GX * 0.62} ${y1}, ${x2 - GX * 0.62} ${y2}, ${x2} ${y2}` + : `M ${a.x + W / 2} ${a.y + H} C ${a.x + W / 2} ${a.y + H + 30}, ${b.x + W / 2} ${b.y + H + 30}, ${b.x + W / 2} ${b.y + H}`; + root.append(svgNode('path', { class: 'flow-art-edge', d, fill: 'none', stroke: 'var(--rule-strong)', 'stroke-width': 1, 'marker-end': `url(#${id})`, opacity: '.85' })); + } + for (const node of flow.nodes) { + const at = place.get(node.id)!, current = node.id === currentId; + const group = svgNode('g', { class: `flow-art-node${current ? ' is-current' : ''}`, transform: `translate(${at.x} ${at.y})` }); + const title = svgNode('title'); title.textContent = `${node.label} · ${node.type} · ${node.id}${current && currentBadge ? ` · ${currentBadge}` : ''}`; group.append(title); + group.append(svgNode('rect', { x: 0.5, y: 0.5, width: W - 1, height: H - 1, rx: 3, fill: current ? 'var(--plate-lit)' : 'var(--plate-solid)', stroke: current ? 'var(--accent)' : 'var(--rule)', 'stroke-width': 1 })); + group.append(svgNode('rect', { x: 0.5, y: 0.5, width: 2, height: H - 1, fill: current ? 'var(--accent)' : 'var(--rule-strong)' })); + const label = svgNode('text', { x: 15, y: 25, class: 'flow-art-label' }); + label.textContent = node.label.length > 21 ? `${node.label.slice(0, 20)}…` : node.label; + const type = svgNode('text', { x: 15, y: 41, class: 'flow-art-type' }); + type.textContent = node.type.toUpperCase(); + group.append(label, type); + if (current && currentBadge) { + const badge = svgNode('text', { x: 1, y: -10, class: 'flow-art-badge' }); + badge.textContent = currentBadge; + group.append(badge); + } + root.append(group); + } + return root; +} + +/** The deterministic authority palette, mirrored verbatim from renderer.ts so + * the field, the atlas rail and the instrument deck cannot disagree about a + * source's hue. test/swarm.test.mjs asserts the two readers stay identical. */ +const hues = ['#7fd1de', '#9bb8e8', '#c2a7e8', '#e8a7b8', '#e8c98a', '#8fd9b6']; +export const sourceHue = (id: string): string => hues[[...id].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % hues.length]!; diff --git a/src/swarm/observatory.css b/src/swarm/observatory.css index 7faaa18..175f881 100644 --- a/src/swarm/observatory.css +++ b/src/swarm/observatory.css @@ -1,43 +1,23 @@ -/* THE OBSERVATORY — art direction for the FACTORY swarm viewer. - * A single deep field holds every authority; instruments float over it on - * hairline glass plates. Motion, colour and emphasis still follow evidence: - * source hues come from the renderer's deterministic palette, amber means - * uncertain, grey means retired/stale, and nothing animates without a lease. */ -:root { - --void:#04060b; --ink:#070a11; --deep:#0a0f18; - --plate:rgba(11,16,25,.82); --plate-solid:#0b1019; - --rule:rgba(150,180,210,.14); --rule-strong:rgba(160,196,226,.28); - --text:#e9eef7; --muted:#a6b3c7; --quiet:#73829a; --faint:#5a677b; - --signal:#7fd1de; --accent:#7fd1de; --warn:#e0a85c; - --serif:ui-serif,'Iowan Old Style','Palatino Linotype',Palatino,Georgia,serif; - --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - --kicker:9px/1.1 var(--mono); - --gap:22px; - background:var(--void); color:var(--text); -} -body{margin:0;background:var(--void);color:var(--text);overflow-x:hidden} -#swarm-app{position:relative;isolation:isolate} -button,input,select{border-radius:3px;font-family:inherit} -button:focus-visible,input:focus-visible,select:focus-visible,canvas:focus-visible,summary:focus-visible{outline:1px solid var(--accent);outline-offset:3px} -button{background:transparent;border:1px solid var(--rule);color:var(--muted);transition:color .14s,border-color .14s,background .14s} -button:hover{background:rgba(127,209,222,.07);border-color:var(--rule-strong);color:var(--text)} -button[aria-pressed=true]{color:var(--text);border-color:var(--accent);background:rgba(127,209,222,.09)} -kbd{font:9px var(--mono);border:1px solid var(--rule);border-radius:2px;padding:1px 3px;color:var(--faint);margin-left:5px} +/* THE OBSERVATORY — art direction for the recorded FACTORY field. + * One deep field holds every authority. Instruments float above it on hairline + * glass plates with engraved corners, so the spatial record stays the subject + * and the controls read as optics laid on top of it. Emphasis follows evidence: + * source hues are deterministic, amber is uncertainty, grey is a retained + * record, and nothing moves unless a person moves it. */ /* ── Masthead ───────────────────────────────────────────────────────────── */ -.swarm-top{position:relative;z-index:20;height:66px;padding:0 26px;gap:18px;align-items:center; +.swarm-top{position:relative;z-index:20;height:64px;padding:0 24px;display:flex;gap:16px;align-items:center; border-bottom:1px solid var(--rule);background:linear-gradient(#0a0e16,#070a11)} -.swarm-brand{display:flex;align-items:center;gap:11px;font:400 21px/1 var(--serif);letter-spacing:-.035em;color:#e4ecf6;white-space:nowrap} +.swarm-top::after{content:'';position:absolute;left:0;right:0;bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,rgba(127,209,222,.3),transparent);opacity:.5} +.swarm-brand{display:flex;align-items:center;gap:12px;font:400 20px/1 var(--serif);letter-spacing:-.035em;color:#e4ecf6;white-space:nowrap} .swarm-brand b{color:#b7d9e8;font-weight:400} -.brand-mark{width:13px;height:13px;border-radius:50%;flex:0 0 auto; - background:radial-gradient(circle at 34% 32%,#eafaff,#7fd1de 46%,#2a5c6a 78%,#0a1016); - box-shadow:0 0 0 1px rgba(127,209,222,.4),0 0 16px rgba(127,209,222,.38)} +.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 auto} .swarm-top button{font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:7px 11px} #swarm-status{flex:1;font:10px/1.5 var(--mono);color:var(--muted);text-align:right;letter-spacing:.02em} -.read-only{font:9px var(--mono);letter-spacing:.14em;color:var(--quiet);border:1px solid var(--rule);border-radius:2px;padding:4px 7px;white-space:nowrap} +.read-only{font:9px var(--mono);letter-spacing:.14em;color:var(--quiet);border:1px solid var(--rule);border-radius:2px;padding:4px 7px;white-space:nowrap;cursor:help} /* ── The field ──────────────────────────────────────────────────────────── */ -.swarm-shell{display:block;position:relative;height:calc(100vh - 112px);min-height:660px} +.swarm-shell{display:block;position:relative;height:calc(100vh - 110px);min-height:640px} .swarm-space{position:absolute;inset:0;overflow:hidden;background: radial-gradient(130% 92% at 50% 128%,#12243390 0%,transparent 62%), radial-gradient(90% 70% at 74% 18%,#1b1f3c66 0%,transparent 58%), @@ -45,9 +25,7 @@ kbd{font:9px var(--mono);border:1px solid var(--rule);border-radius:2px;padding: linear-gradient(#060911,#04060b)} .field-backdrop{position:absolute;inset:0;z-index:0;pointer-events:none} .field-backdrop>span{position:absolute;inset:0;display:block} -.field-nebula{background: - conic-gradient(from 210deg at 46% 54%,#0e3a4733,#1d2a5a2e,#3a1f452b,#0e3a4733); - filter:blur(70px);opacity:.55} +.field-nebula{background:conic-gradient(from 210deg at 46% 54%,#0e3a4733,#1d2a5a2e,#3a1f452b,#0e3a4733);filter:blur(72px);opacity:.5} .field-dust{opacity:.5;background: radial-gradient(1px 1px at 12% 18%,#cfe9f2aa,transparent 60%), radial-gradient(1px 1px at 72% 12%,#bcd6ff99,transparent 60%), @@ -55,46 +33,52 @@ kbd{font:9px var(--mono);border:1px solid var(--rule);border-radius:2px;padding: radial-gradient(1px 1px at 86% 62%,#cfe9f299,transparent 60%), repeating-radial-gradient(circle at 20% 30%,#9fc4d41a 0 1px,transparent 1px 90px), repeating-radial-gradient(circle at 78% 68%,#aab8e018 0 1px,transparent 1px 124px)} -.field-horizon{top:auto;bottom:16%;height:1px;background:linear-gradient(90deg,transparent,#5e8fa233 18%,#9fd3e155 50%,#5e8fa233 82%,transparent);opacity:.5} +.field-horizon{top:auto;bottom:16%;height:1px;background:linear-gradient(90deg,transparent,#5e8fa233 18%,#9fd3e155 50%,#5e8fa233 82%,transparent);opacity:.55} +/* A measured floor: hairline rules receding to the horizon. Pure geometry, + * drawn once, never animated — it gives the field scale without decoration. */ +.field-grid{top:auto;bottom:0;height:16%;opacity:.3; + background:repeating-linear-gradient(90deg,transparent 0 46px,rgba(159,211,225,.16) 46px 47px), + repeating-linear-gradient(0deg,transparent 0 22px,rgba(159,211,225,.1) 22px 23px); + -webkit-mask-image:linear-gradient(#0000,#000);mask-image:linear-gradient(#0000,#000)} .field-vignette{background:radial-gradient(120% 100% at 50% 46%,transparent 38%,#04060bcc 100%)} #swarm-stage{position:absolute;inset:0;z-index:1;height:100%;min-height:0;overflow:hidden} #swarm-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;background:transparent} #swarm-labels{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:2} -/* ── Shared glass plate ─────────────────────────────────────────────────── */ -.swarm-navigation,.swarm-inspector,.swarm-timeline,.swarm-diagnostics,.space-toolbar>.space-controls,#swarm-path{ - background:var(--plate);backdrop-filter:blur(14px) saturate(1.1);-webkit-backdrop-filter:blur(14px) saturate(1.1); - border:1px solid var(--rule);box-shadow:0 24px 60px -30px #000c,inset 0 1px 0 rgba(190,220,240,.07)} -.section-title{font:var(--kicker);letter-spacing:.17em;color:var(--quiet);text-transform:uppercase} +/* ── The glass plate: hairline, inner light, engraved corners ───────────── */ +.swarm-navigation,.swarm-inspector,.swarm-timeline,.swarm-diagnostics,.space-controls,#swarm-path{ + background:var(--plate);backdrop-filter:blur(15px) saturate(1.12);-webkit-backdrop-filter:blur(15px) saturate(1.12); + border:1px solid var(--rule);box-shadow:0 26px 60px -32px #000d,inset 0 1px 0 rgba(190,220,240,.07)} +.swarm-navigation,.swarm-inspector{position:absolute;z-index:12} +.swarm-navigation::before,.swarm-inspector::after{content:'';position:absolute;width:9px;height:9px;pointer-events:none;opacity:.55} +.swarm-navigation::before{left:0;top:0;border-left:1px solid var(--accent);border-top:1px solid var(--accent)} +.swarm-inspector::after{right:0;top:0;border-right:1px solid var(--accent);border-top:1px solid var(--accent)} +.section-title{font:var(--kicker);letter-spacing:.18em;color:var(--quiet);text-transform:uppercase} /* ── Atlas rail ─────────────────────────────────────────────────────────── */ -.swarm-navigation{position:absolute;z-index:12;left:22px;top:22px;bottom:86px;width:294px;padding:22px 20px;overflow:auto;scrollbar-width:thin} +.swarm-navigation{left:22px;top:22px;bottom:84px;width:292px;padding:20px 18px;overflow:hidden auto} .navigation-hidden .swarm-navigation{display:none} -.swarm-navigation>.section-title:first-child{display:block;margin-bottom:16px;color:var(--muted)} -.search-label{display:grid;gap:8px;font:10px var(--mono);letter-spacing:.08em;color:var(--quiet);text-transform:uppercase} +.search-label{display:grid;gap:8px;font:9px var(--mono);letter-spacing:.14em;color:var(--quiet);text-transform:uppercase} input[type=search]{width:100%;font:12px var(--mono);background:#060a11;border:1px solid var(--rule);padding:10px 11px;color:var(--text)} input[type=search]::placeholder{color:#4e5b6d} -#search-results{max-height:30vh;overflow:auto;margin-top:12px} -#search-results p{font:10px/1.6 var(--mono);color:var(--quiet);margin:0 0 6px} +#search-results{max-height:28vh;overflow:auto;margin-top:12px} +#search-results p{font:9px/1.6 var(--mono);letter-spacing:.06em;color:var(--quiet);margin:0 0 6px;text-transform:uppercase} #search-results button{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid rgba(150,180,210,.08);border-radius:0;font:11px var(--mono);padding:9px 2px;margin:0;color:var(--muted);overflow-wrap:anywhere} -.rail-heading{display:flex;align-items:center;gap:10px;margin:26px 0 12px} -.rail-heading .section-title{margin:0} +.rail-heading{display:flex;align-items:center;gap:10px;margin:24px 0 10px} .rail-heading::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,var(--rule),transparent)} -#source-list{margin:0 0 6px;display:grid} +#source-list{margin:0;display:grid} #source-list button{display:grid;grid-template-columns:auto minmax(0,1fr);grid-template-rows:auto auto;column-gap:11px;align-items:center; - width:100%;text-align:left;margin:0;padding:13px 10px 12px 12px;border:0;border-bottom:1px solid rgba(150,180,210,.08);border-radius:0;background:transparent;position:relative} -#source-list button::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:.55} + width:100%;text-align:left;margin:0;padding:12px 10px 11px 13px;border:0;border-bottom:1px solid rgba(150,180,210,.08);border-radius:0;background:transparent;position:relative} +#source-list button::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:.5;transition:opacity .16s var(--ease),box-shadow .16s var(--ease)} #source-list button:hover::before,#source-list button[aria-pressed=true]::before{opacity:1;box-shadow:0 0 12px var(--accent)} -#source-list button[aria-pressed=true]{background:linear-gradient(90deg,rgba(127,209,222,.1),transparent 72%)} +#source-list button[aria-pressed=true]{background:linear-gradient(90deg,rgba(127,209,222,.1),transparent 74%)} .source-dot{grid-row:1/3} .source-name{font:400 16px/1.25 var(--serif);letter-spacing:-.015em;color:#e6eef7;overflow-wrap:anywhere} -#source-list button small{grid-column:2;font:9px var(--mono);letter-spacing:.08em;color:var(--quiet);margin-top:4px;text-transform:uppercase} -#source-list button.source-stale,#source-list button.source-unavailable{--accent:#7d8798} -#source-list button.source-stale,#source-list button.source-unavailable{color:var(--muted)} -#source-list button.source-stale .source-name,#source-list button.source-unavailable .source-name{color:#b0bccb} -#source-list button.source-unavailable::before{background:repeating-linear-gradient(180deg,#6b7789 0 3px,transparent 3px 7px);opacity:.8} -#branch-heading{margin:22px 0 10px;color:var(--muted)} -#branch-list{max-height:30vh;overflow:auto;display:grid} +#source-list button small{grid-column:2;font:9px var(--mono);letter-spacing:.07em;color:var(--quiet);margin-top:4px;text-transform:uppercase} +#source-list button.source-stale,#source-list button.source-unavailable{--accent:#7d8798;color:var(--muted)} +#source-list button.source-stale .source-name,#source-list button.source-unavailable .source-name{color:#aab6c6} +#source-list button.source-unavailable::before{background:repeating-linear-gradient(180deg,#6b7789 0 3px,transparent 3px 7px);opacity:.85} +#branch-list{max-height:28vh;overflow:auto;display:grid} #branch-list button{display:grid;grid-template-columns:auto minmax(0,1fr);column-gap:9px;align-items:baseline;width:100%;text-align:left; border:0;border-bottom:1px solid rgba(150,180,210,.07);border-radius:0;background:transparent;padding:9px 4px;margin:0} .branch-dot{width:9px;height:9px;transform:translateY(-1px)} @@ -104,16 +88,16 @@ input[type=search]::placeholder{color:#4e5b6d} #branch-pages{font:9px/1.7 var(--mono);color:var(--quiet);margin-top:10px} #branch-pages button{font:9px var(--mono);letter-spacing:.08em;padding:6px 8px;margin:6px 6px 0 0;text-transform:uppercase} #branch-pages p{margin:8px 0 0} -.scope-note{font:10px/1.75 var(--mono);color:var(--faint);border-top:1px solid var(--rule);padding-top:15px;margin-top:20px} +.scope-note{font:9px/1.6 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);border-top:1px solid var(--rule);padding-top:13px;margin:18px 0 0;cursor:help} -/* ── Sextant bar: path + view controls ──────────────────────────────────── */ -.space-toolbar{position:absolute;z-index:14;top:22px;left:338px;right:26px;border:0;padding:0;display:flex;gap:14px;align-items:flex-start;justify-content:space-between;pointer-events:none} +/* ── Sextant bar ────────────────────────────────────────────────────────── */ +.space-toolbar{position:absolute;z-index:14;top:22px;left:336px;right:26px;display:flex;gap:14px;align-items:flex-start;justify-content:space-between;pointer-events:none} .has-selection .space-toolbar{right:422px} .space-toolbar>*{pointer-events:auto} #swarm-path{display:flex;flex-wrap:wrap;align-items:center;gap:3px;font:10px var(--mono);padding:5px 9px;border-radius:999px;max-width:min(560px,56%)} #swarm-path button{font:10px var(--mono);padding:4px 8px;border:0;border-radius:999px;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--quiet)} #swarm-path button:hover{color:var(--text);background:rgba(127,209,222,.08)} -#swarm-path button[aria-pressed=true]{color:#eafcff;background:rgba(127,209,222,.13);border:0} +#swarm-path button[aria-pressed=true]{color:#eafcff;background:rgba(127,209,222,.13)} #swarm-path span{color:#45505f;font-size:9px;padding:0 1px} .space-controls{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:999px;font:10px var(--mono);color:var(--muted)} .space-controls button{font:9px var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:6px 9px;border-radius:999px} @@ -121,40 +105,40 @@ input[type=search]::placeholder{color:#4e5b6d} .space-controls select{font:10px var(--mono);padding:5px 6px;background:#070b12;border:1px solid var(--rule);color:var(--muted)} .follow-control input{accent-color:var(--accent);width:12px;height:12px} -/* ── Caption: the display voice of the field ────────────────────────────── */ -.space-caption{position:absolute;z-index:6;left:340px;bottom:96px;max-width:min(430px,38vw);pointer-events:none;color:var(--muted)} -.navigation-hidden .space-caption{left:34px} +/* ── Caption: the field speaks in figures, not sentences ────────────────── */ +.space-caption{position:absolute;z-index:6;left:338px;bottom:92px;max-width:min(560px,46vw);pointer-events:none;color:var(--muted)} +/* A reading scrim: field labels may drift behind the caption, so the figures + * keep a soft floor to sit on instead of colliding with the record. */ +.space-caption::before{content:'';position:absolute;z-index:-1;inset:-22px -70px -26px -26px; + background:radial-gradient(118% 128% at 2% 100%,rgba(4,6,11,.92),rgba(4,6,11,.62) 48%,transparent 78%)} +.navigation-hidden .space-caption{left:32px} .navigation-hidden .space-toolbar{left:26px} -.caption-kicker{font:var(--kicker);letter-spacing:.24em;color:var(--faint);margin:0 0 12px;text-transform:uppercase} -.observatory-title{font:400 clamp(30px,3.6vw,50px)/1.02 var(--serif);letter-spacing:-.035em;color:#eaf2fa;margin:0 0 12px; - text-shadow:0 2px 34px #04060b,0 0 70px rgba(127,209,222,.12)} -.has-selection .observatory-title{font-size:clamp(24px,2.3vw,32px)} -#swarm-counts{display:block;font:10px/1.7 var(--mono);letter-spacing:.06em;color:#9fb0c5} -.space-caption p{font:10px/1.75 var(--mono);color:var(--faint);margin:8px 0 0;max-width:36ch} -.field-legend{display:flex;flex-wrap:wrap;gap:6px 16px;list-style:none;margin:16px 0 0;padding:14px 0 0;border-top:1px solid var(--rule);font:9px var(--mono);letter-spacing:.07em;color:var(--quiet);text-transform:uppercase} -.field-legend li{display:flex;align-items:center;gap:7px} -.legend-note{font:9px/1.6 var(--mono)!important;letter-spacing:.07em;color:var(--faint);margin:9px 0 0!important;text-transform:uppercase} +.caption-kicker{font:var(--kicker);letter-spacing:.26em;color:var(--faint);margin:0 0 14px;text-transform:uppercase} +.field-counts{display:flex;flex-wrap:wrap;align-items:flex-end;gap:0;border-top:1px solid var(--rule);padding-top:14px} +.figure{display:grid;gap:3px;padding:0 20px 0 0} +.figure+.figure{border-left:1px solid var(--rule);padding-left:20px} +.figure b{font:400 clamp(26px,3.1vw,42px)/.95 var(--serif);letter-spacing:-.035em;color:#eaf2fa;font-weight:400;text-shadow:0 2px 30px #04060b} +.figure i{font:9px var(--mono);font-style:normal;letter-spacing:.2em;text-transform:uppercase;color:var(--faint)} +.has-selection .field-counts .figure b{font-size:clamp(20px,2.1vw,28px)} +.field-legend{display:flex;flex-wrap:wrap;gap:6px 16px;list-style:none;margin:16px 0 0;padding:14px 0 0;border-top:1px solid var(--rule); + font:9px var(--mono);letter-spacing:.1em;color:var(--quiet);text-transform:uppercase;pointer-events:auto} +.field-legend li{display:flex;align-items:center;gap:7px;cursor:help} +.legend-note{font:9px/1.6 var(--mono);letter-spacing:.1em;color:var(--faint);margin:10px 0 0;text-transform:uppercase} .has-selection .field-legend,.has-selection .legend-note{display:none} /* ── Static status markers ────────────────────────────────────────────────── - * The same five engraved forms the GL/2D field draws, rebuilt in pure CSS for - * the legend, the atlas rail and the reading guide. Shape alone identifies a - * state: nothing here animates, and nothing depends on glow to be read. - * A plate is an authority, never evidence of a working host. */ -.marker{position:relative;box-sizing:border-box;display:inline-block;flex:none;width:10px;height:10px;border-radius:50%;background:none;box-shadow:none} + * The same five engraved forms the field draws, rebuilt in pure CSS for the + * legend, the atlas rail and the reading guide. Shape alone identifies state: + * nothing animates, and nothing depends on glow to be read. */ +.marker{position:relative;box-sizing:border-box;display:inline-block;flex:none;width:10px;height:10px;border-radius:50%;background:none} .marker::after,.marker::before{content:'';position:absolute;box-sizing:border-box} -/* plate: bezel ring and a hollow keystone, deliberately no filled core */ .marker[data-marker=plate]{border:1px solid var(--accent);opacity:.95} .marker[data-marker=plate]::after{inset:2.5px;border:1px solid var(--accent);border-radius:1px;transform:rotate(45deg);opacity:.72} -/* idle: one open hairline ring — observed, no running lease */ .marker[data-marker=idle]{border:1px solid var(--accent);opacity:.62} -/* recent: bullseye — ring, gap, separated core */ .marker[data-marker=recent]{border:1px solid var(--accent)} .marker[data-marker=recent]::after{inset:2.5px;border-radius:50%;background:var(--accent)} -/* uncertain: amber ring struck through */ .marker[data-marker=uncertain]{border:1px solid var(--warn)} .marker[data-marker=uncertain]::after{left:1.5px;right:1.5px;top:50%;height:1px;margin-top:-.5px;background:var(--warn)} -/* quiet: a broken ring, retained but not live */ .marker[data-marker=quiet]{border:1px dashed #7d8798;opacity:.9} .field-legend .marker{width:9px;height:9px} @@ -174,101 +158,102 @@ input[type=search]::placeholder{color:#4e5b6d} .swarm-label.selected{color:#f1fdff;background:rgba(127,209,222,.12);box-shadow:inset 0 0 0 1px rgba(127,209,222,.4)} /* ── Evidence sheet ─────────────────────────────────────────────────────── */ -.swarm-inspector{display:none;position:absolute;z-index:16;right:22px;top:22px;bottom:86px;width:380px;padding:24px 24px 30px;overflow:auto;scrollbar-width:thin} -.swarm-inspector::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:linear-gradient(180deg,var(--accent),transparent 72%);opacity:.8} +.swarm-inspector{display:none;right:22px;top:22px;bottom:84px;width:382px;padding:22px 24px 28px;overflow:hidden auto;z-index:16} +.swarm-inspector::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:linear-gradient(180deg,var(--accent),transparent 72%);opacity:.85} .has-selection .swarm-inspector{display:block} -.swarm-inspector>.section-title{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--rule);padding-bottom:14px;margin-bottom:20px;color:var(--muted)} -#swarm-close{font-size:17px;line-height:1;padding:2px 6px;color:var(--quiet);border:0} +.swarm-inspector>.section-title{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--rule);padding-bottom:13px;margin-bottom:18px;color:var(--muted)} +#swarm-close{font-size:17px;line-height:1;padding:2px 7px;color:var(--quiet);border:0} .evidence-kicker{font:var(--kicker)!important;letter-spacing:.22em!important;color:var(--accent)!important;margin:0 0 6px!important;text-transform:uppercase} .source-state{font:9px var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--quiet);margin-bottom:4px} -.swarm-inspector h2{font:400 clamp(21px,2vw,28px)/1.14 var(--serif);letter-spacing:-.03em;margin:6px 0 18px;color:#eaf2fa;overflow-wrap:anywhere} -.swarm-inspector h3{font:400 15px var(--serif);color:#d4e4ea;border-top:1px solid var(--rule);padding-top:17px;margin:28px 0 6px} -.swarm-inspector p{font:11.5px/1.7 var(--mono);color:var(--muted);overflow-wrap:anywhere} +.swarm-inspector h2{font:400 clamp(21px,2vw,27px)/1.14 var(--serif);letter-spacing:-.03em;margin:6px 0 16px;color:#eaf2fa;overflow-wrap:anywhere} +.swarm-inspector h3{font:9px var(--mono);letter-spacing:.18em;text-transform:uppercase;color:var(--quiet);border-top:1px solid var(--rule);padding-top:15px;margin:26px 0 4px;font-weight:400} +.swarm-inspector p{font:11.5px/1.7 var(--sans);color:var(--muted);overflow-wrap:anywhere} +.swarm-inspector .chip{margin:4px 6px 4px 0} +.evidence-empty{font:10px var(--mono)!important;letter-spacing:.08em;color:var(--faint)!important;text-transform:uppercase} .swarm-inspector dl{display:block;font:11px/1.55 var(--mono);margin:14px 0 0} -.swarm-inspector dt{color:var(--quiet);padding-top:11px;margin:0;border-top:1px solid rgba(150,180,210,.09);font-size:9px;letter-spacing:.11em;text-transform:uppercase} -.swarm-inspector dd{margin:4px 0 11px;color:#d6e1ee;overflow-wrap:anywhere} -.swarm-inspector button:not(#swarm-close){display:block;width:100%;text-align:left;font:10.5px var(--mono);border:0;border-bottom:1px solid rgba(150,180,210,.09);border-radius:0;padding:10px 2px;margin:0;color:#bcd8e0;overflow-wrap:anywhere} -.swarm-inspector button:not(#swarm-close):hover{color:#eafcff;background:rgba(127,209,222,.06)} -.evidence-note{border:0!important;border-left:2px solid var(--warn)!important;background:rgba(224,168,92,.07)!important;border-radius:0;padding:11px 14px!important;color:#dcc49d!important;margin:14px 0} - +.swarm-inspector dt{color:var(--quiet);padding-top:10px;margin:0;border-top:1px solid rgba(150,180,210,.09);font-size:9px;letter-spacing:.11em;text-transform:uppercase} +.swarm-inspector dd{margin:4px 0 10px;color:#d6e1ee;overflow-wrap:anywhere} +.swarm-inspector button:not(#swarm-close){display:block;width:100%;text-align:left;font:10.5px var(--mono);border:0;border-bottom:1px solid rgba(150,180,210,.09);border-radius:0;padding:10px 2px;margin:0;color:#bcd8e0;overflow-wrap:anywhere;position:relative} +.swarm-inspector button:not(#swarm-close):hover{color:#eafcff;background:rgba(127,209,222,.06);padding-left:7px} .marker-guide{list-style:none;margin:14px 0 4px;padding:0;display:grid;gap:1px} .marker-guide li{display:grid;grid-template-columns:12px minmax(0,1fr);gap:4px 11px;align-items:center;padding:11px 0;border-top:1px solid rgba(150,180,210,.09)} .marker-guide .guide-dot{grid-row:1/3} .marker-guide strong{font:9px var(--mono);font-weight:400;letter-spacing:.13em;text-transform:uppercase;color:#dce7f2} .marker-guide small{font:10px/1.5 var(--mono);color:var(--quiet)} -/* ── Tracking ledger and instruments ────────────────────────────────────── */ -.swarm-timeline{position:absolute;z-index:15;bottom:24px;left:340px;width:min(470px,calc(100% - 800px));padding:13px 16px;border-radius:3px} -.swarm-timeline summary{display:flex;gap:10px;align-items:baseline;cursor:pointer;margin:0;font:var(--kicker);letter-spacing:.16em;color:var(--muted);text-transform:uppercase} -.swarm-timeline summary::marker{color:var(--faint)} +/* ── Tracking ledger, measurements, footer ──────────────────────────────── */ +.swarm-timeline{position:absolute;z-index:15;bottom:22px;left:338px;width:min(430px,calc(100% - 820px));padding:12px 16px;border-radius:3px} +.swarm-timeline summary{display:flex;gap:10px;align-items:baseline;margin:0;font:var(--kicker);letter-spacing:.17em;color:var(--muted);text-transform:uppercase} .swarm-timeline summary span{font:9px var(--mono);letter-spacing:.04em;color:var(--faint);text-transform:none} -#tracking-list{font:10px/1.6 var(--mono);max-height:190px;overflow:auto;padding-top:12px} +#tracking-list{font:10px/1.6 var(--mono);max-height:180px;overflow:auto;padding-top:10px} #tracking-list p{color:var(--faint);margin:0} .tracking-row{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:12px;padding:7px 0;border-bottom:1px solid rgba(150,180,210,.07);color:#c3cfdd} .tracking-row time{color:var(--faint);font-size:9px} -.swarm-measurements{position:absolute;z-index:17;right:24px;bottom:28px;max-width:320px;text-align:right;font:9px var(--mono);color:var(--faint)} -.swarm-measurements summary{cursor:pointer;letter-spacing:.14em;text-transform:uppercase} -.swarm-diagnostics{font:9px/1.85 var(--mono);margin-top:10px;padding:11px 12px;color:var(--muted);text-align:left;overflow-wrap:anywhere} +.swarm-measurements{position:absolute;z-index:17;right:24px;bottom:26px;max-width:320px;text-align:right;font:9px var(--mono);color:var(--faint)} +.swarm-measurements summary{letter-spacing:.16em;text-transform:uppercase} +.swarm-diagnostics{font:9px/1.85 var(--mono);margin-top:10px;padding:11px 12px;color:var(--muted);text-align:left;overflow-wrap:anywhere;border-radius:3px} .sample-notice{color:#e3bf85!important} -footer{position:relative;z-index:20;height:46px;display:flex;flex-wrap:wrap;align-items:center;gap:7px 18px; - border-top:1px solid var(--rule);font:9px/1.6 var(--mono);letter-spacing:.07em;color:var(--faint);padding:0 26px;background:#070a11;text-transform:uppercase} +footer{position:relative;z-index:20;min-height:46px;display:flex;flex-wrap:wrap;align-items:center;gap:7px 18px; + border-top:1px solid var(--rule);font:9px/1.6 var(--mono);letter-spacing:.1em;color:var(--faint);padding:10px 26px;background:#070a11;text-transform:uppercase} footer b{color:var(--warn);font-weight:400} -.field-empty .observatory-title{opacity:.75} +.foot-o{width:9px;height:9px;border-radius:50%;border:1px solid var(--rule-strong);position:relative} +.foot-o::after{content:'';position:absolute;left:1.5px;right:1.5px;top:50%;height:1px;margin-top:-.5px;background:var(--rule-strong)} /* ── Responsive framing ─────────────────────────────────────────────────── */ @media(max-width:1320px){ - .swarm-timeline{width:min(420px,calc(100% - 760px))} + .swarm-timeline{width:min(400px,calc(100% - 780px))} .swarm-inspector{width:340px} .has-selection .space-toolbar{right:378px} } @media(max-width:1120px){ - .swarm-navigation{width:252px;left:16px;top:16px;padding:18px 16px} - .space-toolbar{left:282px;right:18px;top:16px} + .swarm-navigation{width:250px;left:16px;top:16px;padding:18px 15px} + .space-toolbar{left:280px;right:18px;top:16px} .has-selection .space-toolbar{right:312px} - .swarm-inspector{width:286px;right:16px;top:74px;padding:20px} - .space-caption{left:284px;bottom:104px;max-width:min(360px,44vw)} - .swarm-timeline{left:282px;width:calc(100% - 620px)} + .swarm-inspector{width:286px;right:16px;top:72px;padding:20px} + .space-caption{left:282px;bottom:100px;max-width:min(420px,46vw)} + .swarm-timeline{left:280px;width:calc(100% - 620px)} .field-legend{gap:5px 12px} + .figure{padding-right:14px} + .figure+.figure{padding-left:14px} } @media(max-width:900px){ .swarm-timeline{display:none} - .space-caption{bottom:38px} + .space-caption{bottom:34px} } @media(max-width:800px){ - .swarm-top{height:auto;min-height:72px;flex-wrap:wrap;gap:10px;padding:12px 16px;position:sticky;top:0} - .swarm-brand{font-size:19px} + .swarm-top{height:auto;min-height:68px;flex-wrap:wrap;gap:10px;padding:12px 16px;position:sticky;top:0} #swarm-status{order:4;flex-basis:100%;text-align:left;font-size:9px} .read-only{margin-left:auto} .swarm-shell{height:auto;min-height:0;display:block} .swarm-space{position:relative;height:clamp(420px,62vh,620px)} .space-toolbar,.has-selection .space-toolbar{top:12px;left:12px;right:12px;flex-wrap:wrap;gap:8px} #swarm-path{max-width:100%} - .swarm-navigation{position:relative;left:auto;top:auto;bottom:auto;width:auto;margin:16px 12px 0;max-height:none;padding:18px 16px;backdrop-filter:none;background:var(--plate-solid)} + .swarm-navigation{position:relative;left:auto;top:auto;bottom:auto;width:auto;margin:14px 12px 0;max-height:none;padding:18px 16px;backdrop-filter:none;background:var(--plate-solid)} .navigation-hidden .swarm-navigation{display:none} #search-results{max-height:none} - #source-list{grid-auto-flow:column;grid-auto-columns:minmax(186px,1fr);overflow:auto;gap:10px;padding-bottom:6px} + #source-list{grid-auto-flow:column;grid-auto-columns:minmax(184px,1fr);overflow:auto;gap:10px;padding-bottom:6px} #source-list button{border-bottom:0;border:1px solid var(--rule);padding:12px} #branch-list{max-height:220px} - .space-caption,.navigation-hidden .space-caption{left:18px;bottom:24px;max-width:calc(100% - 36px)} - .observatory-title,.has-selection .observatory-title{font-size:clamp(26px,7vw,36px)} + .space-caption,.navigation-hidden .space-caption{left:16px;right:16px;bottom:20px;max-width:none} .field-legend,.has-selection .field-legend{display:flex} .legend-note,.has-selection .legend-note{display:block} .swarm-measurements{right:14px;bottom:14px;max-width:62%} - .swarm-inspector,.has-selection .swarm-inspector{display:block;position:relative;right:auto;left:auto;top:auto;bottom:auto;width:auto;margin:16px 12px;padding:22px;backdrop-filter:none;background:var(--plate-solid)} - footer{height:auto;padding:14px 16px} + .swarm-inspector,.has-selection .swarm-inspector{display:block;position:relative;right:auto;left:auto;top:auto;bottom:auto;width:auto;margin:14px 12px;padding:22px;backdrop-filter:none;background:var(--plate-solid)} + footer{padding:14px 16px} } @media(max-width:520px){ .swarm-brand{font-size:18px} #swarm-find{margin-left:auto} .read-only{display:none} - .swarm-space{height:clamp(500px,62vh,600px)} + .swarm-space{height:clamp(460px,60vh,560px)} .space-controls{flex-wrap:wrap} - .field-legend{gap:4px 10px;font-size:8.5px;letter-spacing:.04em} + .field-legend{gap:4px 10px;font-size:8.5px;letter-spacing:.05em} .legend-note{display:none} - .space-caption p{display:none} + .caption-kicker{display:none} + .figure b{font-size:22px} + .figure,.figure+.figure{padding:0 10px} .swarm-inspector h2{font-size:22px} } @media(prefers-reduced-motion:reduce){ - *{transition:none!important;animation:none!important} - .field-nebula{filter:none;opacity:.35} + .field-nebula{filter:none;opacity:.32} } diff --git a/src/swarm/swarm.css b/src/swarm/swarm.css index 3328391..32f60ee 100644 --- a/src/swarm/swarm.css +++ b/src/swarm/swarm.css @@ -1 +1,72 @@ -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#dfeae7;background:#081216;color-scheme:dark;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,select{color:#cbded7;background:#102127;border:1px solid #2d4146;border-radius:6px}button{cursor:pointer;padding:7px 10px}button:hover,button:focus-visible{background:#19332f;border-color:#80d5b8;outline:none}button[aria-pressed=true]{color:#adf5d9;border-color:#70cbb0;background:#18352e}a{color:#9bcbbd;text-decoration:none}button:disabled{opacity:.4;cursor:default}.swarm-top{height:72px;border-bottom:1px solid #233239;display:flex;gap:24px;align-items:center;padding:0 28px}.swarm-brand{font-size:19px;color:#e0eeea;white-space:nowrap}.swarm-brand b{color:#8bdfc1}#swarm-status{font-size:12px;color:#9badab;flex:1}.read-only,.section-title{font-size:10px;letter-spacing:.14em;color:#afc3ba}.read-only{border:1px solid #344a46;border-radius:3px;padding:4px 7px}.swarm-shell{display:grid;grid-template-columns:230px minmax(0,1fr) 290px;min-height:calc(100vh - 110px)}.swarm-navigation,.swarm-inspector{padding:24px 18px;min-width:0}.swarm-navigation{border-right:1px solid #233239}.swarm-inspector{border-left:1px solid #233239;background:#0b171c}.section-title{margin-bottom:18px}.search-label{display:grid;gap:9px;font-size:11px;color:#9fb6ae}input[type=search]{width:100%;border:1px solid #30464b;border-radius:6px;padding:10px;background:#101f25;color:#ecf5f0;font-size:12px}#source-list{margin:24px 0}#source-list button,#branch-list button,#search-results button{display:block;width:100%;text-align:left;margin:6px 0;font-size:12px;overflow-wrap:anywhere}#source-list button small,#branch-list button small{display:block;margin-top:5px;color:#90a8a0;font-size:10px}#search-results{max-height:270px;overflow:auto}#search-results p{font-size:11px;color:#8caaa0}#branch-list{max-height:420px;overflow:auto}.scope-note{font-size:11px;color:#78908b;line-height:1.65;margin-top:22px}.swarm-space{min-width:0;background:radial-gradient(ellipse at 48% 42%,#18353080,transparent 53%),radial-gradient(ellipse at 73% 62%,#20334f50,transparent 50%)}.space-toolbar{padding:18px 20px;border-bottom:1px solid #203039;display:flex;flex-wrap:wrap;gap:15px;justify-content:space-between;align-items:center}#swarm-path{display:flex;flex-wrap:wrap;align-items:center;gap:5px;font-size:11px}#swarm-path button{padding:4px 7px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.space-controls{display:flex;align-items:center;gap:12px;font-size:11px;flex-wrap:wrap}.space-controls button{font-size:11px}.space-controls select{padding:5px}.follow-control{color:#9fb8b0;display:flex;align-items:center;gap:4px}#swarm-stage{position:relative;height:max(480px,calc(100vh - 245px));min-height:400px;overflow:hidden}#swarm-canvas{display:block;width:100%;height:100%;touch-action:none}#swarm-labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}.swarm-label{position:absolute;top:0;left:0;font-size:10px;color:#9bbeb1;white-space:nowrap;text-shadow:0 1px 7px #081216;max-width:160px;overflow:hidden;text-overflow:ellipsis}.swarm-label.source-label{font-size:13px;letter-spacing:.05em;color:#c5e6d8}.swarm-label.selected{color:#e0fff0}.space-caption{position:absolute;bottom:22px;left:22px;font-size:12px;color:#b0cdc1;pointer-events:none}.space-caption p{font-size:10px;color:#78968a;margin-bottom:0}.swarm-diagnostics{font-size:10px;color:#7e958c;border-top:1px solid #223239;padding:9px 20px;min-height:30px;overflow-wrap:anywhere}.swarm-timeline{padding:20px;border-top:1px solid #223239}.swarm-timeline .section-title{display:flex;gap:12px;flex-wrap:wrap}.swarm-timeline .section-title span{color:#76968b;letter-spacing:0}#tracking-list{font-size:11px;line-height:1.6;max-height:150px;overflow:auto}#tracking-list p{color:#81998f}.tracking-row{display:flex;flex-wrap:wrap;gap:12px;padding:7px 0;border-bottom:1px solid #233239}.tracking-row time{color:#809c91;font-size:10px}.swarm-inspector h2{font-size:21px;font-weight:500;line-height:1.3;overflow-wrap:anywhere}.swarm-inspector h3{font-size:13px;margin-top:26px}.swarm-inspector p{font-size:12px;color:#aec2b9;line-height:1.65;overflow-wrap:anywhere}.swarm-inspector dl{font-size:11px;display:grid;grid-template-columns:1fr;gap:6px;line-height:1.5}.swarm-inspector dt{color:#819a8e;margin-top:7px}.swarm-inspector dd{margin:0;overflow-wrap:anywhere;color:#c8dcd2}.swarm-inspector code{font-size:10px;color:#aac6b6;overflow-wrap:anywhere}.evidence-note{border:1px solid #594a30;background:#342b1b33;padding:10px;border-radius:5px;color:#dac295!important}.task-link,.effect-link{display:block;width:100%;text-align:left;font-size:11px;margin:7px 0;overflow-wrap:anywhere}.sample-notice{color:#e7c288!important}.source-state{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:#8bd4b3}footer{border-top:1px solid #233239;padding:14px 24px;color:#7c958b;font-size:10px;line-height:1.6}@media(max-width:1120px){.swarm-shell{grid-template-columns:190px minmax(0,1fr) 240px}.swarm-top{padding:0 18px;gap:15px}.swarm-navigation,.swarm-inspector{padding:20px 14px}.follow-control{display:none}}@media(max-width:800px){.swarm-shell{grid-template-columns:170px minmax(0,1fr)}.swarm-inspector{grid-column:1/-1;border-left:0;border-top:1px solid #233239;display:block}.swarm-inspector dl{grid-template-columns:130px minmax(0,1fr)}.swarm-inspector dt{margin-top:0}.swarm-top{height:auto;min-height:65px;flex-wrap:wrap;padding:15px}.read-only{margin-left:auto}#swarm-status{order:3;flex-basis:100%}#swarm-stage{height:480px}.space-controls{gap:8px}.space-controls label{font-size:10px}.space-toolbar{padding:13px}.space-caption{left:12px;font-size:11px}}@media(max-width:520px){.swarm-shell{grid-template-columns:minmax(0,1fr)}.swarm-navigation{border-right:0;border-bottom:1px solid #233239;padding:16px}#source-list{display:flex;gap:7px;margin:14px 0;overflow:auto}#source-list button{min-width:140px;max-width:190px;margin:0}#branch-list{max-height:150px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px}#branch-list button{margin:0}#branch-heading{margin-bottom:10px}.scope-note{margin:12px 0 0;font-size:10px}.swarm-navigation .section-title{margin-bottom:10px}#swarm-stage{height:420px}.swarm-inspector dl{grid-template-columns:110px minmax(0,1fr)}footer{padding:12px 16px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} +/* THE OBSERVATORY — tokens, reset and controls. + * One ink, one hairline, three type voices: a serif for figures and names, + * a mono for labels and identities, a sans for recorded prose. Colour is + * evidence: the source hue is deterministic, amber is uncertainty, grey is a + * retained record. Nothing here animates on its own. */ +:root{ + --void:#04060b; --ink:#070a11; --deep:#0a0f18; + --plate:rgba(12,17,27,.78); --plate-solid:#0b1019; --plate-lit:rgba(127,209,222,.1); + --rule:rgba(150,180,210,.14); --rule-strong:rgba(160,196,226,.3); + --text:#e9eef7; --muted:#a6b3c7; --quiet:#8995a7; --faint:#7c899d; + --signal:#7fd1de; --accent:#7fd1de; --warn:#e0a85c; --hist:#b4bcce; + --serif:ui-serif,'Iowan Old Style','Palatino Linotype',Palatino,Georgia,serif; + --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + --sans:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; + --kicker:9px/1.1 var(--mono); + --ease:cubic-bezier(.2,.7,.3,1); + font-family:var(--sans); color:var(--text); background:var(--void); color-scheme:dark; font-synthesis:none; + -webkit-font-smoothing:antialiased; +} +*{box-sizing:border-box} +body{margin:0;background:var(--void);color:var(--text);overflow-x:hidden} +#swarm-app{position:relative;isolation:isolate} +button,input,select{font:inherit;font-family:inherit} +button{cursor:pointer;padding:7px 10px;border-radius:3px;background:transparent;border:1px solid var(--rule);color:var(--muted); + transition:color .16s var(--ease),border-color .16s var(--ease),background .16s var(--ease),transform .16s var(--ease)} +button:hover{background:rgba(127,209,222,.07);border-color:var(--rule-strong);color:var(--text)} +button:active{transform:translateY(.5px)} +button[aria-pressed=true]{color:var(--text);border-color:var(--accent);background:rgba(127,209,222,.1)} +button:disabled{opacity:.35;cursor:default} +select,input{border-radius:3px;background:var(--plate-solid);border:1px solid var(--rule);color:var(--text)} +button:focus-visible,input:focus-visible,select:focus-visible,canvas:focus-visible,summary:focus-visible,a:focus-visible{outline:1px solid var(--accent);outline-offset:3px;border-radius:2px} +a{color:#a9cfdb;text-decoration:none} +kbd{font:9px var(--mono);border:1px solid var(--rule);border-radius:2px;padding:1px 3px;color:var(--faint);margin-left:5px} +summary{cursor:pointer} +::selection{background:rgba(127,209,222,.28)} +/* Hairline scrollbars so a scrolling plate still reads as one surface. */ +*{scrollbar-width:thin;scrollbar-color:rgba(160,196,226,.22) transparent} +*::-webkit-scrollbar{width:8px;height:8px} +*::-webkit-scrollbar-thumb{background:rgba(160,196,226,.2);border-radius:8px} +*::-webkit-scrollbar-track{background:transparent} + +/* ── The O ────────────────────────────────────────────────────────────────── + * The mark is a mouth: the Observatory only ever speaks about records. The + * lips part on hover or keyboard focus — a deliberate flourish on a link, not + * a report of execution — and hold still under reduced motion. */ +.o-mark{display:block;color:var(--signal);overflow:visible} +.o-mark .o-lip{transition:transform .5s var(--ease);transform-origin:32px 32px} +.o-mark .o-core,.o-mark .o-say,.o-mark .o-bezel{transition:opacity .4s var(--ease)} +a:hover .o-mark .o-lip-upper,a:focus-visible .o-mark .o-lip-upper{transform:translateY(-2.2px)} +a:hover .o-mark .o-lip-lower,a:focus-visible .o-mark .o-lip-lower{transform:translateY(2.2px)} +a:hover .o-mark .o-say,a:focus-visible .o-mark .o-say{opacity:.8} +.o-mark[data-speaking=true]{filter:drop-shadow(0 0 10px rgba(127,209,222,.35))} + +/* ── Badges ─────────────────────────────────────────────────────────────── */ +.chip{display:inline-flex;align-items:center;gap:6px;font:9px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase; + color:var(--muted);border:1px solid var(--rule);border-radius:999px;padding:5px 9px;white-space:nowrap;cursor:help} +.chip-live{color:#bfeef5;border-color:rgba(127,209,222,.46)} +.chip-live::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--signal);box-shadow:0 0 0 3px rgba(127,209,222,.14)} +.chip-warn{color:#e8c79a;border-color:rgba(224,168,92,.4);background:rgba(224,168,92,.07)} +.chip-hist{color:var(--hist);border-style:dashed} +.chip-quiet{color:var(--quiet)} +.chip+.chip{margin-left:0} + +/* ── Evidence drawer: the careful wording, folded out of the reading flow ── */ +.evidence-drawer{border-top:1px solid var(--rule);margin-top:14px;padding-top:10px} +.evidence-drawer>summary{font:9px var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--faint);list-style:none;display:flex;align-items:center;gap:8px} +.evidence-drawer>summary::-webkit-details-marker{display:none} +.evidence-drawer>summary::before{content:'+';font:10px var(--mono);color:var(--quiet);border:1px solid var(--rule);border-radius:50%;width:14px;height:14px;display:grid;place-items:center} +.evidence-drawer[open]>summary::before{content:'–'} +.evidence-drawer>summary:hover{color:var(--muted)} +.evidence-drawer p{font:11px/1.75 var(--sans);color:var(--quiet);margin:10px 0 0;max-width:72ch} +@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;scroll-behavior:auto!important}} diff --git a/swarm.html b/swarm.html index 0f92322..890e709 100644 --- a/swarm.html +++ b/swarm.html @@ -1,29 +1,28 @@ -Brain · swarm +O · Observatory
- brain observatory + O Observatory Waiting for an authorized observation - READ ONLY + READ ONLY
- +
@@ -37,25 +36,23 @@

RECORDED FACTORY SPACE

-
A wider mind.
- -

Orbit the recorded world. Enter a source. Follow its work.

-
    -
  • authority plate · not worker activity
  • -
  • observed · no running lease
  • -
  • running lease · recent heartbeat
  • -
  • work unconfirmed · or outcome unknown
  • -
  • retained · retired, reserved or stale
  • +
    +
      +
    • authority
    • +
    • idle
    • +
    • leased
    • +
    • uncertain
    • +
    • retained
    -

    Shapes, not motion: recorded observations only, never host or worker health.

    +

    shapes report records, never host health

-
LIVE TRACKING
-
Rendering details
+
TRACKING
+
Rendering
-
FACTORY owns execution and recovery.Recorded activity does not establish host health.Worker quiescence: unverified.Markers report records, not host health.
+
FACTORY owns executionrecords, not host healthworker quiescence unverified
diff --git a/test/insights-dom.test.mjs b/test/insights-dom.test.mjs index f3727d2..ceb9ba3 100644 --- a/test/insights-dom.test.mjs +++ b/test/insights-dom.test.mjs @@ -6,7 +6,7 @@ import ts from 'typescript'; const generated = new URL('../.vite/insights-dom-tests/', import.meta.url); await mkdir(generated, { recursive: true }); -for (const name of ['insights', 'insights-model', 'model', 'preview', 'insights-preview']) { +for (const name of ['insights', 'insights-model', 'model', 'preview', 'insights-preview', 'marks']) { const code = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); const result = ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText .replace(/import '\.\/insights\.css';/, '') @@ -17,6 +17,41 @@ const { mountInsights } = await import(new URL('insights.mjs', generated)); const { swarmPreview } = await import(new URL('preview.mjs', generated)); const { observatoryPreview } = await import(new URL('insights-preview.mjs', generated)); const { activities } = await import(new URL('model.mjs', generated)); +const { flowDiagram } = await import(new URL('marks.mjs', generated)); + +test('section navigation retains authority fragments and completed operations stay distinct from deliveries', t => { + const previous = globalThis.document; + const dom = new JSDOM('
', { url: 'http://localhost/swarm.html#source=source-1&factory=design-authority-1&kind=source&id=source-1' }); + globalThis.document = dom.window.document; + t.after(() => { globalThis.document = previous; dom.window.close(); }); + let scrolled = false, selections = 0; + const view = mountInsights(() => selections++), swarm = swarmPreview(); + view.observe(observatoryPreview(swarm.sources), swarm); view.render(swarm, null); + document.getElementById('insights-history').scrollIntoView = () => { scrolled = true; }; + const event = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }); + document.querySelector('a[href="#insights-history"]').dispatchEvent(event); + assert.equal(event.defaultPrevented, true); assert.equal(scrolled, true); assert.equal(selections, 0); + assert.match(dom.window.location.hash, /factory=design-authority-1/); + const tasks = [...document.querySelectorAll('.metric-card')].find(card => card.querySelector('p').textContent === 'Tasks'); + assert.match(tasks.querySelector('small').textContent, /0 delivered · 3 completed/); +}); + +test('cyclic and disconnected recorded flows keep every node inside the drawing with full identities', t => { + const previous = globalThis.document, dom = new JSDOM(); globalThis.document = dom.window.document; + t.after(() => { globalThis.document = previous; dom.window.close(); }); + const nodes = ['a', 'b', 'c', 'unconnected'].map(id => ({ id, label: `Full recorded label for ${id}`, type: 'process' })); + const flow = { nodes, edges: [{ source: 'a', target: 'b' }, { source: 'b', target: 'c' }, { source: 'c', target: 'a' }] }; + const art = flowDiagram(flow, 'b', 'LAST RECORDED POSITION'), [, , width, height] = art.getAttribute('viewBox').split(' ').map(Number); + assert.equal(art.querySelectorAll('.flow-art-edge').length, flow.edges.length); + for (const group of art.querySelectorAll('.flow-art-node')) { + const [x, y] = group.getAttribute('transform').match(/[\d.]+/g).map(Number), rect = group.querySelector('rect'); + assert.ok(x >= 0 && x + Number(rect.getAttribute('width')) <= width); + assert.ok(y >= 0 && y + Number(rect.getAttribute('height')) <= height); + assert.match(group.querySelector('title').textContent, /Full recorded label for/); + } + assert.equal(art.querySelectorAll('.flow-art-node').length, nodes.length); + assert.match(art.querySelector('.is-current title').textContent, /LAST RECORDED POSITION/); +}); test('same-object render expires leased analytics; idle ticks expire worker/provider freshness without rebuilding transcripts', t => { const originalDocument = globalThis.document, originalNow = Date.now, base = originalNow(); let now = base; diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index 09e62cc..c236498 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -5,14 +5,20 @@ import { performance } from 'node:perf_hooks'; import ts from 'typescript'; const generated = new URL('../.vite/swarm-tests/', import.meta.url); await mkdir(generated, { recursive: true }); -for (const name of ['model', 'preview', 'transport', 'renderer']) { +for (const name of ['model', 'preview', 'transport', 'renderer', 'marks']) { const source = await readFile(new URL(`../src/swarm/${name}.ts`, import.meta.url), 'utf8'); await writeFile(new URL(`${name}.mjs`, generated), ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText); } const { parseSwarm, SwarmIndex, nodeKey, activities, selectionFragment, selectionFromFragment } = await import(new URL('model.mjs', generated)); const { swarmPreview } = await import(new URL('preview.mjs', generated)); const { readSwarmJson } = await import(new URL('transport.mjs', generated)); -const { markerOf, activityEvidence } = await import(new URL('renderer.mjs', generated)); +const { markerOf, activityEvidence, sourceColor } = await import(new URL('renderer.mjs', generated)); +const { sourceHue: markSourceHue } = await import(new URL('marks.mjs', generated)); + +test('the field renderer and the instrument marks read one deterministic authority palette', () => { + for (const source of swarmPreview().sources) assert.equal(markSourceHue(source.id), sourceColor(source.id)); + for (const id of ['a', 'factory-9', 'another-registry-alias', '']) assert.equal(markSourceHue(id), sourceColor(id)); +}); test('deep links bind a reused registry alias to its exact factory and reject ambiguous/missing authority', () => { const swarm = swarmPreview(), source = swarm.sources[0], selected = { sourceId: source.id, kind: 'cell', id: 'root' }; From 03a94e5bbbe4eade2d02b4c29685862361ee92e5 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:39:45 -0500 Subject: [PATCH 8/9] Qualify the recent leased-cell count and O labels --- docs/OBSERVATORY_INSIGHTS.md | 2 +- src/swarm/insights.ts | 2 +- src/swarm/main.ts | 12 +++++++----- src/swarm/marks.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/OBSERVATORY_INSIGHTS.md b/docs/OBSERVATORY_INSIGHTS.md index 9fa59fe..7e58367 100644 --- a/docs/OBSERVATORY_INSIGHTS.md +++ b/docs/OBSERVATORY_INSIGHTS.md @@ -18,7 +18,7 @@ Validation: `npm run build` and `npm run test:swarm` include namespace, graph-bi The O is a mouth. Its SVG mark shares the field's deterministic authority palette. The visual language combines serif readings, monospace identities, glass surfaces, measured rules and a numbered section spine. All five observation sections remain expanded. Tool calls, operation receipts and evidence digests start expanded; longer interpretation notes live in Evidence drawers. -The dial shows counted recent leases, the budget rule shows recorded paid holds, the timeline ribbon shows record density in three separate provenance lanes, and flow diagrams show recorded nodes and edges. These marks do not introduce health, inferred execution, reconstructed history or new controls. Node tooltips and the accessible graph description preserve full labels, types and identities. Cyclic or disconnected definitions retain every node within the SVG bounds. Completed operations remain separate from delivered software in the task reading. +The dial and field's `recent leased` figure show cells with an unexpired running lease and recent heartbeat/source evidence. Uncertain, unknown-outcome and retained records are excluded; the figure and mouth mark do not report a full lease census. The budget rule shows recorded paid holds, the timeline ribbon shows record density in three separate provenance lanes, and flow diagrams show recorded nodes and edges. These marks do not introduce health, inferred execution, reconstructed history or new controls. Node tooltips and the accessible graph description preserve full labels, types and identities. Cyclic or disconnected definitions retain every node within the SVG bounds. Completed operations remain separate from delivered software in the task reading. The requested FLUJO flow `6b0b98fd-aa5a-4d8a-bdf5-510bb70a7e03` (`Claude_Opus_Agent`) authored the presentation candidate in conversation `c46dc824-9cc3-45a8-9c19-eabab529f9d8`. Parent review repaired section navigation, task-status labels, graph bounds, receipt expansion and responsive readings before acceptance. The flow definition was not changed. diff --git a/src/swarm/insights.ts b/src/swarm/insights.ts index 80d2896..3e10901 100644 --- a/src/swarm/insights.ts +++ b/src/swarm/insights.ts @@ -269,7 +269,7 @@ export function mountInsights(onSelect: (selection: Selection | null) => void) { const mark = oMark(segments, leased, 56); mark.setAttribute('role', 'img'); mark.removeAttribute('aria-hidden'); - mark.setAttribute('aria-label', `${swarm.sources.length} registered authorities · ${leased ? 'a running lease is recorded in scope' : 'no running lease recorded in scope'}`); + mark.setAttribute('aria-label', `${swarm.sources.length} registered authorities · ${leased ? 'recent leased-cell evidence in scope' : 'no recent leased-cell evidence in scope'}; running leases require recent heartbeat/source evidence, with uncertain and retained records excluded`); markSlot.replaceChildren(mark); } function paint() { paintMark(); paintAnalytics(); paintHistory(); paintWorkers(); paintConversations(); paintMachines(); } diff --git a/src/swarm/main.ts b/src/swarm/main.ts index 8e35c89..9adaeca 100644 --- a/src/swarm/main.ts +++ b/src/swarm/main.ts @@ -225,15 +225,17 @@ function redraw(): void { const accented = selection ? index.sources.get(selection.sourceId) : null; document.body.style.setProperty('--accent', accented ? sourceColor(accented.id) : '#7fd1de'); const cells = swarm.sources.reduce((sum, source) => sum + (source.snapshot?.snapshot.cells.length ?? 0), 0), tasks = swarm.sources.reduce((sum, source) => sum + (source.snapshot?.snapshot.tasks.length ?? 0), 0); - const leased = [...index.nodes.values()].filter(node => node.cell && markerOf(node) === 'recent').length; - $('swarm-counts').replaceChildren(figure(String(swarm.sources.length), 'sources'), figure(cells.toLocaleString(), 'cells'), figure(tasks.toLocaleString(), 'tasks'), figure(String(leased), 'leased')); + const recentLeased = [...index.nodes.values()].filter(node => node.cell && markerOf(node) === 'recent').length; + const recentFigure = figure(String(recentLeased), 'recent leased'); + recentFigure.title = 'Cells with an unexpired running lease and recent heartbeat/source evidence. Uncertain, unknown-outcome and retained records are excluded; this is not a full lease census.'; + $('swarm-counts').replaceChildren(figure(String(swarm.sources.length), 'sources'), figure(cells.toLocaleString(), 'cells'), figure(tasks.toLocaleString(), 'tasks'), recentFigure); const stale = swarm.sources.filter(source => sourceStatus(source) !== 'observed').length; // The masthead O carries the registry: one arc per authority, filled mouth - // only while a running lease is recorded somewhere in the swarm. - const mark = oMark(swarm.sources.map(source => ({ color: sourceColor(source.id), broken: sourceStatus(source) !== 'observed' })), leased > 0, 30); + // only while recent leased-cell evidence exists somewhere in the swarm. + const mark = oMark(swarm.sources.map(source => ({ color: sourceColor(source.id), broken: sourceStatus(source) !== 'observed' })), recentLeased > 0, 30); mark.setAttribute('role', 'img'); mark.removeAttribute('aria-hidden'); - mark.setAttribute('aria-label', `${swarm.sources.length} registered authorities · ${leased} cells with a running lease`); + mark.setAttribute('aria-label', `${swarm.sources.length} registered authorities · ${recentLeased} cells with an unexpired running lease and recent heartbeat/source evidence; uncertain and retained records excluded`); $('brand-mark').replaceChildren(mark); $('swarm-status').textContent = swarm.sample ? `Sample data · design preview · ${cells.toLocaleString()} cells` : swarm.sources.length ? `Live · 5s observations · ${stale} stale` : 'Observation unavailable · no registered facts'; $('swarm-status').classList.toggle('sample-notice', !!swarm.sample); diff --git a/src/swarm/marks.ts b/src/swarm/marks.ts index 7526ef1..0761c5a 100644 --- a/src/swarm/marks.ts +++ b/src/swarm/marks.ts @@ -27,7 +27,7 @@ export interface Segment { color: string; broken?: boolean } /** The O: a registry ring that opens into a mouth. * Each ring segment is one registered authority in its deterministic hue; a * broken segment is a retained or unavailable record. The mouth core is filled - * only when the caller counted at least one running lease — it is a count, + * only when the caller counted recent leased-cell evidence — it is a count, * not a heartbeat, and it never animates by itself. */ export function oMark(segments: Segment[], speaking: boolean, size = 44): SVGSVGElement { const root = frame('0 0 64 64', 'o-mark'); From 3e63866f21d914c056b0216a24accf858c73eaac Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:12:46 -0500 Subject: [PATCH 9/9] Read current FACTORY effect kinds in Observatory --- src/swarm/model.ts | 6 +++--- test/swarm.test.mjs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/swarm/model.ts b/src/swarm/model.ts index afc5ba1..1ac541d 100644 --- a/src/swarm/model.ts +++ b/src/swarm/model.ts @@ -2,7 +2,7 @@ * No provider locations, credentials, commands or arbitrary navigation URLs. */ export interface Cell { id: string; parentId: string | null; depth: number; role: 'coordinator' | 'developer' | 'verifier' | 'watcher'; status: 'reserved' | 'ready' | 'retired'; purpose: string; heartbeat: string; allocationCents: number; budgetBasis: 'logical-allocation' } export interface Task { id: string; projectId: string; branch: string; status: 'ready' | 'running' | 'review' | 'verified' | 'delivered' | 'rejected' | 'completed' | 'cancelled'; owner: string | null; attempt: number; leaseExpiry: string | null; specDigest: string; candidate: { sha256: string } | null; review: { accepted: boolean; reviewerId: string; evidenceDigest: string; candidateDigest: string; specDigest: string; attempt: number } | null } -export interface Effect { key: string; kind: 'provision' | 'flow_call' | 'retire' | 'delivery'; state: 'accepted' | 'running' | 'unknown' | 'succeeded' | 'not_applied'; owner: string; taskId: string | null; scope: string; scopeId: string; ownerEpoch: number; controlEpoch: number; createdAt: string; updatedAt: string; requestDigest: string } +export interface Effect { key: string; kind: 'provision' | 'flow_call' | 'message' | 'flow_cancel' | 'retire' | 'delivery' | 'worker_wake' | 'worker_sleep'; state: 'accepted' | 'running' | 'unknown' | 'succeeded' | 'cancelled' | 'not_applied'; owner: string; taskId: string | null; scope: 'task' | 'project' | 'cleanup' | 'worker'; scopeId: string; ownerEpoch: number; controlEpoch: number; createdAt: string; updatedAt: string; requestDigest: string } export interface Change { seq: number; type: string; subject: string; observedAt: string } export interface Snapshot { schemaVersion: 1; factoryId: string; revision: number; cursor: string; observedAt: string; scope: 'local-coordinator'; buildRevision: string; @@ -54,8 +54,8 @@ export function parseSwarm(value: unknown): Swarm { }); const effects = array(state.effects, 10000).map(value => { const effect = record(value); - return { key: id(effect.key), kind: choice(effect.kind, ['provision', 'flow_call', 'retire', 'delivery']), state: choice(effect.state, ['accepted', 'running', 'unknown', 'succeeded', 'not_applied']), - owner: id(effect.owner), taskId: optionalId(effect.taskId), scope: choice(effect.scope, ['task', 'project', 'cleanup']), scopeId: id(effect.scopeId), + return { key: id(effect.key), kind: choice(effect.kind, ['provision', 'flow_call', 'message', 'flow_cancel', 'retire', 'delivery', 'worker_wake', 'worker_sleep']), state: choice(effect.state, ['accepted', 'running', 'unknown', 'succeeded', 'cancelled', 'not_applied']), + owner: id(effect.owner), taskId: optionalId(effect.taskId), scope: choice(effect.scope, ['task', 'project', 'cleanup', 'worker']), scopeId: id(effect.scopeId), ownerEpoch: num(effect.ownerEpoch), controlEpoch: num(effect.controlEpoch), createdAt: date(effect.createdAt), updatedAt: date(effect.updatedAt), requestDigest: digest(effect.requestDigest) }; }); unique(cells.map(cell => cell.id)); unique(tasks.map(task => task.id)); unique(effects.map(effect => effect.key)); diff --git a/test/swarm.test.mjs b/test/swarm.test.mjs index c236498..cc5b19a 100644 --- a/test/swarm.test.mjs +++ b/test/swarm.test.mjs @@ -54,6 +54,21 @@ test('same bare root/task/effect identities remain distinct across authorities a assert.equal(index.nodeFor({ sourceId: source.id, kind: 'task', id: 'exploration' }).source.id, source.id); } }); +test('current FACTORY steering, cancellation and worker power effects remain observable', () => { + for (const [kind, state, scope] of [ + ['message', 'succeeded', 'task'], ['flow_cancel', 'cancelled', 'task'], + ['worker_wake', 'succeeded', 'worker'], ['worker_sleep', 'unknown', 'worker'], + ]) { + const swarm = swarmPreview(); + const effect = swarm.sources[2].snapshot.snapshot.effects[0]; + Object.assign(effect, { kind, state, scope }); + const parsed = parseSwarm(swarm).sources[2].snapshot.snapshot.effects[0]; + assert.deepEqual([parsed.kind, parsed.state, parsed.scope], [kind, state, scope]); + } + const unsupported = swarmPreview(); + unsupported.sources[2].snapshot.snapshot.effects[0].kind = 'arbitrary_command'; + assert.throws(() => parseSwarm(unsupported), /Unsupported observation value/); +}); test('10,000-level delegation can be indexed, searched, selected and drawn without recursive overflow or losing focus', () => { const swarm = swarmPreview(10000), source = swarm.sources[0]; swarm.sources = [source];