hyperkitty-chromium / ARCHITECTURE.md
SNAPKITTYWEST's picture
Add ARCHITECTURE.md
0088be9 verified
|
Raw
History Blame Contribute Delete
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.