guide/03-live-activity.md
Live Activity: connection, data, pipeline, states, asks
Standing: Phases 1–3 are built (2026-11-11): the connection, the data model, the ladder and Dev › Source, the stream core held to qdash by test vectors, and the page, Dev › Live Activity, with its seven improvements. The connection is crates/nozzle (sse.rs, stream.rs, scenario.rs). The data model is crates/types (live.rs, events.rs, monitor.rs). The stream core is crates/stream (ingest.rs). Phase 2 adds the ladder on the Sensor page and the doctor; Phase 3 builds the page. Each section says which parts are built. Facts about qcontrol are read from origin/main 9e6cd55a0 (2026-11-10) and name the file; anything guessed is marked guess.
Live Activity is the firehose: every event the sensor sends, raw, with filters, a tally and the payload. It sits in the Dev group. Events reads the same stream and shows it as activities: a turn, a tool call with its decision and result, a finding. Events is an ordinary page that everyone has. One stream, two altitudes. (Not to be confused with Activity under This machine, c5's sessions over time.)
1. How the app reaches a sensor
This machine (the default)
The app reads one file, then speaks HTTP over loopback.
- The lock file.
<data dir>/dash/qdash.sock.lockholds two lines: the dashboard's pid, then its URL (read:cli/src/commands/monitor/ui/lock.rs:26-30, 84-90). Read the URL rather than assuming port 7878; the dashboard falls back to an ephemeral port when 7878 is taken.- The data dir on macOS is
~/Library/Application Support/com.qpoint.qcontrol; on Linux,~/.local/share/qcontrol(read:crates/qconfig/src/lib.rs). QCONTROL_DATA_DIRmoves the data dir, andQCONTROL_DASH_SOCKET_PATHmoves the socket. The lock file sits beside the socket.- Phase 2 fixed the app's copy of the rule on macOS. It had looked in
…/Application Support/qcontrol. Windows still differs; see page 2.
- The data dir on macOS is
- The stream.
GET /api/events/streamon that URL (read:crates/qdash/src/web.rs:62, 295-317).
At an address (a Linux box, a VM, a container)
The sensor answers only on loopback, and it has no login. So an address means the near end of an SSH tunnel, never a port opened to the network:
ssh -N -L 7878:127.0.0.1:<the sensor's port> <host>
Then attach to http://127.0.0.1:7878. The sensor's port is the second line of the lock file on that host. The dashboard's host guard refuses any request whose Host is not localhost, 127.0.0.1 or [::1] with a 403 (read: web.rs:159-200). A tunnel passes that guard. An opened port does not, and the app says so.
What the app sends and does (built: nozzle::stream::connect)
GET /api/events/stream HTTP/1.1withHost: <the address's host:port>andAccept: text/event-stream. HTTP/1.1 is spoken directly over the socket, so the read timeout, the Host header and the refusal bodies are the app's to handle. The body is decoded from chunked transfer encoding.- Frames are parsed to the WHATWG event-stream rules (
nozzle::sse): CR, LF or CR LF line ends, including a CR LF split across reads;data:lines joined; a comment is a keep-alive. qdash sends one JSON record perdata:line and a:comment every 15 s. - Reconnecting follows the web viewer's schedule: 0.5, 1, 2, 4, 8 s, then every 8 s for ever. A stream that was open starts the schedule over. Each attempt says why the last one ended.
- Leaving the page closes the connection. The reader wakes every 250 ms. When the page has dropped its subscription, the reader closes the socket and its thread ends (tested: closed within one tick). A feed exists only while its page is open.
The ladder (each rung is a state the Sensor page draws)
| Rung | The check | Says | Built |
|---|---|---|---|
| Not found | no lock file, or a stale one (this machine only) | no sensor on this machine | built (Phase 2): nozzle::ladder, on Dev › Source and --check-sensor |
| Found | the lock file's pid is alive (this machine only) | installed and running | built (Phase 2) |
| Answering | GET /api/stats returns 200 | connected; the events it has seen (no version is served) | built (Phase 2) |
| Streaming | the stream is open, keep-alive under 15 s old | live; events a second, the last event's age | Open and the keep-alive are built; the rate and age are Phase 3 |
| Lost | was open, now retrying | reconnecting, with the attempt and the reason | built: Retrying { attempt, reason, retry_in } |
| Refused | 403 (host guard) or 503 (no live hub) | reachable but not serving; what to change | built: Refused { status, reason }, the reason taken from the sensor's own body |
2. The data
The envelope (read from code)
The source is types/live.ts, generated into crates/types/src/live.rs by scripts/ts-to-rust.py --from, the same mechanism qdash's routes will use (c131 D17). Each field's doc says where it was read.
export interface StreamRecord {
timestamp: string // RFC 3339, 9 fractional digits, Z (qevents timestamp.rs:16)
severity?: Severity // always written; absent reads as info
entity_id?: string // "pid:<pid>:start:<unix secs>"; absent on a host record
type: string // "<family>.<name>"
payload?: unknown // shaped per type; decoded on demand
run?: RunContext // legacy run envelope, tolerated, never required
}
- Entity or host. A record without
entity_idis a host record. The host records areinstallation.*(five types) andprocess.snapshot, whose entries each carry their ownentity_id. Everything else belongs to one process lifetime (read:crates/qevents/src/public.rs:30-60). - Severity. It is written on every record. Each type has a default (
public.rs:224): most areinfo;connection.mitm_success,websocket.trace,llm.provider_*andrun.plugin_load_successaredebug;connection.intake_fallbackandrun.adapter_errorarewarn;connection.mitm_failure,connection.proxy_error,run.plugin_load_failureandinstallation.tap_errorareerror. Debug records are not emitted at all unless the sensor's log level is debug. - Tolerant. The app reads an unknown type, an unknown field, and a payload it cannot decode, and each is still a row. The sensor and the app will not always be the same version, and qevents' own enums are strict about unknown variants.
The families (read from code)
| Family | Types |
|---|---|
| process | started, stopped, snapshot |
| installation | discovered, details, snapshot, tap_result, tap_error |
| agent | message, tool_call, tool_decision, tool_result |
| llm | request, response, usage, rate_limit, provider_matched, provider_unmatched |
| mcp | request, response, error, notification, session_close, diagnostic, oauth |
| file | open, read, write, close |
| exec | spawn, exit |
| connection | open, update, close, mitm_success, mitm_failure, proxy_error, intake_fallback |
| http | request, response, exchange_close |
| sse | open, event, close |
| websocket | open, send, recv, close, trace |
| run | adapter_error, plugin_load_success, plugin_load_failure |
| netstat | snapshot |
| plugin | plugin.event |
types::events::FAMILIES carries this table. file.* is the volume family.
The payloads (read from code)
Every type's payload, field by field, with optional versus always-present-but-null, the tagged enums, and file:line, is in ../research/qevents-payloads.md. The synthetic stream scripts are written to it.
Correlation keys (what Events joins on; read from code)
| Activity | Joined by |
|---|---|
| a process and everything it did | entity_id (and each process.snapshot.payload.processes[].entity_id) |
| an installation | installation.*.payload.id = process.started.payload.installation_id |
| a session of a harness | payload.context.session_id (agent, llm), payload.session.session_id (mcp) |
| a tool call, its decision and its result | call_id |
| a model request and its response | request_id |
| an HTTP exchange and its stream | exchange.id = sse/websocket.*.stream.http_exchange_id |
| a connection | connection.id = http.*.exchange.connection_id |
| one file session | file.*.payload.file.id |
| a child process | exec.spawn.payload.id = exec.exit.payload.id |
The monitor's shapes (proposed)
The Monitor page's two routes of its own, workers with their lifecycle and the process tree, are in types/monitor.ts, generated into crates/types/src/monitor.rs. Its health, resources, log and loaded targets and behaviors ride on c131 D17's /api/sensor and /api/config (reconciled 2026-11-12, so each route is asked for once). None is served today. Each field marks whether the monitor already holds that data in its in-process view (read) or not (proposed). Page 4 has the rest.
What is read, and what is guessed
| Read from code | Guessed | |
|---|---|---|
| The envelope, families, payloads, severities, correlation keys | qevents | none |
| Framing, keep-alive, 403/503, the 1,024 buffer, the resync on connect | qdash web.rs, hub.rs, the monitor's inventory state | none |
That mcp exchange_id shares its id space with http.*.exchange.id | not stated in code | guess |
That a tap skip is written as warn | a doc comment says so; no code sets it | guess |
The synthetic streams' values, mix and rates (fixtures::streams) | the shapes are read | guess: the content is invented. A recording of a real session replaces it (page 6). |
| The monitor's four routes | the data behind each field is read | guess: the shapes are ours to propose |
3. The pipeline
Built in Phase 1:
sensor ── SSE ──▶ nozzle::sse ──▶ Subscription ──▶ the page ──▶ stream::ingest
(frames) (a channel of (awaits it; no (qdash's LogEvent,
1,024 items: timer, so idle held to qdash by
synthetic ── fixtures::streams ─▶ play() costs nothing) test vectors)
- One subscription per open page, through
Repo::subscribe(). A page never knows whether the sensor or a script is behind it (c131 R1, R2). The plane serves no stream of this machine's events and says so (ruling 3). - Backpressure, not loss, inside the app. When the page falls behind, the producer waits. Against the sensor, this means qdash's 1,024-record buffer fills and qdash cuts the app, exactly as it cuts a browser. That is the "Lost" rung, and ask 2 below would let the app say how much was lost.
- Records stay text until they are needed. The subscription carries each frame's JSON as sent. Phase 3 keeps a ring of raw frames and parses on demand.
- Held to the web by test.
scripts/live-vectors.shgenerates a corpus from the stream scripts plus edge lines (not JSON, not an object, no type, a legacy run envelope,observed_atandsequence, an unknown severity). It runs qdash's owningestEventandparseRunLogover the corpus in node, bundled read-only from the qcontrol checkout, and writescrates/stream/vectors/ingest.json. The Rust port agrees with all 615 vectors and with the run log's sort order. The vectors already carry qdash's previews, summaries, run and process summaries, ready for Phase 3's port.
Built in Phase 3:
- The buffer (
stream::buffer): a ring of raw frames, bounded by count and by the bytes on the wire. The default is 20,000 events and 32 MiB, and the person can choose 5k, 20k or 100k.- A light head per frame (type, family, severity, entity, session, time) skips the payload. Over every corpus line it agrees with ingest.
- Facets (family, type, severity, process, session) and the filtered view are kept as frames arrive and leave.
- Full rows are built only for the rows on screen.
- Parity with qdash's
lib/live(crates/stream), held by cross-language vectors thatscripts/live-vectors.shregenerates byte-identical:- ingest and previews (614 records: summary, preview, run, process);
- presence (99 checkpoints, 256 cards, all four statuses);
- the tally (63 views; 126 CSVs, byte for byte);
- entity-meta and the table projections (filters, counts, trees, chips, rows).
- Web quirks are kept for parity and marked
web quirk, kept for parity:in the code.
- Rows: the page also builds a full row for every arriving record, because presence, the entity index and qdash's tally read every one, as the web's do.
4. The states (what the person sees; drawn on Live Activity since Phase 3)
| State | From | Says |
|---|---|---|
| Connecting | Connecting { attempt } | connecting, and the attempt after the first |
| Live | Open, records or keep-alives | live; events a second; the last event's age |
| Reconnecting | Retrying { attempt, reason, retry_in } | why it closed, the attempt, when the next one is |
| Refused | Refused { status, reason } | the sensor's own sentence (403: the host guard; 503: a static qdash serve) and what to change |
| Not running | NotRunning { reason } | no sensor on this machine, and where the app looked |
| Ended | Ended | a finite recording played to its end |
| Filtered to nothing | the page's filters | the filters, and a way to clear them |
5. What the app does not need
The web page this replicates still expects fields that the sensor stopped sending. The native pages are written against the envelope above and need none of these:
sequenceandobserved_at. The app numbers frames as they arrive and says so. Both are read if present, as the web reads them, so the normal form agrees.- The
runenvelope. Attribution is byentity_id. - Retired types:
run.ended,agent.classified,auto_tap.*and others.process.stoppedis the end signal. POST /api/agent/focusand/api/agent/reveal. A native app can bring a window forward or reveal a folder itself.
6. What is shown, and what is masked (built in Phase 3: stream::mask)
installation.details is documented as secrets-bearing (scan.rs:236-238). Live Activity masks its mcp_servers[].env and headers values before showing or copying them, and offers a deliberate reveal.
The code does not document these as secret, but they carry user content and are masked the same way until the sensor's engineer says otherwise:
- HTTP headers;
- MCP
params,resultanderror.data; - tool arguments and output;
llm.request.system_instructions;exec.spawn.argvandprocess.started.argv.
A masked value reads •••• masked (N chars) everywhere a row shows it: summary, preview and JSON. Reveal shows them on that page until it is masked again, and Copy JSON copies what is shown.
Ids that are u64 on the wire (exchange_id, connection.id) can exceed 2^53. The web page loses precision on them; the app parses them as 64-bit integers and does not.
7. Asks of the sensor
The app builds on today's stream as it is. These are recorded, not waited on. Each has why it matters and what we would accept if the answer is "not now".
| # | Ask | Why | We would accept |
|---|---|---|---|
| 1 | A cursor. An id: on each frame, and a way to resume from one (Last-Event-ID or ?since=), from a short ring in the hub. | A reload, a restart or a lag currently loses events silently. Events cannot be trusted as a record without it. The app's parser already keeps id:. | No cursor, with ask 2. |
| 2 | A gap marker. When a subscriber lags, a frame that says how many records were dropped, in place of a bare disconnect. | The person should see "312 events missed", not a clean-looking list with a hole in it. | The count on the next connect. |
| 3 | A filter on the server. ?families=, ?min_severity=, ?entity_id=. | Events needs a fraction of the firehose. Sending all of it costs the sensor serialisation and the app parsing. | None; the app filters and coalesces. |
| 4 | A snapshot for the new subscriber only. | Today every connect makes the monitor re-scan installations and broadcast two snapshots to everyone. An app that opens and closes a page pays that each time, and so do the other subscribers. | The broadcast, kept throttled. |
| 5 | The id spaces, stated. Does mcp.*.exchange_id share its space with http.*.exchange.id? | Events would join an MCP call to its HTTP exchange. | A "no", and the app keeps them apart. |
| 6 | A key that forbids developer mode on a company's machines, whatever the build (review stop 0, call 8: proposed). Where would it live in qconfig, and may the app read it? | A company that ships the full build can still say no. | The lean build, which carries no developer mode at all. |
Asks 1 and 4 touch the sensor's own cost, which is the point of Footprint.