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.
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: Sendconstraint 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
mcpSDK. - 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.
Contracts
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:
- R4 — bearer-token auth on
ekos mcp serve --tcp. The first line on an authenticated connection must be aninitializecarryingparams._meta.token; anything else gets a single-32001 unauthorizedand the socket closes before a tool is reachable. Hand-rolled constant-time compare, no new dependency. Token-less--tcpis unchanged. - The Python MCP client —
app/mcp_client.py, ~150 lines, asyncio, no SDK. Framing is one JSON object per line;call_toolunwraps the server's JSON-inside-JSON result; one automatic reconnect on a mid-call EOF. - The
web/skeleton — the FastAPI app factory, a handful of real endpoints proving the wiring end to end, a Vite + React shell, and adocker-compose.yml.
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.
Dashboard
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.
doctor panel is ekos doctor --json rendered as a checklist.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.
ekos.toml
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.
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.
Command runner
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.
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:
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.
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.
Scheduling
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.
Graph view
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.
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.
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.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
- The browser sends a cookie-authed
GET /api/workspaces/self/graph?level=aggregate&group_by=kind. - FastAPI resolves the read principal, looks the workspace up in SQLite, and asks the supervisor for that workspace's pooled MCP client.
- The client sends one
tools/callline forekos_graph_exportover the NDJSON/TCP socket, holding anasyncio.Lockso one request is on the wire at a time. ekos mcp serveanswers from its cached read-only handle — one whole-store walk overall_objects+all_relationships, collapsed to super-nodes, truncated by degree if over the caps, with the truncation reported in the payload.- 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
| Phase | RFC | Devlog | Key modules | New tests |
|---|---|---|---|---|
| 00 Contracts | 0128 | 151 | mcp.rs R4 · mcp_client.py | 5 + 10 |
| 01 Dashboard | 0129 | 152 | supervisor.py · readproc.py · models.py · R5/R6 | 10 + 12 |
| 02 ekos.toml | 0130 | 153 | config_io.py · commands/config.rs | 11 + 12 |
| 03 Command runner | 0131 | 154 | auth.py · commands.py · runner.py | 26 |
| 04 Scheduling | 0132 | 155 | scheduler.py | 10 |
| 05 Graph view | 0133 | 156 | Graph.tsx · GraphCanvas.tsx · ObjectPanel.tsx | 4 |
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
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.