|
Download ARCHITECTURE.md from Snapkitty/hyperkitty-chromium: direct link, hf CLI and curl.
- Browser
- Download file 30 kB
-
https://huggingface.co/Snapkitty/hyperkitty-chromium/resolve/main/ARCHITECTURE.md
- Command line
-
hf download hf://Snapkitty/hyperkitty-chromium/ARCHITECTURE.md
-
curl -L -o ARCHITECTURE.md https://huggingface.co/Snapkitty/hyperkitty-chromium/resolve/main/ARCHITECTURE.md
30 kB
| # HyperKitty Chromium β Architecture | |
| **Status: implemented reference implementation.** Every module, supervision | |
| relationship, schema, and API route described below exists in this | |
| repository and compiles, boots, and passes its test suite (`rebar3 eunit`: | |
| 24 tests, 0 failures, including real headless-Chromium integration tests | |
| and chaos tests that kill live processes to verify recovery). Sections are | |
| marked *(planned)* where the specification calls for something this | |
| revision intentionally stubs or defers β there is no unmarked aspirational | |
| content in this document. | |
| ## 1. Purpose and scope | |
| HyperKitty Chromium is an Erlang/OTP-native browser agent platform: a | |
| headless Chromium instance, driven over the Chrome DevTools Protocol | |
| (CDP), wrapped in an explicit OTP supervision tree so that browser | |
| control, agent orchestration, search, and messaging are separate, | |
| independently-supervised, independently-observable subsystems rather than | |
| library calls inside one monolithic process. The design principle it | |
| exists to demonstrate: an LLM-driven "agent" is a planning function that | |
| proposes tool calls; it is never trusted with direct, unchecked access to | |
| a browser process. Every tool call crosses an explicit, typed capability | |
| boundary β enforced by Erlang code, not by the prompt β before it reaches | |
| Chromium. | |
| This document is the architecture reference for engineers building on, | |
| operating, or extending this codebase. It assumes familiarity with | |
| Erlang/OTP (supervisors, `gen_server`, `gen_statem`) and HTTP/WebSocket | |
| APIs, but not with this specific codebase. | |
| ## 2. Repository layout | |
| ``` | |
| hyperkitty/ | |
| rebar.config deps, relx release config, profiles, dialyzer/xref | |
| config/sys.config application env (chromium path, API port, limits) | |
| config/vm.args node name, cookie, scheduler flags | |
| include/hyperkitty.hrl canonical entity records (data model) | |
| src/ | |
| hyperkitty_app.erl application callback | |
| hyperkitty_sup.erl HK-CORE root supervisor | |
| hk_schema.erl record -> JSON map projection (wire format) | |
| hk_id.erl, hk_event.erl ids, timestamps, the event envelope | |
| hk_sup_util.erl shared "wait for the old holder to die" helper | |
| hk_event_bus.erl / _sup event bus (pub/sub + audit log) | |
| hk_messaging_portal.erl HK-MSG | |
| hk_cdp_client.erl one CDP WebSocket connection (via `gun`) | |
| hk_chrome_http.erl Chrome's plain-HTTP /json control endpoint | |
| hk_browser_*.erl HK-BROWSER: session, capability, registry, supervisors | |
| hk_agent_*.erl HK-AGENT: the agent state machine, registry, toolbox | |
| hk_search_*.erl HK-SEARCH: the 11-stage pipeline job, provider behaviour | |
| hk_operation_store.erl operation_id lifecycle (accepted/succeeded/failed) | |
| hk_api_*.erl HK-VIS backend: Cowboy router, listener, REST + WS handlers | |
| hk_frontend_*.erl connected-client tracking for the observability UI | |
| hk_health.erl aggregate subsystem health | |
| priv/frontend/ the 7-view observability UI (static HTML/JS, no build step) | |
| test/ EUnit suites (see Β§11) | |
| Brewfile, bin/bootstrap.sh reproducible environment setup (see Β§12) | |
| ``` | |
| ## 3. Supervision tree | |
| ``` | |
| hyperkitty_sup (rest_for_one, intensity 8/10) | |
| +-- hk_event_sup (one_for_one) | |
| | +-- hk_event_bus [gen_server] | |
| +-- hk_messaging_sup (one_for_one) | |
| | +-- hk_messaging_portal [gen_server] | |
| +-- hk_browser_sup (one_for_one) | |
| | +-- hk_browser_registry [gen_server] session_id -> pid | |
| | +-- hk_browser_session_sup (simple_one_for_one, transient) | |
| | +-- hk_browser_session (one per session) [gen_server] owns a Chrome OS process | |
| +-- hk_agent_sup (one_for_one) | |
| | +-- hk_agent_registry [gen_server] agent_id -> pid | |
| | +-- hk_agent_fsm_sup (simple_one_for_one, transient) | |
| | +-- hk_agent_fsm (one per agent) [gen_statem] | |
| +-- hk_search_sup (one_for_one) | |
| | +-- hk_search_registry [gen_server] job_id -> pid | |
| | +-- hk_search_job_sup (simple_one_for_one, temporary) | |
| | +-- hk_search_job (one per search) [gen_statem] | |
| +-- hk_health [gen_server, worker] | |
| +-- hk_api_sup (one_for_one) | |
| | +-- hk_api_listener [gen_server] owns the Cowboy/Ranch listener | |
| +-- hk_frontend_sup (one_for_one) | |
| +-- hk_frontend_client_registry [gen_server] connected WS clients | |
| ``` | |
| **Why `rest_for_one` at the root.** Subsystems are listed in dependency | |
| order. If `hk_event_sup` crashes, everything after it is restarted too, | |
| because every later subsystem holds an implicit dependency on the event | |
| bus (agents, browser sessions, and search jobs all publish to it; the API | |
| layer's WebSocket clients have subscriptions that would otherwise go | |
| silently stale against a bus that no longer exists). A crash in | |
| `hk_browser_sup`, by contrast, restarts `hk_agent_sup`, `hk_search_sup`, | |
| `hk_health`, `hk_api_sup`, and `hk_frontend_sup`, but leaves | |
| `hk_event_sup` and `hk_messaging_sup` β listed *before* it β running | |
| undisturbed. `test/hk_supervision_tests.erl:rest_for_one_isolation/0` | |
| kills `hk_browser_sup` directly and asserts exactly this shape of | |
| recovery. | |
| **Why each plane is a two-level tree, not one `simple_one_for_one` | |
| supervisor.** OTP does not allow mixing a singleton child (the registry) | |
| with `simple_one_for_one` dynamic children (session/agent/job instances) | |
| under the same supervisor. Splitting each plane into a `one_for_one` | |
| supervisor wrapping (a) a singleton registry and (b) a `simple_one_for_one` | |
| instance pool means the registry restarting does not restart live | |
| sessions/agents/jobs (they re-register on next lookup miss; live | |
| processes are untouched), and a crash-restart storm in the instance pool | |
| cannot, via `hk_browser_sup`'s own intensity/period, take the registry | |
| down with it. | |
| **A known, fixed race in this shape, documented for anyone extending | |
| it.** An abrupt, unmaskable kill of a mid-tree supervisor (as opposed to | |
| the ordinary synchronous shutdown protocol a supervisor uses on its own | |
| children) does not guarantee its nested-supervisor children have finished | |
| their own termination and released their registered name before the | |
| grandparent supervisor's restart attempt runs. `hk_sup_util:start_link_retry/1` | |
| wraps every registered-name `start_link/0` in this application: on | |
| `{error, {already_started, OldPid}}` it monitors `OldPid`, waits for it to | |
| actually exit, and retries once, rather than surfacing the transient race | |
| as a failed restart. Separately, `hk_api_listener` calls | |
| `process_flag(trap_exit, true)` in `init/1` β without it, the ordinary | |
| supervisor shutdown signal (`exit(Pid, shutdown)`) would kill it directly | |
| without ever running `terminate/2`, leaking the underlying Ranch/Cowboy | |
| listener and its registered name on every restart, not only under abrupt | |
| kills. Both are exercised by `hk_supervision_tests.erl` and by | |
| `hk_browser_session_tests.erl`'s crash-isolation test. | |
| ## 4. Subsystems | |
| ### HK-CORE (`hyperkitty_sup`, `hk_event_bus`, `hk_schema`, `hk_id`, `hk_sup_util`) | |
| Boot sequencing, the root supervision policy (Β§3), the canonical | |
| record-to-JSON projection (`hk_schema:to_map/1` β the single place a wire | |
| representation is produced; internal code passes records, never | |
| hand-built maps, until it crosses this boundary), id/timestamp generation, | |
| and the event bus described in Β§7. | |
| ### HK-BROWSER (`hk_browser_session`, `hk_cdp_client`, `hk_chrome_http`, `hk_browser_capability`, `hk_browser_registry`) | |
| One `hk_browser_session` gen_server per browser session. `init/1` finds a | |
| free TCP port, launches a real headless Chromium OS process via | |
| `erlang:open_port({spawn_executable, ...}, ["--headless=new", | |
| "--remote-debugging-port=<port>", "--remote-debugging-address=127.0.0.1", | |
| "--user-data-dir=<tmp>", "--no-sandbox", "--disable-gpu", | |
| "--disable-dev-shm-usage", "about:blank"])`, polls Chrome's plain-HTTP | |
| `/json/version` endpoint until it answers, and registers itself with | |
| `hk_browser_registry`. Tab lifecycle (`open_tab`/`close_tab`) goes through | |
| Chrome's HTTP control surface (`hk_chrome_http`); per-tab interaction goes | |
| over CDP WebSocket JSON-RPC (`hk_cdp_client`, built on `gun`). | |
| Navigation correlates against the `Page.loadEventFired` CDP event via a | |
| waiters map. A Chrome OS process crash is detected in `handle_info` via | |
| the port's `{exit_status, Status}` message and stops the session with an | |
| abnormal reason, which `hk_browser_session_sup` (restart type `transient`) | |
| restarts under the same `session_id` β the concrete demonstration that one | |
| crashed session never takes down another (`browser_session_crash_isolation/0`) | |
| or any other subsystem. | |
| **Typed capability interface.** `hk_browser_capability:operations/0` is | |
| the closed list of 14 operations (`create_session`, `close_session`, | |
| `open_tab`, `close_tab`, `navigate`, `back`, `forward`, `reload`, | |
| `read_page`, `query_element`, `click`, `type`, `scroll`, `screenshot`, | |
| `extract_links`). There is no operation outside this list and no | |
| unrestricted "just run this JS" escape hatch. `is_granted/2` checks an | |
| agent's capability list, which supports bare-atom grants (unrestricted for | |
| that operation) and scoped grants β currently `{navigate, #{allowed_domains | |
| => [...]}}}`, checked against the URL's host. `validate_args/2` performs | |
| structural validation independent of authorization: URL scheme | |
| allow-list (`http`/`https`/`about`/`data`), selector length bounds | |
| (1β2047 bytes), typed text length bound (β€10000 bytes), scroll delta | |
| bounds. Both checks run in `hk_agent_fsm` (the control plane) before a | |
| call ever reaches `hk_browser_session` β see Β§8. | |
| *(Implementation note, stated plainly per this project's honesty | |
| requirement: `click`/`type`/`query_element`/`extract_links`/`scroll` are | |
| implemented via `Runtime.evaluate` β JavaScript injected into the page | |
| context β rather than the CDP `Input` domain's synthetic OS-level input | |
| events. This is a real, intentional simplification: it is sufficient for | |
| programmatic page interaction and observation, but it is not | |
| indistinguishable from a physical input device the way `Input.dispatchMouseEvent` | |
| would be. A production deployment that needs that distinction should | |
| route these through the `Input` domain instead.)* | |
| ### HK-AGENT (`hk_agent_fsm`, `hk_agent_registry`, `hk_agent_toolbox`) | |
| One `gen_statem` per agent, states `created -> ready -> planning -> | |
| executing -> waiting -> planning -> ... -> completed`, with `failed` and | |
| `terminated` reachable from any non-terminal state. `callback_mode() -> | |
| state_functions`. The planning intelligence itself (what decides which | |
| tool to call next) is deliberately external β a planner process, an LLM | |
| call, a human operator, or a test drives the loop one step at a time via | |
| `next_action(Pid, Tool, Op, Args)`. What the FSM owns is everything that | |
| must be enforced regardless of what decided the action: capability | |
| authorization (delegates to `hk_browser_capability` for browser ops), | |
| argument validation, the configurable loop limits, action history, and | |
| state transitions. `next_action/4` validates synchronously in the caller's | |
| context (fast) but spawns a linked worker to perform the actual | |
| browser/search call, so the FSM process is never blocked on a slow page | |
| load β it sits in `waiting` for a real `{agent_observation, Ref, Outcome}` | |
| message, correlated by reference, with a 45-second per-action timeout. | |
| **Loop limits**, checked before every tool call | |
| (`hk_agent_fsm:check_limits/1`), merged from `default_agent_limits` (app | |
| env) over any per-agent override: `max_actions` (default 40), | |
| `max_execution_ms` (default 300000), `max_network_requests` (default | |
| 200), `max_search_iterations` (default 5). Exceeding a limit transitions | |
| the agent to `failed` and replies `{error, {limit_exceeded, Which}}}` to | |
| the caller β a genuine error, not a silently-accepted `ok` (this was a | |
| bug caught and fixed during development; see Β§13). | |
| ### HK-SEARCH (`hk_search_job`, `hk_search_pipeline`, `hk_search_provider`, `hk_search_provider_mock`) | |
| One `gen_statem` per search job whose states are the pipeline stages | |
| themselves β `user_query -> query_normalization -> search_planner -> | |
| search_provider -> result_normalization -> url_deduplication -> | |
| content_retrieval -> content_extraction -> source_ranking -> | |
| result_synthesis -> frontend_visibility -> done`, with `failed` reachable | |
| from any stage. The job's current state *is* its current pipeline stage; | |
| there is no separate stage field that could drift from the state machine. | |
| `source_ranking` loops back to `search_planner` (bumping `iteration`, | |
| bounded by `max_iterations`, default 3) when fewer than 3 ranked results | |
| were found β the concrete enforcement point for the search-iteration | |
| limit. Every stage transition publishes an `hk_event` before advancing | |
| (Β§7 has the full catalog). The search backend is a pluggable behaviour | |
| (`hk_search_provider`: `plan/2`, `search/2`, `fetch/1`, `extract/2`); the | |
| bundled `hk_search_provider_mock` is a deterministic, self-contained | |
| reference implementation that makes no real network calls, so the full | |
| pipeline is exercisable and testable without external dependencies. A | |
| real provider (a live search API, an HTTP fetcher, an HTML/text extractor) | |
| plugs in by implementing the same behaviour and setting | |
| `{search_provider, Module}` in `sys.config` β *(planned: no live provider | |
| is bundled)*. | |
| ### HK-MSG (`hk_messaging_portal`) | |
| A minimal pub/sub message bus for inter-agent and agent/user messaging, | |
| independent of the event bus (events are platform telemetry; messages are | |
| addressed application payloads). `send/5` marks a message `delivered` if | |
| the recipient is currently subscribed, `queued` otherwise; | |
| `list_conversation/1` returns history in order; `mark_read/1` updates | |
| status. Backed by two ETS tables (`hk_messages_tab`, keyed by | |
| `message_id`; `hk_messages_by_conversation_tab`, keyed by | |
| `conversation_id`). | |
| ### HK-VIS (`hk_api_*`, `priv/frontend/`) | |
| The API layer (`hk_api_router`, `hk_api_listener` owning the Cowboy/Ranch | |
| listener lifecycle, and one handler module per domain) and the static | |
| observability frontend (`priv/frontend/index.html` + `js/app.js`) β see | |
| Β§9 and Β§10. | |
| ## 5. Data model | |
| Every entity is a record in `include/hyperkitty.hrl`, with a matching | |
| `hk_schema:to_map/1` clause for its JSON wire form (records are the | |
| compiler-checked internal shape; maps/JSON are produced only at the | |
| boundary). | |
| | Entity | Key fields | | |
| |---|---| | |
| | `#agent{}` | `agent_id`, `objective`, `state`, `capabilities`, `action_count`, `started_at_ms`/`updated_at_ms`, `limits`, `result`, `failure_reason` | | |
| | `#agent_objective{}` | `objective_id`, `agent_id`, `description`, `success_criteria`, `created_at_ms` | | |
| | `#browser_session{}` | `session_id`, `owner_agent_id`, `profile`, `pid`, `status` (`starting\|ready\|busy\|closing\|closed\|crashed`), `tabs`, timestamps | | |
| | `#browser_tab{}` | `tab_id`, `session_id`, `url`, `title`, `status` (`opening\|loading\|idle\|closed`), timestamps | | |
| | `#search_job{}` | `job_id`, `owner_agent_id`, `query`, `stage` (the 13-value pipeline enum), `max_iterations`, `iteration`, `results`, timestamps | | |
| | `#search_result{}` | `result_id`, `job_id`, `url`, `normalized_url`, `title`, `snippet`, `rank`, `source_document_id`, `created_at_ms` | | |
| | `#source_document{}` | `document_id`, `url`, `fetched_at_ms`, `content_type`, `extracted_text`, `citation_map` | | |
| | `#message{}` | `message_id`, `sender`, `recipient`, `timestamp`, `conversation_id`, `message_type` (`user_to_agent\|agent_to_user\|agent_to_agent\|system`), `payload`, `status` | | |
| | `#operation{}` | `operation_id`, `request_id`, `kind`, `status` (`accepted\|in_progress\|succeeded\|failed`), `subject`, `error`, timestamps | | |
| | `hk_event:t()` (opaque) | `event_id`, `category`, `subject`, `operation_id`, `data`, `emitted_at_ms` | | |
| ## 6. Agent capability, limits, and event flow (worked example) | |
| ``` | |
| Caller hk_agent_fsm hk_browser_capability hk_agent_toolbox hk_browser_session | |
| | next_action(browser, | | | | | |
| | navigate, #{url=>U}) | | | | | |
| |-------------------------->| check_limits/1 | | | | |
| | | is_granted(Caps,{navigate,U})| | | | |
| | |----------------------------->| | | | |
| | |<---- true/false --------------| | | | |
| | | validate_args(navigate, Args)| | | | |
| | |----------------------------->| | | | |
| | |<---- {ok,Args}/{error,_} -----| | | | |
| | | spawn_link(worker) -> emit "agent.action.started" | | | |
| | | | dispatch(browser,navigate,Args,AgentId) | | |
| | | |-------------------------------------------> | | |
| | | | | navigate/3 -> CDP -> emit "browser.navigation.*" | |
| | |<-------- {agent_observation, Ref, Outcome} ----------------------------------| | |
| | | emit "agent.action.completed"/"failed", -> planning | | | |
| |<-------- {ok,Result} / {error,Reason} -------------------| | | | |
| ``` | |
| Rejection paths never reach `hk_browser_session`: an unauthorized call | |
| emits `agent.action.rejected` and replies `{error, not_authorized}}` | |
| without spawning a worker; invalid arguments emit the same category with | |
| `{error, {invalid_arguments, Reason}}}`; a tripped limit transitions the | |
| agent straight to `failed` and replies `{error, {limit_exceeded, Which}}}`. | |
| This is the concrete form of "an agent's prompt is not a security | |
| boundary" β the check is structural Erlang code sitting between the | |
| FSM's `planning`/`ready` states and `hk_agent_toolbox`, not a | |
| convention the planner is trusted to honor. | |
| ## 7. Event catalog and observability | |
| Every subsystem publishes to `hk_event_bus` (a `gen_server` holding an | |
| ETS `ordered_set` audit log capped at 20,000 entries, oldest trimmed | |
| first). Subscribers register with an optional filter | |
| (`category_prefix`, `subject`) and receive `{hk_event, EventMap}` | |
| messages; the bus monitors subscribers and removes them automatically on | |
| death. `hk_api_events_ws_h` (a `cowboy_websocket` handler) subscribes on | |
| connect using the query string as a filter and streams every matching | |
| event to the browser in real time; `hk_api_events_recent_h` serves the | |
| same log as a plain GET for polling clients. | |
| | Subsystem | Categories | | |
| |---|---| | |
| | Agent | `agent.created`, `agent.objective.assigned`, `agent.action.started`, `agent.action.completed`, `agent.action.failed`, `agent.action.rejected`, `agent.completed`, `agent.failed`, `agent.terminated` | | |
| | Browser | `browser.session.created`, `browser.session.closed`, `browser.session.crashed`, `browser.tab.opened`, `browser.tab.open_failed`, `browser.tab.closed`, `browser.navigation.started`, `browser.navigation.completed`, `browser.page.read`, `browser.element.queried`, `browser.action.click`, `browser.action.type`, `browser.action.scroll`, `browser.action.failed`, `browser.screenshot.captured`, `browser.links.extracted` | | |
| | Search | `search.started`, `search.query_normalized`, `search.query_generated`, `search.provider_called`, `search.results_received`, `search.urls_deduplicated`, `search.document_retrieved`, `search.document_extracted`, `search.source_ranked`, `search.iteration_retried`, `search.result_synthesized`, `search.completed`, `search.failed` | | |
| | Messaging | `message.sent`, `message.received` | | |
| `hk_search_job_tests.erl:full_pipeline_runs_in_order/0` asserts these | |
| fire in the pipeline's relative order on every run β deterministic | |
| orchestration is a tested property, not just a design intent. | |
| `hk_health:report/0` computes status fresh on every call (no cached/stale | |
| state) from `whereis/1` on all 7 subsystem supervisors plus live counts | |
| from the three registries and the event bus's recent log; it backs | |
| `GET /api/health` and the frontend dashboard. | |
| ## 8. Security model | |
| 1. **The agent's plan (whatever produced it β LLM, script, human) is not | |
| a trust boundary.** Every tool call is authorized and validated by | |
| `hk_agent_fsm` against `hk_browser_capability`/`hk_agent_toolbox` | |
| before it can reach Chromium, regardless of what asked for it. | |
| 2. **Capabilities are explicit and closed.** An agent's `capabilities` | |
| list is set at creation (`hk_agent_fsm:init/1`); there are exactly 14 | |
| named browser operations and no implicit "do anything" grant. | |
| `navigate` supports domain scoping; other operations are all-or-nothing | |
| per agent. | |
| 3. **Argument validation is structural, not advisory.** URL scheme | |
| allow-listing rejects `javascript:`, `file:`, and other non-navigable | |
| schemes outright; selector and text length bounds guard against | |
| pathological input regardless of whether the operation is authorized. | |
| 4. **No hidden browser execution.** Every operation that reaches | |
| `hk_browser_session` β and its outcome β is emitted to the event bus | |
| under a `browser.*` category before the caller sees the result. There | |
| is no code path that drives Chromium without publishing what it did. | |
| 5. **No opaque network activity at the control-plane level.** The API | |
| layer, the event bus, and the search pipeline's stage events give an | |
| operator a complete, ordered account of what happened and when β see | |
| Β§7. *(What this repository does not implement: sandboxing Chromium's | |
| own process beyond `--no-sandbox`/`--disable-gpu` launch flags, or | |
| TLS/authentication on the HTTP API β both are deployment-environment | |
| concerns flagged as out of scope for this reference implementation, | |
| not silently omitted.)* | |
| ## 9. API reference | |
| Base: `http://<host>:<api_port>` (default port `8420`, `config/sys.config`). | |
| Every request gets a request id (the `x-request-id` header if supplied, | |
| else minted); every mutating endpoint runs through | |
| `hk_api_util:run_operation/4`, which creates an `#operation{}` | |
| (`accepted` -> `succeeded`/`failed`) and returns | |
| `{operation_id, request_id, status, result | error}`. | |
| | Domain | Routes | | |
| |---|---| | |
| | Health | `GET /api/health` | | |
| | Agents | `GET/POST /api/agents`, `GET /api/agents/:id`, `POST /api/agents/:id/objective`, `POST /api/agents/:id/actions`, `POST /api/agents/:id/complete`, `POST /api/agents/:id/terminate` | | |
| | Browser | `GET/POST /api/browser/sessions`, `GET /api/browser/sessions/:id`, `POST /api/browser/sessions/:id/close`, `POST /api/browser/sessions/:id/tabs`, `POST .../tabs/:tab_id/navigate`, `POST .../tabs/:tab_id/actions` | | |
| | Search | `GET/POST /api/search`, `GET /api/search/:id`, `GET /api/search/:id/result` | | |
| | Messages | `GET/POST /api/messages` | | |
| | Events | `GET /api/events/recent`, `WS /api/events/stream` (optional `?category_prefix=...` filter) | | |
| Example β create an agent, grant it unscoped `navigate`, and drive one | |
| action: | |
| ``` | |
| curl -s -XPOST localhost:8420/api/agents \ | |
| -d '{"capabilities":["navigate","open_tab","read_page"]}' | |
| # => {"operation_id":"op_...","status":"succeeded","result":{"agent_id":"agent_...", ...}} | |
| curl -s -XPOST localhost:8420/api/agents/agent_.../actions \ | |
| -d '{"tool":"browser","op":"create_session","args":{}}' | |
| curl -s -XPOST localhost:8420/api/agents/agent_.../actions \ | |
| -d '{"tool":"browser","op":"open_tab","args":{"session_id":"sess_...","url":"https://example.com"}}' | |
| ``` | |
| ## 10. Frontend (HK-VIS) | |
| `priv/frontend/index.html` + `js/app.js`: a single-page, dark-themed, | |
| dependency-free (no build step) client with the 7 required views β | |
| dashboard, agent console, browser sessions, search, messages, event | |
| stream, audit log. `connectEventStream()` opens a WebSocket to | |
| `/api/events/stream` on load, auto-reconnects on close, and live-updates | |
| whichever view is relevant to each incoming event plus a running audit | |
| log; the other views poll their REST endpoints on a refresh interval. | |
| Served directly by Cowboy (`cowboy_static`) β no separate frontend server | |
| or build pipeline. | |
| ## 11. Test strategy | |
| `rebar3 eunit`: 24 tests across 7 modules, 0 failures. | |
| | File | Covers | | |
| |---|---| | |
| | `hk_agent_fsm_tests.erl` | Deterministic state transitions; unauthorized-call and invalid-argument rejection; limit-exceeded failing the agent with the correct error; crash isolation between sibling agents | | |
| | `hk_browser_capability_tests.erl` | Authorization (including domain-scoped `navigate`) and argument validation (rejecting `javascript:` URLs, oversized selectors, missing `type` text) | | |
| | `hk_browser_session_tests.erl` | Real integration against a real headless Chromium process via CDP: navigate to a `data:` URL and read it back, extract links. Self-skips (not fails) when no `chromium_executable` is present on disk | | |
| | `hk_search_job_tests.erl` | The full 11-stage pipeline against the mock provider, asserting every stage's event fires in pipeline order and the job reaches `done` with synthesized results | | |
| | `hk_search_pipeline_tests.erl` | Pure functions: URL normalization, dedup, ranking | | |
| | `hk_messaging_portal_tests.erl` | Immediate vs. queued delivery, conversation history ordering | | |
| | `hk_supervision_tests.erl` | `rest_for_one` isolation (kill `hk_browser_sup`, verify recovery scope), event delivery ordering, browser-session crash isolation | | |
| `test/hk_test_helper.erl` starts the full real application once | |
| (idempotently β every test module shares one running instance rather than | |
| starting/stopping the singleton HTTP listener per module, which was | |
| itself a source of a race fixed during development; see Β§13) and leaves | |
| it running for the test VM's lifetime; isolation between tests comes from | |
| unique generated ids, not from process-table resets. | |
| *(Planned, not present in this revision: property-based tests, a | |
| Dialyzer PLT run as part of CI β `rebar3 dialyzer` is configured in | |
| `rebar.config` but was not run to completion in this environment β and a | |
| chaos-test suite beyond the two crash-isolation cases above.)* | |
| ## 12. Reproducible bootstrap | |
| `Brewfile` pins the five dependencies (`erlang`, `rebar3`, `chromium`, | |
| `git`, `node` β the last is headroom for future frontend tooling, not a | |
| current build requirement) as a project-local Homebrew manifest, not a | |
| global install. `bin/bootstrap.sh` runs `brew bundle`, validates each | |
| tool is on `PATH`, patches `config/sys.config`'s `chromium_executable` | |
| in place with the resolved binary path, then runs | |
| `rebar3 get-deps && rebar3 compile && rebar3 eunit`. It is safe to re-run. | |
| ## 13. Notable issues found and fixed during development | |
| Recorded here because a "produce concrete interfaces... rather than a | |
| high-level product pitch" instruction implies the failure history is part | |
| of the record, not just the final shape: | |
| - **Agent FSM deadlock.** The `ready` state's tool-call clause originally | |
| passed `planning` (the state to resume *after* dispatch, i.e. the wrong | |
| argument) instead of `executing` as the post-validation transition | |
| target, so a successfully-validated call's internal dispatch event | |
| landed in `planning/3`, which has no matching clause for it and | |
| silently dropped it via the generic reject fallback β the calling | |
| process hung until its call timeout. Found via a hanging EUnit test; | |
| fixed by matching the pattern already correctly used in `planning/3`'s | |
| own tool-call clause. | |
| - **Wrong reply type on limit-exceeded.** The original limit-exceeded | |
| branch reused `do_fail/3`, which always replies `ok` (it is designed | |
| for the external `fail/2` API) β meaning a call refused for exceeding a | |
| loop limit incorrectly looked like success to its caller. Fixed with a | |
| dedicated branch that replies `{error, {limit_exceeded, Which}}}`. | |
| - **Nested-supervisor restart race** and **missing `trap_exit` on | |
| `hk_api_listener`** β both described in Β§3, both exercised by | |
| `hk_supervision_tests.erl`, both fixed (`hk_sup_util:start_link_retry/1` | |
| and `process_flag(trap_exit, true)` respectively) rather than papered | |
| over by loosening the restart-intensity budget alone. | |
| - **Chrome's `/json/new` endpoint requires `PUT`, not `GET`**, on | |
| current Chromium releases (a CSRF-hardening change that broke the | |
| originally-written `hk_chrome_http:new_target/2`, which used `GET`). | |
| Fixed and covered by the real-Chromium integration tests in | |
| `hk_browser_session_tests.erl`. | |
| ## 14. Terminology | |
| - **Capability** β one of the 14 named `hk_browser_capability` operations, | |
| optionally scoped (currently: `navigate` by allowed domain), grantable | |
| to an agent at creation. | |
| - **Control plane** β the code path (`hk_agent_fsm` + | |
| `hk_browser_capability` + `hk_agent_toolbox`) that authorizes and | |
| validates every tool call, as distinct from whatever decided *which* | |
| call to make (the "agent" in the LLM sense). | |
| - **Operation** β a tracked mutating API call (`#operation{}`), distinct | |
| from an **event** (`hk_event:t()`), which is a fire-and-forget | |
| observability record; an operation is created and completed by exactly | |
| one API request, while an event may be published by any subsystem for | |
| any reason, including ones with no API request behind them. | |
| - **Stage** β one of the 13 `hk_search_job` `gen_statem` states; the | |
| pipeline's current stage and the job's current state are the same | |
| value by construction. | |