ADR-0082: VS Code Studio observability client¶
- Status: Accepted
- Kind: Retrospective
- Area: studio
- Date: 2026-07-09
- Relations: supersedes v0-0084; extends ADR-0076
Context¶
The VS Code extension under apps/vscode/ is a native TypeScript client of the public Studio daemon. It does not embed the Studio SPA and does not read StateDB. It provides an Explorer, status bar, run detail, operation-tree views, and optional lifecycle management for a local API-only daemon.
This ADR answers six concrete problems in the shipped extension.
P1 — Editor observability must not create a second data model. Direct SQLite reads would duplicate joins, lifecycle classification, auth, and live-stream behavior already owned by the daemon (apps/vscode/src/api/*; lionagi/studio/services/runs.py).
P2 — “Read-only” must distinguish application state from process supervision. The extension starts or stops a Python child, but its StudioClient only issues GET requests. Calling the extension a control client because it manages a process would erase a useful negative capability boundary (extension.ts; api/client.ts; backend/lifecycle.ts).
P3 — The extension may attach or own, and ownership changes stop semantics. A configured URL must be attach-only; an already healthy default daemon must not be killed; only a child spawned by the extension may be terminated (backend/lifecycle.ts).
P4 — Python availability is not guaranteed in the editor environment. The extension may need an explicit interpreter, a workspace .venv, a source-checkout uv sync, or a system python3 fallback. Failed provisioning and import preflight need actionable states.
P5 — Authenticated live output cannot use native EventSource. Session output and persisted signals require bearer-aware fetch streams, terminal-frame handling, and visible transport EOF failure (api/sse.ts; api/signals.ts).
P6 — The extension and web client independently encode the daemon contract. Run, project-group, invocation, auth, trailing-slash, and stream-shape changes can break the extension even if the daemon and web app continue to work.
| Concern | Decision |
|---|---|
| Data boundary | D1: Use only Studio HTTP/SSE for application data; never read StateDB or embed the SPA. |
| Capability boundary | D2: Keep StudioClient GET-only; classify backend start/stop as owned-process lifecycle. |
| Daemon lifecycle | D3: Attach when configured/healthy, otherwise provision and supervise an API-only local child. |
| Live observation | D4: Parse authenticated session and signal SSE with explicit terminal and EOF behavior. |
| Native UX | D5: Prefer Explorer, status bar, commands, and purpose-built detail webviews over a general SPA webview. |
| Compatibility | D6: Treat daemon OpenAPI and shared fixtures as the client seam. |
Out of scope:
- Launching runs, editing definitions, changing schedules, approvals, maintenance, or any other Studio application mutation.
- Shipping Docker, Node, a frontend build, or the web SPA as an extension dependency.
- Defining the daemon's HTTP/SSE contract; ADR-0076 and ADR-0078 own it.
- Converging the VS Code visual design with the browser cockpit.
Decision¶
D1 — Native client over Studio HTTP/SSE only¶
The extension module tree is:
apps/vscode/src/
├── extension.ts # composition and commands
├── config.ts # `den.*` settings
├── statusBar.ts
├── api/
│ ├── client.ts # GET JSON client
│ ├── types.ts # daemon projections
│ ├── sse.ts # session message stream
│ └── signals.ts # persisted signal stream
├── backend/lifecycle.ts # attach/provision/spawn/supervise
└── runs/
├── runsExplorer.ts
├── runItem.ts
├── runDetailPanel.ts
├── runTreeModel.ts
└── runTreePanel.ts
The injected dependency boundary is:
export interface StudioDeps {
client: StudioClient;
backend: BackendManager;
}
Exact semantics:
StudioClientobtains its base URL fromBackendManagerand its optional token from extension configuration.- Every application record comes from
/api/*;/healthis used only for process discovery/supervision. - No extension module imports a Python package, SQLite library, StateDB path, or browser Studio bundle.
- A purpose-built webview may render detail, but it receives messages from extension code; it is not granted a separate daemon client or arbitrary host-file access.
- When the backend is not running, the Explorer clears its cached grouping and returns no rows rather than displaying stale data as current.
Why this way: the daemon already owns lifecycle and read adaptation. Reusing it keeps the editor from becoming a second persistence consumer while preserving a native VS Code UX.
D2 — GET-only application client and negative capability¶
The shipped JSON client contract is:
export class StudioApiError extends Error {
constructor(
public readonly status: number,
public readonly detail: string,
);
}
export interface ListRunsOptions {
page?: number;
per_page?: number;
status?: string;
playbook?: string;
project?: string;
project_null?: boolean;
}
export class StudioClient {
constructor(
getBaseUrl: () => string,
getToken: () => string | undefined,
);
listRuns(opts?: ListRunsOptions): Promise<RunsPage>;
listProjectGroups(): Promise<ProjectGroupsPage>;
getRun(runId: string): Promise<Run>;
getInvocation(invocationId: string): Promise<InvocationDetail>;
}
All four public methods call the private request helper with method GET. The paths are:
/api/runs/ # slash retained, optional query
/api/runs/projects
/api/runs/{runId}
/api/invocations/{encodedId}
Exact semantics:
- Every request sends
Content-Type: application/jsonand adds the bearer header when configured. GETs have no body. - Non-2xx responses attempt to parse
detail; failure falls back tostatusText, then throwsStudioApiError(status, detail). - JSON response parsing errors propagate; they are not converted into empty pages.
- Run ids are currently interpolated directly in
getRun; invocation ids are encoded. Contract tests must cover ids accepted by the daemon so this difference cannot become an injection/path bug. - The client exposes no POST, PUT, PATCH, or DELETE application method.
den.startBackendandden.stopBackendcallBackendManager; they do not hit/apimutation routes.
This negative capability is architectural. Adding launch, schedule, definition, approval, or maintenance methods requires a new decision, not an opportunistic helper.
D3 — Attach/provision/spawn lifecycle with ownership tracking¶
The configuration contract is:
den.url: string = ""
den.pythonPath: string = ""
den.port: number = 8765
den.host: string = "127.0.0.1"
den.autoStart: boolean = true
den.authToken: string = ""
export type BackendState = "stopped" | "starting" | "running" | "error";
Interpreter resolution order is:
- Explicit
den.pythonPath, with import preflight. - Existing workspace
.venvPython, with preflight and optionaluv sync --extra studio --no-devrepair. - Source checkout with
pyproject.tomlanduv, provisioned into.venvusing the same sync command. - System
python3, with import preflight.
Every spawn executes python -m lionagi.studio. It propagates host, port, and a non-empty token through LIONAGI_STUDIO_* environment variables. It does not build or mount a frontend.
Exact discovery and ownership semantics:
- A non-empty
den.urlis attach-only. The manager probes it; unreachable configuration becomeserrorand does not fall back to spawning another daemon. - With no configured URL, the default host/port is probed before spawning. A healthy daemon is attached as unmanaged.
stop()detaches from unmanaged backends and explicitly reports they remain running.stop()terminates only the child stored as owned. It first sends the platform default termination and escalates toSIGKILLafter 3 seconds if the child remains.- An epoch counter prevents a slow in-flight start from resurrecting state after a newer start/stop.
- A spawn that loses an address-in-use race re-probes the target; if another daemon is healthy, it attaches instead of reporting failure.
- Start waits up to 30 seconds for health. It probes every 400 ms and aborts when the spawn fails or the start generation is superseded.
- Supervisor reconciliation runs every 8 seconds. Two consecutive misses change a running backend to
error; a later healthy probe restoresrunning. - Configured/existing/start-path probes use 2.5-second attempts with two retries; the recurring reconciliation health probe uses 2.5 seconds with one retry; the general helper defaults to 1.5 seconds and zero retries. Import preflight is killed after 5 seconds. Provisioning is killed after 180 seconds.
The time values are shipped operating constants. Their reasons are bounded startup, avoiding port races, hysteresis against one slow health response, and bounding package installation/import checks. The source contains no measurements selecting the exact values.
Failure semantics:
- Missing imports produce an actionable install/configuration message before a doomed spawn when preflight applies.
- Provision failure and spawn failure transition to
error; output is retained in the extension output channel. - An owned child dying after readiness triggers reconciliation. If the port remains healthy, the manager does not flap to error.
dispose()clears the supervisor, invokes ownership-aware stop, and disposes emitters and output resources.
D4 — Authenticated session and signal streams¶
The two shipped stream signatures are:
export async function streamSession(
baseUrl: string,
sessionId: string,
token: string | undefined,
onEvent: (event: StudioEvent) => void,
signal: AbortSignal,
): Promise<void>;
export async function streamSignals(
baseUrl: string,
sessionId: string,
token: string | undefined,
onEvent: (event: SignalStreamEvent) => void,
signal: AbortSignal,
): Promise<void>;
Their public event types are:
type StudioEvent = { type: "heartbeat" } | { type: "done" } | MessageEvent;
type SignalStreamEvent =
| { type: "heartbeat" }
| { type: "done" }
| {
id: string;
session_id: string;
seq: number;
kind: string;
op_id: string;
ts: number;
payload: Record<string, unknown>;
};
Exact semantics:
- Both send
Accept: text/event-stream,Cache-Control: no-cache, and optional bearer authorization. - Session ids are URL-encoded.
- Non-2xx or a missing response body throws before frame parsing.
- Frames split on double newlines. Multiple
data:lines are joined and parsed as JSON. - Malformed JSON frames are ignored; other valid event objects are delivered in arrival order.
- Heartbeats are visible to the caller type but ignored by current detail rendering.
- Explicit
{type:"done"}returns normally. - Transport EOF before
donethrows a visible stream-closed error. An AbortSignal-driven close is handled by the caller and does not show a spurious error after retarget/dispose. - These functions do not reconnect or resume. The signals endpoint replays persisted rows from seq zero on each new connection; the session message stream uses its server-local timestamp cursor per connection.
The missing reconnect/resume contract is a current delta, not an implied guarantee.
D5 — Native Explorer and bounded purpose-built webviews¶
The Explorer groups runs under a pinned Active band and project buckets. The key budgets are:
const POLL_INTERVAL_MS = 4_000;
const PAGE_SIZE = 50;
const ACTIVE_LIMIT = 100;
Exact semantics:
- The root loads project counts and active runs concurrently with
Promise.allSettled. Active-band failure does not blank the archive; project-group failure does. - The active query requests status
running, page 1, up to 100 rows, then sorts newest first. It does not page active rows, so more than 100 current sessions are not shown in the pinned band. - Project groups start with 50 rows and grow one page at a time. Unassigned runs use the explicit
project_nullquery. - Polling runs only while the view is visible, the backend is running, and active work is known. Loading completion, not merely refresh request, starts polling to avoid the stale- empty bootstrap race.
- The four-second poll, page 50, and active cap 100 are inherited responsiveness/resource bounds. No recorded measurement selects the exact values.
- One run-detail webview is reused and retargeted; clicking multiple runs does not create unbounded panels.
- Retarget aborts the old stream before changing the selected run. The old stream's failure cannot post an error into the new run.
- Terminal runs fetch detail, merge it with list metadata, show structured failure reasons, and flatten
steps[].messages[]. Live runs consume session SSE. - Operation-tree views consume persisted signal SSE through extension code. Webviews do not receive the auth token or fetch daemon data themselves.
D6 — Shared daemon contract is the compatibility seam¶
The extension's Run, RunsPage, ProjectGroupsPage, InvocationDetail, session event, and signal event interfaces are projections of daemon payloads, not independent domain models. Compatibility therefore follows ADR-0078 D5:
- OpenAPI and route snapshots pin method/path/shape/auth.
- Recorded SSE fixtures pin heartbeat, data, terminal, malformed frame, EOF, and auth behavior.
- The VS Code serializers/parsers and web parsers run against the same fixtures.
- Additive optional fields are tolerated. Removing or changing required fields, route slashes, auth, or terminal behavior is versioned before an extension release consumes it.
- A negative capability test inspects the public
StudioClientsurface and exercises its request calls to prove only GET methods are emitted.
Consequences¶
- The editor gets native local observability without a second datastore or SPA hosting requirement.
- Attach versus own is explicit, preventing the extension from killing a daemon it did not spawn.
- Provisioning makes source-checkout use more accessible but adds interpreter, package manager, timeout, and health-race failure modes.
- GET-only scope reduces application mutation risk. Reversing D2 would be a product and authorization expansion, not a small API addition.
- Fetch streams support bearer auth and visible EOF failure but currently require the caller to reconnect manually.
- Shared contract fixtures add release work but prevent one of two clients from silently drifting.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Add a contract test suite that runs the VS Code serializers and SSE parsers against the daemon's current OpenAPI examples and recorded stream fixtures. | M | (filled at issue-open time) |
| 2 | Add a negative capability test proving the extension client has no methods for launch, definition, schedule, approval, maintenance, or other mutation endpoints. | S | (filled at issue-open time) |
| 3 | Define retry, backoff, resume, and terminal behavior for dropped session and signal streams; a transport EOF must produce a visible recoverable state rather than a frozen view. | M | (filled at issue-open time) |
| 4 | Version or compatibility-gate daemon response and stream changes before publishing an extension release that depends on them. | S | (filled at issue-open time) |
Alternatives considered¶
Direct SQLite/StateDB reads from the extension¶
This would remove the daemon dependency and permit rich local queries. It lost because the extension would need schema migrations, query joins, lifecycle health logic, filesystem policy, and live observation. It would also bypass daemon auth and diverge from the web read model.
Embed the Studio SPA in a general webview¶
This would maximize browser/editor parity and reuse feature components. It lost because the extension would inherit the frontend build and cockpit navigation while still requiring the daemon. Native Explorer and focused panels integrate better with editor workflows and keep the deployment API-only.
Make the extension a launch/control client¶
Adding POST methods would let users launch or cancel work without leaving VS Code. It lost because the current product has a clear observer boundary and no editor-specific confirmation/authorization design. Process start/stop is sufficient for local availability.
Always spawn a private daemon¶
This would simplify ownership and configuration. It lost because an existing daemon may already hold the port and serve the browser, and a configured remote/local URL reflects user intent. Attach-first avoids duplicate state processes and address races.
Require Docker for lifecycle management¶
Docker could make dependencies reproducible and isolate the daemon. It lost because the extension should work in a Python source checkout or installed environment, including editor hosts where Docker is absent. The API-only process needs no frontend container.
Native EventSource for streams¶
It offers automatic reconnect. It lost because the extension must attach the bearer header and requires explicit EOF/error behavior. Fetch streams keep authentication and cancellation under extension control.