- Status: done
- Date: 2026-06-04
- Specs touched:
BRAIN_HOST_PROTOCOL.md(added thejournal_followop to Pattern C; no decision flipped). RealizesLOGGING.md# Per-app logs + # Mechanisms andBRAIN_UI_PROTOCOL.mdPattern C stream 1 — both already specified; the implementation matches them, so neither needed editing.
Closes issue #6. A per-app Logs panel that live-tails one container's stdout/stderr end-to-end: host-agent journalctl CONTAINER_NAME=<c> -f → SSE → brain transparent forwarder with a per-instance ring/replay hub → brain GET /api/v1/apps/{id}/log → dashboard. The brain is containerized and can't read the host journal directly, so the tail flows brain → host-agent → journald exactly as LOGGING.md # Mechanisms requires. Live-tail only; the sibling journal_query (historical search) and journal_export_range (bundle dump) stay deferred.
internal/protocol/host.go—JournalLine({ts, stream, line, lost}), thedata:payload of each Pattern-C frame.lost=trueis the gap marker (no text).internal/hostagent/agent.go—LogSourceinterface (consumer-side, lives with its user), anAgent.Logsfield, andGET /v1/journal/follow?container=<name>:501if no source is wired,400ifcontaineris missing, else200+text/event-stream. A reconnect carryingLast-Event-IDcan't be replayed by a fresh per-connection follower, so the handler leads with one{"lost":true}frame then streams live. Frames carry host-agent's own per-connection monotonicid.internal/hostagent/fake.go—FakeLogSource: a synthetic ticker (one line per interval) so the all-native inner loop has a live stream with no real journald.internal/hostagent/journalsource/— the realLogSourceforhost-agent-real. Shells out tojournalctl CONTAINER_NAME=<c> -f -o json -n 100, binds the process to the follow context (a brain disconnect kills it), parses each entry (PRIORITY3 → stderr, else stdout;__REALTIME_TIMESTAMPµs → RFC3339;MESSAGEhandled as both the JSON-string and non-UTF-8 byte-array forms journald emits), and skips unparseable lines rather than aborting the stream. Relies on Docker's daemon-widejournaldlog driver (LOGGING.md# Operational logs).cmd/host-agent/main.gowiresNewFakeLogSource(time.Second);cmd/host-agent-real/main.gowiresjournalsource.New().
internal/hostclient/hostclient.go—JournalFollow(ctx, container) (<-chan protocol.JournalLine, error). Returns an error on a non-200 (so a501/400surfaces as an error, not a silent empty stream), else scans the SSEdata:lines into the channel until ctx is cancelled or the stream ends. Gotcha fixed: the sharedhttp.Clienthas a 30 sTimeoutthat would guillotine a long-lived follow, soNew()now builds one transport shared by two clients — the existing 30 shttpclient and a new timeout-lessstreamclient used only for follows (bounded by ctx instead).
internal/applog/—Registryof onehubper instance id.Subscribe(instanceID, container, lastID) → (replay, live, release). The first subscriber opens one upstreamJournalFollow; subsequent subscribers of the same app share that one follow (no secondjournalctl). Each frame is re-stamped with the brain's own monotonic id and pushed into a ~256 KB rolling ring. Replay logic:lastID==0→ whole backlog, no{lost}; alastIDstill buffered → tail replay; an evictedlastID→ one{lost}then the full ring. When the last subscriber leaves, a short linger keeps the follow warm; if it elapses idle the hub tears down and the next reader reopens cold (a cold hub +Last-Event-IDalways emits{lost}, so id-reset is safe). A subscriber that can't keep up is closed (forcing a reconnect + ring replay) rather than silently dropped — an unmarked gap would be a correctness bug. Mirrorsinternal/systemlive's ref-counted-poller shape.internal/lifecycle/lifecycle.go—MainContainerName(id)resolvesmalmo-<id>-<main_service>from the on-disk instance manifest, the container the brain hands to the follow.
internal/api/applogs.go—GET /api/v1/apps/{id}/log, registered raw (likeevents/systemLive) because theid:/data:/Last-Event-IDreconnect format is the point and huma streaming would obscure it. Order: authenticate (401) →store.Get(404) →logVisibility(403/404) →MainContainerName(500) →beginStream(429, the per-session SSE cap) → write 200 → replay backlog → fan out live.logVisibilityis stricter thancanSeeand pinsLOGGING.md# Per-app logs's visibility rule: admin → 200; owner of a personal instance → 200; non-admin + household → 403 (a member may see a household app in the launcher, but its logs can leak another member's activity, so logs are admins-only); non-admin + someone else's personal → 404 (leak guard, mirroringgetApp).- Wired through
cmd/brain/main.go(applog.NewRegistry(pollCtx, host)) andNewServer.
web-ui/src/useLogStream.ts— opens anEventSourceover/api/v1/apps/{id}/log(withCredentialsfor the session cookie) on mount, closes on unmount (the "only-while-watching" contract the brain ref-counts). EventSource auto-reconnects and resendsLast-Event-ID; the composable just appends, capped at 1000 in-memory lines (the brain's ring is the replay source of truth).web-ui/src/components/AppLogs.vue— monospace panel: stderr in red, the{lost}marker rendered as an amber "some earlier lines were dropped" divider, auto-scroll pinned to the bottom unless the user scrolls up (with a "jump to latest" affordance), and Waiting/Connecting empty states.web-ui/src/views/SettingsView.vue— a per-row Logs toggle in the installed-apps list (one panel open at a time), shown only when the viewer may see the logs (canViewLogs: admin, or a member's own personal app). The list is already visibility-scoped server-side, so the toggle is a pre-gate, not the security boundary.
Across every layer, all race-clean: host-agent handler (nil→501, missing-container→400, monotonic ids, Last-Event-ID→{lost}-then-live, source-error→500); journalsource parse units (priority→stream, µs→RFC3339, byte-array message, garbage skipped); hostclient (parses frames then closes, non-200→error, ctx-cancel→channel close); applog hub (late joiner gets backlog, two readers share one upstream, tail-replay vs {lost}-on-eviction, linger teardown reopens upstream); api (logVisibility matrix + the four HTTP denial paths — 401/403/404/404).
LOGGING.md# Per-app logs — Logs tab, live SSE tail, plaintext monospace no-parsing, and the exact visibility rule (Tier-3 owner + admins; Tier-1 shared → admins only).LOGGING.md# Mechanisms — realizes the called-for "new section inBRAIN_HOST_PROTOCOL.mdfor journal operations" by addingjournal_follow(read-only;journal_query/journal_export_rangedeferred). The brain → host-agent → journald path and the Dockerjournald-log-driver dependency are exactly as specified.BRAIN_UI_PROTOCOL.mdPattern C stream 1 —GET /api/v1/apps/:id/logas a transparent forwarder; the brain re-emits ids from its own monotonic counter soLast-Event-IDreplay survives brain restarts. Counts against the ≤16-per-session stream cap viabeginStream.BRAIN_HOST_PROTOCOL.mdPattern C — frame shape (id:+data:, noevent:), monotonic ids,{"lost":true}on gap.- CLAUDE.md # Go code discipline — consumer-side
LogSource(inhostagent) andfollower(inapplog) interfaces; newapplogpackage for a self-contained concern (goroutine lifecycle + ring + tests), mirroringsystemlive;log/slogwith the standarderr/containerfields; layer boundaries respected (applogimports onlyprotocol;api/cmd/brainimportapplog;MainContainerNameadded onlifecycle, the manifest owner).
- Two-tier replay split. The generic Pattern-C contract puts the ~256 KB ring +
Last-Event-IDreplay in host-agent. Forjournal_followit lives in the brain hub instead; host-agent is a thin per-connection streamer (one{lost}on a reconnect withLast-Event-ID, then live — no cross-connection buffer). The brain owns the ring shared across all dashboard subscribers of one app and is the side the browser reconnects against. A host-side shared-follower buffer (so two brain consumers share onejournalctl) is deferred until a second consumer exists. Documented inBRAIN_HOST_PROTOCOL.md# Pattern C. CONTAINER_NAMEreplica-suffix mismatch. Docker'sjournalddriver tags lines with the running container name, which compose suffixes with a replica number —malmo-<id>-<service>-1.MainContainerNamereturns the un-suffixed stemmalmo-<id>-<service>, so an exactCONTAINER_NAME=match misses the line on a real box until the brain passes the replica-qualified name. The brain-side resolution (or aCONTAINER_NAMEprefix/glob match) is a follow-up; the fake host-agent doesn't reproduce this, so the inner loop is unaffected. Noted in thejournalsourcepackage doc and surfaced in the PR body.- Logs attach to the Settings installed-apps list, not an app detail card.
LOGGING.md# Per-app logs says the Logs tab "lives on each app's card." No per-app detail view with tabs exists yet (the installed-apps list in Settings is where per-instance management lives, with a// when the app detail page lands, this moves therenote already in the file). The Logs toggle was added there alongside Uninstall; it moves to the detail card when that lands — no behavior change, just a host surface. - api 200/streaming happy-path is covered by hub + handler unit tests, not one end-to-end HTTP integration test. The
apploghub tests prove replay/fan-out/linger, the api tests prove auth + the four denial statuses, and the hostclient/host-agent tests prove the wire. A single test that stands up the full manifest-on-disk + host scaffold and reads a live 200 stream over HTTP was judged not worth the fixture cost given that coverage; recorded here as the deliberate seam. - Member-visible household logs (
logs.member_visible: true) not implemented. The manifest opt-in that would relax the admins-only rule for household apps is deferred perLOGGING.md# Per-app logs. - "No logs received" empty-state hint (
LOGGING.md# Apps are expected to log to stdout — the sliding-window "this app may be logging to a file" card) is not built; the panel shows a neutral Waiting state instead. Out of issue scope.
- Replica-qualified container name so real-box logs match (
malmo-<id>-<service>-1), or aCONTAINER_NAMEprefix match injournalsource. This is the one gap that blocks the feature on a real host (the fake works today). journal_query+journal_export_rangefor the System logs view and the diagnostic bundle (LOGGING.md# System logs, # Diagnostic bundle).- Logs tab on the app detail card when that view lands; retire the Settings-list toggle.
- Real journald exercise under the QEMU medium lane — the
journalsourceparser is unit-tested but the livejournalctlfollow + the Docker log-driver config aren't yet run against a real journal.