Build report · web/ · 2026-09-03

EKOS Web Console

A browser surface over the EKOS knowledge compiler. Six shipped increments take it from a pair of CLI contracts to a running console: workspace supervision, a statistics dashboard, a config editor, a job runner, a scheduler, and an interactive evidence graph.

6 phases RFC 01280133 devlog 151156 Rust + Python + React ~14k lines 72 API tests · ~30 Rust CLI tests all merged to main
00 · 0128
Contracts — graph export, status --json, MCP tool, TCP auth, Python client, skeleton
01 · 0129
Dashboard — workspace registry, MCP supervisor, stats + doctor
02 · 0130
ekos.toml — validate, preview-scan, append-only warning
03 · 0131
Command runner — allowlist, job queue, SSE logs, read/write roles
04 · 0132
Scheduling — cron/interval, webhook on failure
05 · 0133
Graph view — LOD 0/1, filters, search, object + evidence panel
§

What this is

EKOS is a compiler for enterprise knowledge. It observes source systems without interpreting them, compiles those observations through deterministic passes into a Canonical Knowledge Model, and stores the result in an append-only ledger where every conclusion carries the evidence it was derived from. Until now the only way to look at a compiled workspace was a terminal or an AI agent over MCP — the product's central claim, a traceable evidence chain, had no visual form.

The console closes that. It is a FastAPI application (web/api/) plus a Vite + React app (web/ui/). It never touches raw source systems: reads go through the read-only EKOS Runtime over the Model Context Protocol, and the few write actions run the ekos CLI as a subprocess against a hardcoded allowlist.

The load-bearing architectural decision

  • Python owns concurrency; Rust stays synchronous. A console needs long-lived jobs, a scheduler, and concurrent request handling — each pushes on the KnowledgeStore: Send constraint the Rust side documents and works around. Putting the concurrency in Python keeps every Rust ledger handle one-owner-per-process. The accepted cost: a second runtime and an extra network hop on reads.
  • The MCP transport is raw NDJSON over TCP (RFC 0115), so the console ships its own ~150-line asyncio client rather than the stdio-oriented mcp SDK.
  • Every write is gated twice — a hardcoded command allowlist and a role check — and never goes through a shell.
§

System shape

One FastAPI process holds five concerns. The workspace registry (SQLite) is the source of truth for what exists. The MCP supervisor spawns and restarts one ekos mcp serve --tcp per registered workspace and owns a pooled client to each. The job runner executes allowlisted ekos commands through a bounded per-workspace queue. The scheduler fires those same commands on a cron or interval trigger. Auth resolves a read/write principal per request.

Browser — React SPA Vite · TanStack Query · react-force-graph HTTP + SSE · session cookie FastAPI console — web/api Auth OIDC · or 2 tokens require_role() Workspace registry SQLite · SQLModel MCP supervisor spawn · restart · pool Job runner queue · SIGTERM Scheduler APScheduler read-only subprocess seam status / doctor / ledger timeline / config — --json NDJSON / TCP · bearer token exec, never a shell ekos mcp serve --tcp read-only Runtime · RFC 0097 ekos build / recover / … write lock · RFC 0104 Semantic Knowledge Ledger append-only · fact-segment engine
One writer per workspace. The supervisor's MCP server holds a cached read-only handle that never blocks a writer (RFC 0097); the job runner's single per-workspace worker serialises every command it runs, which is what the ledger's cross-process write lock (RFC 0104) requires.

00

Contracts

RFC 0128devlog 151 Rust + Python5 + 10 tests

Phase 0 is pure plumbing — the pieces the browser needs before any pixel. RFC 0127's earlier increment had already landed ekos graph export (the first bulk graph-extraction path in EKOS — every other read is per-object or LIMIT 50), ekos status --json, and the ekos_graph_export MCP tool. This phase added the rest:

latent

The client's NDJSON reader used asyncio's default 64 KiB line limit. It stayed hidden for five phases — every test used fixture-sized workspaces — and surfaced in Phase 5 the first time a real ekos_search produced a >64 KiB response line.


01

Dashboard

RFC 0129devlog 152 10 + 12 tests

Phase 1 makes the console own its inputs. A registered workspace is a directory containing ekos.toml and .ekos/; the console does the rest. The McpSupervisor spawns one ekos mcp serve --tcp per workspace on a loopback port with a fresh 32-byte token, probes it with tools/list before marking it ready, and restarts it with exponential backoff (cap 30 s, five strikes then failed). Two small Rust additions feed the dashboard: R5 ekos doctor --json and R6 ekos ledger timeline --json — cumulative object and relationship counts bucketed by mint time, backend-agnostic, no new KnowledgeStore method.

EKOS Console dashboard: stat tiles, growth and objects-by-kind charts, storage, recent queries, doctor checklist
The dashboard for this repo's own workspace. 20,793 ledger entries · 5,533 objects · 8,364 relationships · 5,045 evidence records, on the fact-segment engine. The charts are Recharts: cumulative growth by day, objects by kind, per-component storage, and the RFC 0114 query-usage log aggregated by tool with p50/p95. The doctor panel is ekos doctor --json rendered as a checklist.
fixed here

ekos status --json had been printing a tantivy log line to stdout ahead of its JSON since it shipped — any consumer doing json.loads would have hit it. Phase 1 routes every machine-output subcommand's logs to stderr.


02

ekos.toml

RFC 0130devlog 153 11 + 12 tests

Getting [observe] wrong is expensive in a way that isn't obvious: the ledger is append-only, so narrowing a path or an ignore-pattern never retroactively removes already-compiled data — the only remedy is a full wipe and rebuild. Phase 2 makes that concrete. R7 ekos config validate reports TOML syntax and deny_unknown_fields errors plus [observe] warnings; R8 ekos config preview-scan counts what ekos build would observe — files by extension, and how many directories each ignore-pattern actually pruned. The editor writes an ekos.toml.bak, and a narrowing save returns the wipe-and-rebuild warning.

found

On its first run against this repo, preview-scan reported that *.lock in the repo's own ignore-patterns pruned zero directories — the pattern is matched as a directory name, not a glob, so it does nothing. This is exactly the class of mistake the feature exists to catch.


03

Command runner

RFC 0131devlog 154 26 tests · 58/58

Phase 3 lets you run EKOS pipeline commands from the browser and watch them stream — the first mutation, so it also brings the read/write role split. A hardcoded COMMAND_ALLOWLIST of 15 entries is the only way to run anything; argv is built from a fixed template plus validated parameters, never interpolated into a shell.

Run page: command cards for doctor, status, ledger-status, graph-export, ekl, build, recover, resolve, compile, commit
The command catalogue. Read commands (doctor, status, ekl with its query field) sit alongside the write-gated pipeline verbs. build, recover, resolve, compile, commit carry a write badge and are disabled for a read principal; recover --parallel and resolve --force render as checkboxes from the parameter schema.

The pipeline command chains the five verbs as one run entry with per-stage status, stopping on the first non-zero exit:

build
observe source systems → artifacts
recover
analyzer passes → Knowledge IR
resolve
identity resolution
compile
semantic compiler → CKM
commit
CKM → append-only ledger

The JobRunner keeps one bounded queue and one worker task per workspace — the single worker serialises everything, which is what the ledger's write lock demands. Output is streamed ANSI-stripped to .ekos-web/runs/<id>.log; the browser follows it over an SSE endpoint that replays existing lines then polls to a terminal status. Cancellation is SIGTERM then SIGKILL. On startup any run left running is swept to interrupted.

Auth — two modes, one cookie

OIDC OIDC_ISSUER set

Authorization Code + PKCE via authlib. A configurable ID-token claim maps to the write role; everyone else authenticated is read. The console holds no passwords.

Two static tokens fallback

CONSOLE_TOKEN → read, CONSOLE_WRITE_TOKEN → write, constant-time compare. For CI, Compose, and local dev where there is no IdP.

Both modes end in a signed session cookie — EventSource can't set an Authorization header, so the SSE log stream needs cookie auth to work at all.

fixed

The chained pipeline opened its log file before the runs directory existed, and final was only bound on the success paths — so the first pipeline run raised, the worker crashed, and the run sat at running forever. Fix: mkdir up front, default final = "failed", wrap the body.


04

Scheduling

RFC 0132devlog 155 10 tests · 68/68

A Schedule row — workspace, command, params, a cron or interval trigger, a required notify_url — fires the same job runner. The row is the source of truth: ConsoleScheduler runs APScheduler in memory and rebuilds it from the enabled rows on every console start, so there is no pickle-based job store to corrupt. Triggers are validated at create time (CronTrigger.from_crontab, UTC) — a bad expression is a 422, never a background crash. misfire_grace_time=1 means a schedule that should have fired during a restart is skipped, not caught up.

On any non-succeeded terminal run — including a QueueFull — the console POSTs {schedule_id, workspace_id, command, run_id, status, detail} to the notify_url. Best-effort, logged, no retry. The one change to Phase 3's runner is a single optional on_done callback on submit.


05

Graph view

RFC 0133devlog 156 4 tests · 72/72

ekos graph export shipped in Phase 0 with nothing that drew it. Phase 5 draws it, in 2D (react-force-graph-2d, canvas — a third the bundle of the three.js build, and the labels stay readable). The renderer is behind a React.lazy boundary: a separate 178 KB chunk that the rest of the console never loads.

Graph overview: one super-node per object kind, sized by count
LOD 0 — the overview. One super-node per object kind (RustSymbol 1829, Section 1347, File 691, …), sized by count, with weighted group edges. The sidebar carries relationship-kind filters (CoupledWith and FeedsInto off by default), a search box, and the kind list. Zoom / pan / fit controls sit bottom-right.

Clicking a super-node drills in. The first cut filtered to that one kind and lost every cross-kind edge — RustModule and File connect almost entirely to other kinds, so they showed zero edges. The rework fetches the whole object graph at a min degree threshold (default 2, ~800 nodes); the focused kind stays bright and every other kind dims to 20% alpha.

LOD 1 drilled into RustModule: the full object graph, RustModule nodes highlighted red, others dimmed
LOD 1 — focus: RustModule. "Showing the 800 most-connected of 1,947." The min-degree slider thins the hairball; the checked relationship kinds (Calls, Contains, DependsOn, References, SameAs) are all present now.
Graph zoomed in with the object panel open, showing properties and 40 relationships for ekos_kir::RelationshipKind
The object panel — the payoff. Clicking a node calls ekos_state: here ekos_kir::RelationshipKind, its ai_overview / ai_evidence_hash properties, and all 40 relationships (DependsOn, References) each linking to the other object. On an object with source evidence, this panel shows one row per claim — path, line, analyzer, confidence, fragment — which is "every conclusion carries its evidence" made visible.

§

How a graph render flows

  1. The browser sends a cookie-authed GET /api/workspaces/self/graph?level=aggregate&group_by=kind.
  2. FastAPI resolves the read principal, looks the workspace up in SQLite, and asks the supervisor for that workspace's pooled MCP client.
  3. The client sends one tools/call line for ekos_graph_export over the NDJSON/TCP socket, holding an asyncio.Lock so one request is on the wire at a time.
  4. ekos mcp serve answers from its cached read-only handle — one whole-store walk over all_objects + all_relationships, collapsed to super-nodes, truncated by degree if over the caps, with the truncation reported in the payload.
  5. The response line (which can exceed 64 KiB — hence the reader-limit fix) is parsed and returned. React maps {s, t} edge indices to node ids and hands the result to the canvas.

§

Status

PhaseRFCDevlogKey modulesNew tests
00 Contracts0128151 mcp.rs R4 · mcp_client.py5 + 10
01 Dashboard0129152 supervisor.py · readproc.py · models.py · R5/R610 + 12
02 ekos.toml0130153 config_io.py · commands/config.rs11 + 12
03 Command runner0131154 auth.py · commands.py · runner.py26
04 Scheduling0132155 scheduler.py10
05 Graph view0133156 Graph.tsx · GraphCanvas.tsx · ObjectPanel.tsx4

The Rust workspace gate (fmt, clippy --workspace, test --workspace, integration) and the web/api pytest suite (72 passing, the EKOS_BIN-gated ones included) both run clean; web/ui typechecks and builds with the graph renderer as a separate lazy chunk. Every phase merged to main after local verification.

HTTP surface

GET/api/healthpublic
·/api/auth/{me, login, callback, logout, token-login}phase 3
·/api/workspacesGET · POST · DELETE
GET/api/workspaces/{id}/{stats, health, stats/timeline, stats/kinds, stats/queries}phase 1
·/api/workspaces/{id}/{config, config/validate, config/preview-scan}phase 2
·/api/workspaces/{id}/{graph, search, objects/<oid>}phase 0 · 5
·/api/commands · /api/workspaces/{id}/commands/<name>phase 3
·/api/runs · /api/runs/<id> · /api/runs/<id>/logs · /cancelSSE
·/api/schedules · /api/schedules/<id>/run-nowphase 4

§

Shipped since this deck

Phase 6 — graph v2 (RFC 0136, devlog_164)

Neighbourhood isolation (real BFS sub-graph via the existing, unmodified ekos_neighborhood at depth 1–3), the impact-mode trace (ekos_impact's hop-distance results as node coloring plus highlighted edges — "the visual form of the claim that currently has none"), a server-side ForceAtlas2 layout (Python networkx + fa2_modified, cached per graph structure) for graphs past ~2,000 nodes, and one-click PNG / glTF export, entirely client-side. Zero Rust-side changes needed — both MCP tools already existed.

Phase 7 — hardening (devlog_165)

Per-route code-splitting (react-router-dom lazy routes, isolating the charting dependency to its own chunk), deep-linkable graph time-travel (?as_of=&focus= plus a one-click "copy link"), a real evidence_count RPC for distributed workspaces, and docker-compose.yml cleanup — the console's last named phase.