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 1/2] 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 2/2] 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'); +});