Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/design/daemon-multi-workspace-session-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Workspace-Qualified Session Export

## Summary

Issue #6378 requires clients to export a persisted session from an explicitly
selected registered workspace. The existing `GET /session/:id/export` route is
intentionally bound to the primary workspace, so reusing it for a secondary
session either returns `404` or can select the wrong transcript when the same
session id exists in more than one workspace.

This change adds
`GET /workspaces/:workspace/session/:id/export?format=html|md|json|jsonl`, the
`workspace_session_export` capability, a matching `WorkspaceDaemonClient`
method, and supporting documentation. The legacy route remains primary-bound.

## Contract

The workspace selector follows the existing plural-route rule: exact registered
workspace id first, then a URL-encoded absolute cwd after canonicalization. The
selected runtime must be trusted. Resolution and trust checks happen before
session or format validation.

The route reads only the selected workspace's active persisted JSONL. It does
not search another workspace, fall back to primary, resolve a live owner, start
ACP, attach a client, or load workspace settings. Archived sessions remain
unavailable. Success uses the same formatter, filename sanitization, MIME type,
cache policy, and attachment headers as the legacy export route.

Errors preserve the existing export/storage shapes, with
`400 workspace_mismatch`, `403 untrusted_workspace`,
`400 invalid_export_format`, `404 session_not_found`, and the existing
`409 session_archived`, `session_archiving`, and `session_conflict` contracts.

## Capability and Compatibility

`workspace_session_export` is an unconditional v1 capability because the plural
route is useful for a trusted single-workspace primary selected by id or cwd.
Trust is still evaluated per request. The new tag is independent of
`multi_workspace_sessions` and cannot be inferred from `session_export` or
`workspace_qualified_rest_core`; released daemons advertise both older tags but
do not implement this route.

Direct SDK callers receive the normal HTTP error when they call the new method
against an older daemon. Web Shell integration is outside this change, so its
existing primary-only export behavior remains unchanged.

## Concurrency and Security

Export retains the existing shared archive-coordinator lock keyed by session
id, so archive and delete cannot move or remove the file during replay. The
coordinator remains conservatively global: identical ids in different
workspaces may serialize even though their files are independent. Renaming all
archive/delete lock keys is outside this change.

Unlike the bounded persisted transcript pager, full export materializes the
complete transcript and is not available to an untrusted secondary workspace.
The existing trusted export has no new response-size budget; adding a
workspace-specific limit would make the plural and legacy format contracts
diverge. Daemon bearer authentication, the default GET read-rate tier, and
per-request workspace trust checks continue to apply.

Runtime removal races use the runtime selected at request resolution. Removal
does not delete transcript storage, so export needs no runtime lease and does
not keep an ACP child alive.

## SDK and Observability

`WorkspaceDaemonClient.exportSession` reuses the existing export result and
format types and always uses native REST, including when the parent client has
an ACP transport. The shared request helper preserves token, client identity,
timeout, error parsing, content type, and attachment filename behavior.

Daemon telemetry normalizes the new path as
`GET /workspaces/:workspace/session/:id/export`, decodes the session id, and
uses middleware workspace resolution for the selected workspace hash.

## Alternatives Rejected

- Routing the singular export by live owner fails for inactive persisted
sessions and makes ownership ambiguous after restart.
- Adding a `cwd` query to the legacy route changes a primary-only compatibility
contract and is less consistent than existing plural workspace routes.
- Falling back to primary on a miss can export a different workspace's session
when ids collide.
- Allowing untrusted full export would bypass the bounded read policy designed
for the persisted transcript pager.

## Verification

Tests cover capability advertisement, id/cwd selectors, same-id isolation,
every format, response headers, trust and archive boundaries, missing/unknown
targets, absence of bridge activity, telemetry attribution, SDK transport and
encoding, and archive/delete coordination. End-to-end verification uses
isolated runtime and workspace directories with deterministic persisted
transcripts.
4 changes: 3 additions & 1 deletion docs/developers/daemon/11-capabilities-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T

Foundation: `health`, `daemon_status`, `capabilities`.

Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`.
Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`.

Streaming: `slow_client_warning`, `typed_event_schema`.

Expand All @@ -120,6 +120,8 @@ Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_p

Extension management: `extension_management_v2` adds the global `/extensions/*` catalog/mutation/operation contract and the workspace activation projection. It is separate from the published `workspace_extensions` compatibility surface and from `workspace_qualified_rest_core`.

Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`. The export tag is separate from both `session_export` and `workspace_qualified_rest_core` because older daemons advertise those tags without the plural export route. Persisted transcript paging permits an untrusted secondary under its bounded read policy; full export remains trusted-only.

Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, **`workspace_reload`** (conditional).

MCP guardrails: **`mcp_guardrails`** (`modes: ['warn', 'enforce']`), `mcp_guardrail_events`, `mcp_server_runtime_mutation`, **`mcp_workspace_pool`** (conditional), **`mcp_pool_restart`** (conditional).
Expand Down
2 changes: 2 additions & 0 deletions docs/developers/daemon/13-sdk-daemon-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ On reconnect the daemon replays events with `id > lastSeenEventId` from its boun

When `workspace_persisted_transcript` is advertised, `client.workspaceById(workspaceId).getSessionTranscriptPage(sessionId, { cursor, limit })` reads the selected registered workspace without attaching to ACP. The workspace-qualified method always uses native REST even if the client has a replaceable transport; its cursor expires when the daemon restarts.

When `workspace_session_export` is advertised, `client.workspaceById(workspaceId).exportSession(sessionId, { format })` or `client.workspaceByCwd(workspaceCwd).exportSession(...)` exports the selected trusted workspace's active persisted transcript. It returns the existing `DaemonSessionExportResult`, preserves optional client identity and client-wide fetch timeout behavior, and always uses native REST even if the client has a replaceable transport. Do not infer this method's server support from `session_export` or `workspace_qualified_rest_core`; older daemons keep primary-only export.

### Seeding `lastEventId` at Construction

Callers that persist the cursor across process restarts can seed it:
Expand Down
13 changes: 12 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design

`workspace_persisted_transcript` advertises `GET /workspaces/:workspace/session/:id/transcript`, a daemon-local persisted-only pager that does not start ACP, query live bridge state, load settings, discover project capabilities, or create the legacy persisted cursor key. The tag is unconditional because trusted single-workspace primaries can use the plural route; per-workspace trust authorization is still evaluated on every request. Registered untrusted secondary workspaces may read, while an untrusted primary remains rejected.

`workspace_session_export` advertises `GET /workspaces/:workspace/session/:id/export`, a trusted-only full export of the selected workspace's active persisted session. It is independent of `session_export` and `workspace_qualified_rest_core`: released daemons can advertise both older tags without implementing the plural route, so clients must pre-flight this tag directly. The tag is unconditional because a trusted single-workspace primary can use the route by id or cwd. The export does not resolve a live owner, start ACP, attach a client, or fall back to another workspace.

`slow_client_warning` covers SSE backpressure behavior: (a) the daemon emits a `slow_client_warning` synthetic event-stream frame when a subscriber's live frame backlog or live serialized-byte backlog crosses 75% full, once per overflow episode (rearmed after both measurements drain below 37.5%); (b) `GET /session/:id/events` accepts a `?maxQueued=N` query param (range `[16, 2048]`) to pre-size the per-subscriber frame backlog for cold reconnects against a large replay ring. The serialized-byte cap is daemon-owned (default **2 MiB** per subscriber), live-only, and intentionally has no query parameter. The daemon-wide ring size is controlled by `--event-ring-size` (default **8000**, per #3803 §02). Old daemons silently lack the warning/query behavior — pre-flight this tag before opting in.

`typed_event_schema` advertises daemon event payloads that match the SDK's `KnownDaemonEvent` schema. Older daemons may still stream compatible frames, but SDK clients should pre-flight this tag before assuming typed event coverage.
Expand All @@ -220,7 +222,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design

`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived.

`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.
`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, channel-worker routing, or workspace-qualified session export; pre-flight `workspace_session_export` separately. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.

`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.

Expand Down Expand Up @@ -847,6 +849,7 @@ Capability tags:
- `session_status` → `GET /session/:id/status`
- `session_transcript` → `GET /session/:id/transcript`
- `workspace_persisted_transcript` → `GET /workspaces/:workspace/session/:id/transcript`
- `workspace_session_export` → `GET /workspaces/:workspace/session/:id/export`

Common status cell:

Expand Down Expand Up @@ -1743,6 +1746,14 @@ For this workspace-qualified route, `limit` is the maximum record count. A page

Unlike the legacy singular route, this path is implemented entirely inside the daemon process. It does not call the workspace bridge, start ACP, load settings, parse project-defined agents or skills, or create/repair `session-transcript-cursor-key`. Tool frames use persisted tool names and descriptions without consulting the runtime tool registry. Its HMAC cursor key exists only in daemon memory, is isolated per workspace, and rotates on restart; a cursor from a previous daemon process returns `400 invalid_transcript_cursor`.

### `GET /workspaces/:workspace/session/:id/export`

Export the selected registered workspace's active persisted session as an attachment. Pre-flight `workspace_session_export`; do not infer support from `session_export` or `workspace_qualified_rest_core`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Both primary and secondary runtimes must be trusted. An untrusted runtime returns `403 untrusted_workspace` before session or format validation.

The optional `format` query is `html` (default), `md`, `json`, or `jsonl`. The body, MIME type, filename sanitization, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, and attachment disposition match `GET /session/:id/export`. The legacy route remains bound to primary storage.

The plural route reads only the selected workspace's active persisted JSONL under the existing shared archive coordinator. It does not scan other workspace stores, fall back to primary, resolve a live owner, call the workspace bridge, start ACP, attach a client, or load settings. A session id that exists only in another workspace returns `404 { code: "session_not_found" }`; archived sessions return `409 session_archived`. Invalid formats return `400 invalid_export_format`, and storage races retain the existing `session_archiving` and `session_conflict` errors.

### `POST /session/:id/resume`

Restore a persisted ACP session by id WITHOUT replaying history through SSE. The model context is restored internally on the agent side (via `geminiClient.initialize` reading `config.getResumedSessionData`); the SSE stream stays clean for clients that already have history rendered. Pre-flight `caps.features.session_resume`; `unstable_session_resume` remains a deprecated compatibility alias for older clients.
Expand Down
Loading
Loading