guide/04-monitor.md
Monitor: the contract
Standing: "Where it comes from today" is read from qcontrol main (2026-11-10). The shapes and routes are proposals; the names are ours to change.
The Monitor page replicates the monitor TUI (qcontrol start --tui, cli/src/commands/monitor/ui/terminal/). The TUI can show what it shows because it is the monitor process. The app is another process, so it can show only what the sensor serves. This page lists what that is, view by view.
What each view needs, and whether it can be read today
| View | Shows | Where the TUI gets it | Readable from another process today |
|---|---|---|---|
| Overview | live workers, throughput per worker, latest events, health, the event mix of the last 60 s, targets | the MonitorView snapshot and the event route | events: yes. Workers, health, last error: no |
| Qcontrol | the monitor's process tree with CPU and memory, history, averages over 1, 5, 10 and 30 min | sysinfo, sampled on the TUI's own thread at 1 Hz, 1,800 samples per process | no; nothing produces it outside the TUI |
| Workers | lifecycle timeline, process facts (signing, inject verdicts, capture), behavior, plugins, process tree | Workers::view() (crates/qmonitor/src/workers/telemetry.rs), compiled only with the telemetry feature | no. Plugin loads arrive as events, at Debug severity |
| Events | the raw event table | the tui event route | yes; this is the Live Activity page |
| Config | targets, behaviors with own and inherited values, YAML as written and effective | the loaded configuration | no. The file on disk can be read, but it is not what the monitor has loaded |
| Inventory | harnesses, other matched processes | inventory state | yes: installation.snapshot, process.snapshot, or /api/installations |
| Log | log lines and the monitor's stderr | a tracing layer in process | no; only the log file |
What exists today for another process to read: the event stream, the two snapshots, the dashboard's REST routes, and two lock files (<data dir>/start.lock, <data dir>/dash/qdash.sock.lock). The control socket (<data dir>/monitor.control.sock) takes Snapshot, Shutdown, CheckUpdate and ApplyUpdate; none of them returns state.
The mechanism
Design c131 D17, ruled 2026-11-11: shapes that only the endpoint knows become dashboard routes, typed in TypeScript in ui/app/types/api/ and generated into Rust for this app, under the same drift test as the other 38. Never hand-written in the app, never the monitor's internal structs. D17 names six shapes on three routes: SensorState, Resources and LogLine on /api/sensor; EffectiveConfig and ConfigFile on /api/config; PlaneConnection on /api/plane.
One ask per route (reconciled 2026-11-12, design c143). The Monitor adds its fields to two of D17's routes rather than asking for a second shape of the same facts, and it needs two routes of its own. That makes four routes to ask for, two of them new, plus the event stream that is served today:
| Route | D17's shape, as V1's Sensor page reads it | What the Monitor adds | Views that read it |
|---|---|---|---|
/api/sensor (D17) | SensorState, the Resources totals, the last LogLines | the monitor's pid; a row per process in its tree, with 1 Hz samples; health and the last error; the log's target and a sequence number | Overview (health), Qcontrol, Log; the pid and uptime in the page's header |
/api/config (D17) | EffectiveConfig, ConfigFile | the targets, the behaviors (each value own or inherited) and the include globs, as the monitor has loaded them | Config, Overview (targets) |
/api/monitor/workers (new) | one row per worker, with its lifecycle | Overview (workers), Workers | |
/api/monitor/processes (new) | every process the monitor tracks | Workers (the process tree); Live's attribution to a worker | |
/api/events/stream (served) | Overview (throughput, latest events, the event mix), Live |
Inventory reads what is served today: installation.snapshot and process.snapshot, or /api/installations.
Until the routes exist, the Monitor's additions to D17's shapes live with the rest of those shapes in design's bridge (app/data/worlds/bridge/types.ts, mirrored by hand in crates/types/src/bridge.rs), and its two routes in types/monitor.ts, generated into crates/types/src/monitor.rs. Every Monitor field on D17's routes is optional, so V1's Sensor page and the bridge files are unchanged, and an absent field draws as "not served yet". When the routes land, all of it moves into qdash's TypeScript.
What the Monitor adds to /api/sensor
export interface SensorState {
// V1's fields: state, version, uptime_s, started_at, last_seen_by_plane, degraded
/** The monitor's pid; null when off. */
pid?: number | null
}
/** Totals over the monitor's own tree: CPU as percent of one core (a 5 s mean), resident memory, live workers. */
export interface Resources {
// V1's fields: cpu_pct, rss_bytes, workers
processes?: {
pid: number
ppid: number
/** `qcontrol monitor`, `worker W3 · claude-cli`, or the executable's name */
label: string
/** The worker this process is, when it is one. */
worker_id: number | null
cpu_pct: number
rss_bytes: number
/** 1 Hz samples after ?samples_since=, oldest first. */
samples?: { at: string; cpu_pct: number; rss_bytes: number }[]
}[]
}
export interface LogLine {
// V1's fields: at, level (now trace · debug · info · warn · error), line
/** The tracing target: `qcontrol::monitor`, `stderr`. */
target?: string
/** Rises by one per line; follow the tail with ?log_after=. */
seq?: number
}
/** New on the response, beside sensor, resources and log: every health row, problems and all, and the last error. */
health?: {
rows: { name: string; status: 'ok' | 'degraded' | 'failing'; detail: string }[]
last_error: { at: string; message: string } | null
}
stateanddegradedare the rollup ofhealth: a failing or degraded row makes the state degraded, anddegradedlists those rows' details.- Two cursors, both optional.
?log_after=<seq>returns only the lines after that one.?samples_since=<time>adds each process's samples after that time. Without them the response is V1's: the last lines, newest first, and no samples. - The page works out the 1, 5, 10 and 30 minute means and the charts from the samples, and draws a window it has not filled yet muted, as the TUI does.
- No dashboard URL. The app reaches the sensor through the dashboard, so it already holds the address.
What the Monitor adds to /api/config
export interface EffectiveConfig {
// V1's fields: file_found, mode, plane, won, plugins, it_contact
/** As the monitor has loaded them, first match wins; not config.yaml as it is on disk. */
targets?: { id: string; title: string; matcher: string; behaviors: string[]; harness: string | null }[]
/** Each behavior resolved alone onto the base. */
behaviors?: {
name: string
network: Setting; file: Setting; exec: Setting
inject: Setting; env: Setting; plugins: Setting
}[]
/** The include globs in force. */
include?: string[]
}
/** Whether this behavior set the value (drawn bright) or inherited it from the base (muted). */
export interface Setting { value: string; set: boolean }
The Monitor's two routes
/** GET /api/monitor/workers. One row per worker the monitor holds. */
export interface MonitorWorker {
/** The launch id. Stable for the worker's life. */
id: number
kind: 'launch' | 'attach'
worker_pid: number | null
/** The process being observed; null until spawned. */
target_pid: number | null
client_pid: number | null
/** The harness adapter; null when the target is not a harness. */
adapter_id: string | null
program: string
cwd: string | null
/** The config target that matched, if any. */
target: string | null
/** Where capture is enrolled to, if it is. */
capture: string | null
proxy_addr: string | null
container_id: string | null
behavior: { plugins: string[]; proxy: string; inject: string }
/** In order. The page draws the timeline and the duration of each phase. */
transitions: { phase: 'prepare' | 'ready' | 'started' | 'exited'; at: string }[]
/** New: set when the worker is gone, so no client has to diff snapshots. */
ended_at: string | null
}
/** GET /api/monitor/processes. Every process the monitor tracks, not only matched ones. */
export interface MonitorProcess {
pid: number
ppid: number
exe: string
args: string[]
}
The reference for every field is what the TUI's snapshot holds today: MonitorView, WorkerRow, HealthRow, ProcessRow, TargetRow, BehaviorRow in cli/src/commands/monitor/ui/shared/mod.rs, and WorkerView in qmonitor. Three things are new: ended_at (the TUI works out that a worker has gone by comparing snapshots, and a served shape should say so), the log's seq, and the per-process samples, which nothing outside the TUI produces.
Cadence and cost
- The TUI's snapshot is built at most four times a second, and only when
--tuiis passed. Nothing is built for nobody. - While the Monitor page is open, the app would poll
/api/sensor(with both cursors) and/api/monitor/workersonce a second,/api/monitor/processesonce a second while a view that draws the tree is shown, and/api/configwhen the page opens and when asked. Nothing while the page is closed. A stream in place of polling is welcome and not required. - Resources once a second, as the TUI samples, kept for 30 minutes.
- The rule we would keep: no subscriber, no work. Then the samples begin when a reader arrives, and the 30-minute mean fills in over 30 minutes, drawn muted until it does, as in the TUI (question 3).
What the page does until a route exists
- On synthetic data: every view, fully, from fixtures in the shapes above (Monitor's fixtures come with its build, Phase 5; the bridge files carry none of its fields yet).
- On a real sensor: Inventory and the event-derived parts of Overview work today. Every other region is drawn with its name and one line: this sensor does not serve this yet, with the route it is waiting for. When a route lands, the region fills in. No page change is needed.
Hooking it up
Either the sensor serves a shape at the route above, or one transport function in crates/nozzle reads it from wherever it is served. The doctor (--check-sensor) lists each feed as served, missing or mismatched.
Questions
- Which of these can the dashboard serve: the Monitor's fields on
/api/sensorand/api/config, and its two routes? And does its loopback API stay as the sensor's local read surface once the dashboard's own UI leaves qcontrol? - The monitor may run elevated. Can a user-level app read the lock files and reach the loopback API in that case?
- Resources: can the monitor produce the samples? A user-level app may not be allowed to read an elevated process, and should not be the one sampling the sensor. And should it sample always (a few processes at 1 Hz), so the 30-minute mean is there when the page opens, or only while a reader is attached?
- LogLine: a tail with a sequence number (or as a stream), and at what default level? Outside TUI mode the log file's default filter is
qcontrol=error. - Serving these means the dashboard subsystem is on, with its SQLite projection. Is there a lighter way to serve four small routes to one local reader?
- A worker or run id on the event envelope, so an event attributes to a worker without walking the process tree. The TUI's plan defers it as a schema change.
- Is anything in these shapes something you would rather not expose outside the process?