From 4992a54864e0ec956db27c9653030125c2cfa2c2 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Mon, 7 Sep 2026 17:38:35 +0800 Subject: [PATCH] docs(dsh): clarify observation subscription and snapshot contracts --- packages/dsh-plugin-browserskill/README.md | 33 +++++++++++++++---- .../src/observation-http.ts | 10 ++++-- .../src/observation.ts | 16 +++++++-- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/dsh-plugin-browserskill/README.md b/packages/dsh-plugin-browserskill/README.md index 4217fcf..aee68f7 100644 --- a/packages/dsh-plugin-browserskill/README.md +++ b/packages/dsh-plugin-browserskill/README.md @@ -112,7 +112,7 @@ shell's theme cannot bleed back in. - **Lifecycle**: hidden while the plugin owns no sessions; appears on the first `browser_session` start action; disappears when all sessions stop (or the plugin unloads). - **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page - frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on + frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on stage while the next one loads, and is kept on errors so the card does not flash), and a compact icon toolbar (Interrupt + Pop out, hover for the label). - **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics @@ -129,11 +129,32 @@ shell's theme cannot bleed back in. - **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user gesture, per browser rules), sized from the current card; closing the PiP falls back to the in-page card with state intact. Browsers without Document PiP simply hide the button. -- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE), - `POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/` over - the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree - packages). All commands for one session — tool calls and frame captures alike — run through a - per-session FIFO, because the daemon accepts only one unfinished command per session. +- **Screenshot demand**: the client requests periodic screenshots while an observation view is + visible, using the PiP document's visibility when popped out. Hidden or collapsed views can + keep subscribing to state without requesting screenshots. The first screenshot subscriber + starts capture scheduling; the last one's departure cancels timers and skips queued captures. + An already running capture may finish. Demand applies to the service's owned sessions, not just + the selected session. +- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection, + including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by + `upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state + with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only + subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to + requesting screenshots for compatibility. Closing the stream releases its screenshot demand. + `GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not + request screenshots. It has no ordering guarantee relative to a separate SSE connection; + live clients should initialize and resynchronize from the stream's snapshot instead. + The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and + `GET /bsk-observation/thumbnail/` through the dsh `webServer` route seam + (dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one + session — tool calls and frame captures alike — run through a per-session FIFO, because the + daemon accepts only one unfinished command per session. +- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })` + subscribes to state without requesting screenshots. On an active service, the listener receives + the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent + changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned + unsubscribe function is safe to call repeatedly and releases this subscription's screenshot + demand. Subscribing after service disposal is a no-op. - **Trust model**: these routes expose live screenshots (and an interrupt write), so they replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must diff --git a/packages/dsh-plugin-browserskill/src/observation-http.ts b/packages/dsh-plugin-browserskill/src/observation-http.ts index 115d689..4b1a008 100644 --- a/packages/dsh-plugin-browserskill/src/observation-http.ts +++ b/packages/dsh-plugin-browserskill/src/observation-http.ts @@ -5,13 +5,19 @@ * the event allowlist lives in dsh-api-remotes), so the plugin serves its * observation channel through the documented `webServer` route seam instead: * - * GET /bsk-observation/state → { sessions, available } + * GET /bsk-observation/state → one-time { sessions, available } read * GET /bsk-observation/events → SSE snapshot, then ObservationEvent increments - * (?thumbnails=0 for state-only subscribers) + * (on every connection, including reconnects) + * ?thumbnails=0: state only; =1: request screenshots + * Omitted parameter defaults to screenshots. * POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean} * POST /bsk-observation/stop → body {sessionId} → {stopped: boolean} * GET /bsk-observation/thumbnail/ → image bytes * + * Live clients initialize/resynchronize from the SSE snapshot; a separate + * /state read has no ordering guarantee relative to the stream and does not + * request screenshots. Closing an SSE connection releases its screenshot demand. + * * Routes exist only when a webServer service is mounted (web composition); * headless deployments skip registration entirely. */ diff --git a/packages/dsh-plugin-browserskill/src/observation.ts b/packages/dsh-plugin-browserskill/src/observation.ts index 17b0194..efdd9c1 100644 --- a/packages/dsh-plugin-browserskill/src/observation.ts +++ b/packages/dsh-plugin-browserskill/src/observation.ts @@ -127,7 +127,7 @@ export class ObservationService { return this.deps.scheduler ?? DEFAULT_SCHEDULER; } - /** All current entries (client initial/resync snapshot). */ + /** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */ getState(): SessionObservation[] { return [...this.observations.values()].map((entry) => ({ ...entry })); } @@ -143,7 +143,19 @@ export class ObservationService { this.emit({ type: "availability", available }); } - /** A snapshot and subsequent changes share one ordered subscription. */ + /** + * Subscribe to an ordered initial snapshot and subsequent changes. On an + * active service, listener receives the snapshot synchronously before this + * method returns. Subscribing after disposal is a no-op. + * + * `thumbnails` defaults to true for compatibility: this subscription counts + * as a screenshot viewer for the service's owned sessions. Pass false for + * state-only observation. The first viewer starts capture scheduling. + * + * @returns An idempotent unsubscribe function releasing this subscription's + * screenshot demand. When the last viewer leaves, scheduled/queued captures + * are cancelled/skipped; an already running capture may finish. + */ subscribe( listener: (event: ObservationEvent) => void, { thumbnails = true }: { thumbnails?: boolean } = {},