From b4fe43741aa0403e20d0ecf467a1e0e4ac0671d6 Mon Sep 17 00:00:00 2001 From: jinye Date: Wed, 1 Jul 2026 16:24:26 +0800 Subject: [PATCH] feat(daemon): Add session archive support (#6058) * feat(daemon): add session archive support Add active versus archived daemon session storage, archive and unarchive APIs, strict live-session close handling, ACP and SDK support, and coverage for archive listing, load rejection, deletion, and route mapping. Closes #6057 Co-authored-by: Qwen-Coder * codex: fix CI failure on PR #6058 Update the no-AK integration capability baseline to include the new session_archive capability added by this PR. Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6058) Co-authored-by: Qwen-Coder * fix(daemon): preserve live session on strict close failure Co-authored-by: Qwen-Coder * fix(daemon): serialize session delete with archive transitions Co-authored-by: Qwen-Coder * refactor(daemon): share session archive orchestration Co-authored-by: Qwen-Coder * fix(daemon): clean up strict close failures Co-authored-by: Qwen-Coder * docs(daemon): clarify archive recovery tradeoffs Co-authored-by: Qwen-Coder * fix(daemon): log session archive outcomes Co-authored-by: Qwen-Coder * fix(daemon): parallelize archive session closes Co-authored-by: Qwen-Coder * fix(daemon): serialize archive restore races Co-authored-by: Qwen-Coder * fix(daemon): satisfy archive lint checks Co-authored-by: Qwen-Coder * fix(daemon): keep strict close retryable Co-authored-by: Qwen-Coder * fix(daemon): distinguish archive conflicts Co-authored-by: Qwen-Coder * fix(core): warn on unreadable session heads Co-authored-by: Qwen-Coder * docs(core): document active-only session helpers Co-authored-by: Qwen-Coder * fix(daemon): tighten archive review edges Co-authored-by: Qwen-Coder * fix(daemon): allow concurrent session restores during archive gate Co-authored-by: Qwen-Coder * fix(daemon): avoid archive head reads on session restore Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6058) Co-authored-by: Qwen-Coder * fix(sdk): account for session archive bundle size Co-authored-by: Qwen-Coder * codex: address archive gate review (#6058) Co-authored-by: Qwen-Coder * codex: address archive review follow-ups (#6058) Co-authored-by: Qwen-Coder * codex: address ACP archive review feedback (#6058) Co-authored-by: Qwen-Coder * fix(daemon): Address session archive review feedback Co-authored-by: Qwen-Coder * test(daemon): Cover close ownership restoration Co-authored-by: Qwen-Coder * fix(daemon): Address archive review feedback Co-authored-by: Qwen-Coder * test(daemon): Cover ACP close prompt fallback Co-authored-by: Qwen-Coder * test(daemon): Cover archive close channel loss Co-authored-by: Qwen-Coder * fix(daemon): Address archive follow-up review Co-authored-by: Qwen-Coder * fix(daemon): Return archiving conflict for delete gate Co-authored-by: Qwen-Coder * fix(core): Treat unreadable archived ids as occupied Co-authored-by: Qwen-Coder * refactor(daemon): Share session delete orchestration Co-authored-by: Qwen-Coder * fix(daemon): Address archive review feedback Co-authored-by: Qwen-Coder * fix(deps): Clear critical runtime audit failures Co-authored-by: Qwen-Coder * fix(deps): Sync runtime dependency ranges with main Co-authored-by: Qwen-Coder --------- Co-authored-by: Qwen-Coder --- .../developers/daemon/08-session-lifecycle.md | 22 +- .../daemon/11-capabilities-versioning.md | 2 +- docs/developers/qwen-serve-protocol.md | 120 +- .../cli/qwen-serve-routes.test.ts | 1 + package-lock.json | 12 +- packages/acp-bridge/src/bridge.test.ts | 48 + packages/acp-bridge/src/bridge.ts | 39 +- packages/acp-bridge/src/bridgeErrors.ts | 40 + packages/acp-bridge/src/bridgeTypes.ts | 3 + .../cli/src/acp-integration/acpAgent.test.ts | 59 + packages/cli/src/acp-integration/acpAgent.ts | 36 +- packages/cli/src/config/config.ts | 13 +- packages/cli/src/serve/acp-http/dispatch.ts | 795 ++++---- packages/cli/src/serve/acp-http/index.ts | 3 + .../cli/src/serve/acp-http/transport.test.ts | 864 ++++++++- packages/cli/src/serve/acp-session-bridge.ts | 3 + packages/cli/src/serve/capabilities.ts | 1 + packages/cli/src/serve/routes/session.ts | 1629 +++++++++-------- packages/cli/src/serve/server.test.ts | 938 +++++++++- packages/cli/src/serve/server.ts | 4 + .../cli/src/serve/server/error-response.ts | 28 + .../src/serve/server/session-archive.test.ts | 333 ++++ .../cli/src/serve/server/session-archive.ts | 442 +++++ packages/cli/src/serve/server/session-list.ts | 18 +- .../core/src/services/sessionService.test.ts | 541 +++++- packages/core/src/services/sessionService.ts | 362 +++- packages/sdk-typescript/scripts/build.js | 7 +- .../src/daemon/AcpHttpTransport.ts | 7 +- .../src/daemon/AcpWsTransport.ts | 7 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 54 +- .../src/daemon/acpRouteTable.ts | 38 + .../src/daemon/acpTransportUtils.ts | 15 + packages/sdk-typescript/src/daemon/index.ts | 3 + packages/sdk-typescript/src/daemon/types.ts | 17 + .../test/unit/DaemonClient.test.ts | 63 + .../test/unit/acpRouteTable.test.ts | 33 + .../test/unit/acpTransportUtils.test.ts | 22 + 37 files changed, 5399 insertions(+), 1223 deletions(-) create mode 100644 packages/cli/src/serve/server/session-archive.test.ts create mode 100644 packages/cli/src/serve/server/session-archive.ts create mode 100644 packages/sdk-typescript/test/unit/acpTransportUtils.test.ts diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index 47ba304e2c..01cbb85f72 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -205,8 +205,26 @@ attach or subscriber remains, the session is reaped. The endpoint returns 204. ### Batch Session Delete `POST /sessions/delete` accepts `{ sessionIds: string[] }` (up to 100 ids), -closes bridge sessions, and deletes transcript files. It uses -`Promise.allSettled` for resilience and returns `{ removed, notFound, errors }`. +closes bridge sessions, and deletes active or archived transcript files. If both +active and archived JSONL files exist for the same id, hard delete removes both +so operators can clear the conflict. It cleans active and archived worktree +sidecars, but leaves file-history snapshots, subagent transcripts, and runtime +sidecars intact. It uses `Promise.allSettled` for resilience and returns +`{ removed, notFound, errors }`. + +### Session Archive + +`POST /sessions/archive` moves inactive session JSONL files from `chats/` into +`chats/archive/`. If the target session is live, the daemon first enters a +per-session archive gate and performs a strict close that requires the ACP child +to flush `ChatRecordingService`; archive leaves the JSONL in place if close or +flush fails. + +`POST /sessions/unarchive` moves archived JSONL files back to `chats/`. This is +only a storage-state transition; clients must call `session/load` or +`session/resume` afterward. Archived sessions return `409 session_archived` for +load/resume, and mutations racing an archive transition return +`409 session_archiving`. ### Context Usage (`session_context_usage` capability tag) diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index cbcfce1769..3e32b212a7 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -97,7 +97,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_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_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`. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 9c5ea40d0f..105ab29748 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -111,6 +111,30 @@ Attaches to existing sessions are NOT counted toward the cap, so an idle daemon' Fired when a `session/load` is issued for an id that already has a `session/resume` in flight (or vice versa). Wait at least `Retry-After` seconds and retry — the underlying restore completes within `initTimeoutMs` (default 10s). Same-action races (`load` vs `load`, `resume` vs `resume`) coalesce instead of erroring. +`SessionArchivedError` is emitted when a caller tries to load or resume a session whose JSONL is under `chats/archive/`: + +```json +{ + "error": "Session \"\" is archived. Unarchive it before loading.", + "code": "session_archived", + "sessionId": "" +} +``` + +with status `409`. + +`SessionArchivingError` is emitted when a session archive or unarchive transition is already in flight for the same id: + +```json +{ + "error": "Session \"\" is being archived or unarchived; retry later.", + "code": "session_archiving", + "sessionId": "" +} +``` + +with status `409` and `Retry-After: 5`. + ## Capabilities The daemon advertises its supported feature tags from the serve capability @@ -130,7 +154,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_preflight', 'session_context', 'session_context_usage', 'session_supported_commands', 'session_tasks', 'session_stats', 'session_lsp', 'session_status', - 'session_close', 'session_metadata', 'mcp_guardrails', + 'session_close', 'session_metadata', 'session_archive', 'mcp_guardrails', 'workspace_mcp_manage', 'mcp_guardrail_events', 'mcp_server_runtime_mutation', 'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write', @@ -159,6 +183,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_close` and `session_metadata` advertise `DELETE /session/:id` and `PATCH /session/:id/metadata`. Older daemons return `404`; pre-flight these tags before exposing close or rename affordances. +`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. + `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. `session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list. @@ -1151,6 +1177,9 @@ Response: - `400` — `workspace_mismatch` (same shape as `POST /session`). - `503` — `session_limit_exceeded` (counts against `--max-sessions`; in-flight restores are accounted for too). - `409` — `restore_in_progress` (a `session/resume` for the same id is already in flight). `Retry-After: 5`. Same-action races (two concurrent `session/load` for the same id) coalesce — exactly one returns `attached: false`, the rest return `attached: true` with the same `state`. +- `409` — `session_archived` when the id exists only under `chats/archive/`; call `POST /sessions/unarchive` before `load` or `resume`. +- `409` — `session_archiving` when archive or unarchive is in flight for the same id. `Retry-After: 5`. +- `409` — `session_conflict` when the id exists in both `chats/` and `chats/archive/`; delete the session with `POST /sessions/delete` before loading. ### `POST /session/:id/resume` @@ -1164,12 +1193,21 @@ Use `/load` when the client has no history rendered (cold reconnect, picker → ### `GET /workspace/:id/sessions` -List all live sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd). +List persisted sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd). The default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. `archiveState=all` is not supported in v1. ```bash curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions +curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions?archiveState=archived ``` +Query parameters: + +| Field | Required | Notes | +| -------------- | -------- | ------------------------------------------------------------------------------------------------------- | +| `archiveState` | no | `active` (default) or `archived`. Any other value returns `400 { code: "invalid_archive_state" }`. | +| `cursor` | no | Pagination cursor from the previous response. | +| `size` | no | Page size. Invalid values return `400 { code: "invalid_cursor" }` or the existing page-size validation. | + Response: ```json @@ -1181,13 +1219,85 @@ Response: "createdAt": "2026-05-17T08:30:00.000Z", "displayName": "My Session", "clientCount": 2, - "hasActivePrompt": false + "hasActivePrompt": false, + "isArchived": false } - ] + ], + "nextCursor": 1772251200000 } ``` -Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. +Active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Archived lists are storage-only: `isArchived` is `true`, and live overlay fields remain absent or false. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. + +### `POST /sessions/delete` + +Hard-delete one or more persisted session JSONL files. The daemon first best-effort closes live sessions, then removes the active or archived JSONL. If both active and archived copies exist for the same id, both are removed. Worktree sidecars on both sides are cleaned; file history, subagent transcripts, and runtime sidecars are intentionally preserved. + +Request: + +```json +{ "sessionIds": [""] } +``` + +Response: + +```json +{ + "removed": [""], + "notFound": [], + "errors": [] +} +``` + +### `POST /sessions/archive` + +Archive one or more sessions. Archive is a state transition, not deletion: the JSONL moves from `chats/.jsonl` to `chats/archive/.jsonl`. File history, subagent transcripts, and runtime sidecars stay in place. If a session is live, the daemon first performs a strict close and requires the ACP agent's close handler to flush the chat recording; if close or flush fails, the JSONL is not moved. Pre-flight `caps.features.session_archive`. + +Request: + +```json +{ "sessionIds": [""] } +``` + +`sessionIds` must be a non-empty string array with at most 100 ids. Duplicates are collapsed. + +Response: + +```json +{ + "archived": [""], + "alreadyArchived": [], + "notFound": [], + "errors": [] +} +``` + +`errors` entries have `{ "sessionId": "", "error": "message" }`. Active and archived files with the same id are treated as a conflict and reported in `errors`; no file is overwritten. + +### `POST /sessions/unarchive` + +Restore archived sessions to the active directory. This does not resume the session by itself; it only moves `chats/archive/.jsonl` back to `chats/.jsonl`. After unarchive succeeds, clients may call `POST /session/:id/load` or `POST /session/:id/resume`. + +Request: + +```json +{ "sessionIds": [""] } +``` + +Response: + +```json +{ + "unarchived": [""], + "alreadyActive": [], + "notFound": [], + "errors": [] +} +``` + +If an active JSONL already exists for the id, unarchive reports a conflict in `errors` and does not overwrite it. Archive or unarchive in flight for the same id returns `409 session_archiving` before starting the batch. + +ACP-over-HTTP uses the same request and response bodies through vendor methods `_qwen/sessions/archive` and `_qwen/sessions/unarchive`. The REST route table maps `POST /sessions/archive` and `POST /sessions/unarchive` to those methods for ACP transports. ### `POST /session/:id/prompt` diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 3ea1c34acd..5c5197b8fc 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -271,6 +271,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_lsp', 'session_status', 'session_close', + 'session_archive', 'session_metadata', 'mcp_guardrails', 'workspace_mcp_manage', diff --git a/package-lock.json b/package-lock.json index fdcf12ae3b..29f9dd66ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12434,9 +12434,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -17158,9 +17158,9 @@ } }, "node_modules/npm-run-all2/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 146d01c2f8..bfb6f7d032 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -8973,6 +8973,54 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('preserves bridge state when required agent close fails so retry can flush', async () => { + let failClose = true; + const handle = makeChannel({ + extMethodImpl: (method) => { + if (method === 'qwen/control/session/close' && failClose) { + failClose = false; + throw new Error('flush failed'); + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.closeSession(session.sessionId, undefined, { + requireAgentClose: true, + }), + ).rejects.toThrow(); + + expect(handle.agent.extMethodCalls).toHaveLength(1); + expect(handle.agent.extMethodCalls[0]).toEqual({ + method: 'qwen/control/session/close', + params: { sessionId: session.sessionId, requireFlush: true }, + }); + expect(bridge.sessionCount).toBe(1); + expect(() => + bridge.recordHeartbeat(session.sessionId, { + clientId: session.clientId, + }), + ).not.toThrow(); + + await bridge.closeSession(session.sessionId, undefined, { + requireAgentClose: true, + }); + + expect(handle.agent.extMethodCalls).toHaveLength(2); + expect(() => + bridge.recordHeartbeat(session.sessionId, { + clientId: session.clientId, + }), + ).toThrow(SessionNotFoundError); + + await bridge.shutdown(); + }); + it('resolves pending permissions as cancelled', async () => { let capturedConn: AgentSideConnection | undefined; const factory: ChannelFactory = async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 85b4a2c1bc..2b780c10db 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1985,13 +1985,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry: SessionEntry, ci: ChannelInfo | undefined, label: 'closeSession' | 'killSession', + opts?: { throwOnFailure?: boolean; requireFlush?: boolean }, ): Promise => { - if (!ci || ci.channel !== entry.channel) return; + if (!ci || ci.channel !== entry.channel) { + if (opts?.throwOnFailure === true) { + writeStderrLine( + `qwen serve: ${label} ACP session close channel unavailable ` + + `for session ${JSON.stringify(entry.sessionId)}; agent close skipped`, + ); + throw new Error( + `ACP session close channel unavailable for ${entry.sessionId}`, + ); + } + return; + } try { await Promise.race([ withTimeout( entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionClose, { sessionId: entry.sessionId, + ...(opts?.requireFlush === true ? { requireFlush: true } : {}), }), initTimeoutMs, SERVE_CONTROL_EXT_METHODS.sessionClose, @@ -2005,6 +2018,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { err instanceof Error ? err.message : err, )}`, ); + if (opts?.throwOnFailure === true) { + throw err; + } } }; @@ -2664,15 +2680,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { `for session ${JSON.stringify(sessionId)} — channel cleanup skipped (entry's channel already torn down)`, ); } + const requireAgentClose = closeOpts?.requireAgentClose === true; + if (requireAgentClose) { + await notifyAgentSessionClose(entry, ci, 'closeSession', { + throwOnFailure: true, + requireFlush: true, + }); + } if (ci && ci.channel === entry.channel) { ci.sessionIds.delete(sessionId); } - // Synchronous teardown block — intentionally diverges from killSession: - // tombstone + event publish + bus close all run BEFORE - // notifyAgentSessionClose, so concurrent callers see - // byId.get(sessionId) === undefined and throw SessionNotFoundError, - // and late agent frames arriving during the RPC are dropped by the - // closed bus. + // For normal close, tombstone + event publish + bus close run before the + // best-effort agent notification. Strict archive close is different: the + // agent flush must succeed before bridge state is removed, so a failed + // archive close can be retried against the same live session. permissionMediator.forgetSession(sessionId); entry.pendingPermissionIds.clear(); if (entry.promptActive) { @@ -2706,7 +2727,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // `session_closed` is terminal. Close the bus before ACP cancel so any // late cancellation frames from the agent are intentionally dropped. entry.events.close(); - await notifyAgentSessionClose(entry, ci, 'closeSession'); + if (!requireAgentClose) { + await notifyAgentSessionClose(entry, ci, 'closeSession'); + } try { await telemetry.withSpan( 'session.close.cancel_active_prompt', diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 3ca9dae236..b6ecac3fc5 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -64,6 +64,46 @@ export class SessionNotFoundError extends Error { } } +export class SessionArchivedError extends Error { + readonly sessionId: string; + + constructor(sessionId: string) { + super(`Session "${sessionId}" is archived. Unarchive it before loading.`); + this.name = 'SessionArchivedError'; + this.sessionId = sessionId; + } +} + +export class SessionConflictError extends Error { + readonly sessionId: string; + + constructor(sessionId: string) { + super( + `Session "${sessionId}" exists in both active and archived directories. ` + + `Delete the session with POST /sessions/delete before loading.`, + ); + this.name = 'SessionConflictError'; + this.sessionId = sessionId; + } +} + +export class SessionArchivingError extends Error { + readonly sessionId: string; + readonly lockKind: 'exclusive' | 'shared'; + + constructor( + sessionId: string, + lockKind: 'exclusive' | 'shared' = 'exclusive', + ) { + super( + `Session "${sessionId}" is being archived or unarchived; retry later.`, + ); + this.name = 'SessionArchivingError'; + this.sessionId = sessionId; + this.lockKind = lockKind; + } +} + export class RestoreInProgressError extends Error { readonly sessionId: string; readonly activeAction: 'load' | 'resume'; diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 7f1491e90e..8705e919a8 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -157,6 +157,7 @@ export interface BridgeSessionSummary { displayName?: string; clientCount: number; hasActivePrompt: boolean; + isArchived?: boolean; } export interface SessionMetadataUpdate { @@ -166,6 +167,8 @@ export interface SessionMetadataUpdate { export interface CloseSessionOpts { /** Override the default `'client_close'` reason in the `session_closed` event. */ reason?: string; + /** Require the ACP child to acknowledge session close before resolving. */ + requireAgentClose?: boolean; } export interface BridgeClientRequestContext { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index b8222dac32..8f077842e7 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -293,6 +293,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ updatedModelProviders: {}, }), unregisterGoalHook: vi.fn(), + uiTelemetryService: { + removeSession: vi.fn(), + }, runManagedRememberByAgent: mockRunManagedRememberByAgent, clearCachedCredentialFile: vi.fn(), getAllGeminiMdFilenames: vi.fn(() => ['QWEN.md', 'AGENTS.md']), @@ -6204,6 +6207,7 @@ describe('QwenAgent extMethod renameSession routing', () => { | ((conn: AgentSideConnectionLike) => AgentLike) | undefined; let mockConfig: Config; + let liveCancelPendingPrompt: ReturnType; // Live session sessionId is whatever `getSessionId()` on the inner config // returns; matches the existing test scaffolding. @@ -6213,6 +6217,7 @@ describe('QwenAgent extMethod renameSession routing', () => { vi.clearAllMocks(); mockConnectionState.reset(); capturedAgentFactory = undefined; + liveCancelPendingPrompt = vi.fn().mockResolvedValue(undefined); vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => { capturedAgentFactory = factory as typeof capturedAgentFactory; @@ -6274,6 +6279,7 @@ describe('QwenAgent extMethod renameSession routing', () => { getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue(undefined), getChatRecordingService: vi.fn().mockReturnValue(recording), }; } @@ -6298,6 +6304,7 @@ describe('QwenAgent extMethod renameSession routing', () => { ({ getId: vi.fn().mockReturnValue(liveSessionId), getConfig: vi.fn().mockReturnValue(innerConfig), + cancelPendingPrompt: liveCancelPendingPrompt, sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), replayHistory: vi.fn().mockResolvedValue(undefined), installRewriter: vi.fn(), @@ -6408,6 +6415,58 @@ describe('QwenAgent extMethod renameSession routing', () => { mockConnectionState.resolve(); await agentPromise; }); + + it('keeps the live session open when strict session close flush fails', async () => { + const recording = makeRecordingService(); + recording.flush.mockRejectedValueOnce(new Error('flush failed')); + const innerConfig = makeLiveSessionInnerConfig(recording); + const toolRegistry = { stop: vi.fn().mockResolvedValue(undefined) }; + innerConfig.getToolRegistry.mockReturnValue(toolRegistry); + const { agent, agentPromise } = await bootAgent(innerConfig); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.extMethod('qwen/control/session/close', { + sessionId: liveSessionId, + requireFlush: true, + }), + ).rejects.toThrow('flush failed'); + expect( + ( + agent as unknown as { + getActiveSessions: () => Array<{ getId: () => string }>; + } + ) + .getActiveSessions() + .map((session) => session.getId()), + ).toContain(liveSessionId); + expect(recording.flush).toHaveBeenCalledOnce(); + expect(liveCancelPendingPrompt).not.toHaveBeenCalled(); + expect(toolRegistry.stop).not.toHaveBeenCalled(); + + await expect( + agent.extMethod('qwen/control/session/close', { + sessionId: liveSessionId, + requireFlush: true, + }), + ).resolves.toEqual({ sessionId: liveSessionId, closed: true }); + expect(recording.flush).toHaveBeenCalledTimes(3); + expect(liveCancelPendingPrompt).toHaveBeenCalledOnce(); + expect(toolRegistry.stop).toHaveBeenCalledOnce(); + expect( + ( + agent as unknown as { + getActiveSessions: () => Array<{ getId: () => string }>; + } + ) + .getActiveSessions() + .map((session) => session.getId()), + ).not.toContain(liveSessionId); + + mockConnectionState.resolve(); + await agentPromise; + }); }); describe('QwenAgent unstable_listSessions cursor parsing', () => { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 0c343a9b3e..8b8031d1ef 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2616,13 +2616,38 @@ class QwenAgent implements Agent { } } - private async closeStoredSession(sessionId: string): Promise { + private async closeStoredSession( + sessionId: string, + opts?: { requireFlush?: boolean }, + ): Promise { const session = this.sessions.get(sessionId); if (!session) { this.mcpPool?.releaseSession(sessionId); return; } + const requireFlush = opts?.requireFlush === true; + const flushRecording = async (): Promise => { + try { + await session.getConfig().getChatRecordingService()?.flush(); + return undefined; + } catch (err) { + debugLogger.debug( + `Session ${sessionId} chat recording flush during close failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return err; + } + }; + + if (requireFlush) { + const preCancelFlushError = await flushRecording(); + if (preCancelFlushError !== undefined) { + throw preCancelFlushError; + } + } + try { await session.cancelPendingPrompt(); } catch (err) { @@ -2633,6 +2658,11 @@ class QwenAgent implements Agent { ); } + const flushError = await flushRecording(); + if (flushError !== undefined && requireFlush) { + throw flushError; + } + try { await session.getConfig().getToolRegistry()?.stop(); } catch (err) { @@ -5874,7 +5904,9 @@ class QwenAgent implements Agent { 'Invalid or missing sessionId', ); } - await this.closeStoredSession(sessionId); + await this.closeStoredSession(sessionId, { + requireFlush: params['requireFlush'] === true, + }); return { sessionId, closed: true }; } case SERVE_CONTROL_EXT_METHODS.sessionCd: { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9d9be7bcec..670211af74 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1411,7 +1411,8 @@ export async function loadCliConfig( ): Promise { const debugMode = isDebugMode(argv); const bareMode = isBareMode(argv.bare); - const safeMode = argv.safeMode !== undefined ? argv.safeMode : isSafeModeEnv(); + const safeMode = + argv.safeMode !== undefined ? argv.safeMode : isSafeModeEnv(); // Surface `--insecure` as an env var so it reaches the undici dispatcher // layer (which controls TLS verification) without threading a flag through @@ -1855,9 +1856,11 @@ export async function loadCliConfig( // Use provided session ID without session resumption // Check if session ID is already in use const sessionService = new SessionService(cwd); - const exists = await sessionService.sessionExists(argv['sessionId']); + const exists = await sessionService.sessionExistsInAnyState( + argv['sessionId'], + ); if (exists) { - const message = `Error: Session Id ${argv['sessionId']} is already in use.`; + const message = `Error: Session Id ${argv['sessionId']} already exists (active or archived). Delete or unarchive it first.`; writeStderrLine(message); process.exit(1); } @@ -2093,9 +2096,7 @@ export async function loadCliConfig( ? false : (settings.memory?.enableTeamMemorySync ?? false), enableAutoSkill: - bareMode || safeMode - ? false - : (settings.memory?.enableAutoSkill ?? true), + bareMode || safeMode ? false : (settings.memory?.enableAutoSkill ?? true), autoSkillConfirm: bareMode || safeMode ? false diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 993f8932a5..d16c53ac86 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -15,6 +15,7 @@ import { WorkspaceMemoryFileTooLargeError, WorkspaceMemoryWriteTimeoutError, writeWorkspaceContextFile, + type SessionArchiveState, type SubagentLevel, } from '@qwen-code/qwen-code-core'; // Import the permission error classes from the same module REST's @@ -25,6 +26,7 @@ import { InvalidPermissionOptionError, PermissionForbiddenError, PermissionPolicyNotImplementedError, + SessionArchivingError, } from '../acp-session-bridge.js'; import { FsError } from '../fs/errors.js'; import { @@ -37,7 +39,9 @@ import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { SessionShellClientRequiredError, SessionShellDisabledError, + WorkspaceMismatchError, } from '@qwen-code/acp-bridge/bridgeErrors'; +import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js'; import { @@ -76,6 +80,14 @@ import { InvalidCursorError, listWorkspaceSessionsForResponse, } from '../server.js'; +import { + archiveDaemonSessions, + assertSessionLoadable, + deleteDaemonSessions, + logSessionArchiveWarning, + SessionArchiveCoordinator, + unarchiveDaemonSessions, +} from '../server/session-archive.js'; import type { DaemonWorkspaceService, WorkspaceRequestContext, @@ -173,6 +185,8 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}workspace/mcp/servers/add`, `${QWEN_METHOD_NS}workspace/mcp/servers/remove`, `${QWEN_METHOD_NS}sessions/delete`, + `${QWEN_METHOD_NS}sessions/archive`, + `${QWEN_METHOD_NS}sessions/unarchive`, // Wave 2: agents `${QWEN_METHOD_NS}workspace/agents/list`, `${QWEN_METHOD_NS}workspace/agents/get`, @@ -452,6 +466,33 @@ function toRpcError(err: unknown): { } const name = err instanceof Error ? err.name : ''; switch (name) { + case 'SessionArchivedError': + return { + code: RPC.INTERNAL_ERROR, + message: errMsg(err), + data: { + errorKind: 'session_archived', + sessionId: (err as { sessionId?: unknown }).sessionId, + }, + }; + case 'SessionConflictError': + return { + code: RPC.INTERNAL_ERROR, + message: errMsg(err), + data: { + errorKind: 'session_conflict', + sessionId: (err as { sessionId?: unknown }).sessionId, + }, + }; + case 'SessionArchivingError': + return { + code: RPC.INTERNAL_ERROR, + message: errMsg(err), + data: { + errorKind: 'session_archiving', + sessionId: (err as { sessionId?: unknown }).sessionId, + }, + }; case 'SessionNotFoundError': case 'InvalidSessionScopeError': case 'WorkspaceMismatchError': @@ -513,6 +554,7 @@ export class AcpDispatcher { private readonly deviceFlowRegistry?: DeviceFlowRegistry, private readonly sessionShellCommandEnabled: boolean = false, private readonly registry?: ConnectionRegistry, + private readonly archiveCoordinator: SessionArchiveCoordinator = new SessionArchiveCoordinator(), ) { this.agentManager = createDaemonSubagentManager(boundWorkspace); } @@ -540,6 +582,30 @@ export class AcpDispatcher { }; } + private parseSessionIds(params: Record): string[] { + const sessionIds = params['sessionIds']; + if ( + !Array.isArray(sessionIds) || + sessionIds.length === 0 || + sessionIds.length > 100 || + !sessionIds.every((s) => typeof s === 'string') + ) { + throw new AcpParamError( + '`sessionIds` must be non-empty string array (max 100)', + ); + } + return [...new Set(sessionIds as string[])]; + } + + private serializeSessionErrors( + errors: Array<{ sessionId: string; error: unknown }>, + ): Array<{ sessionId: string; error: string }> { + return errors.map((e) => ({ + sessionId: e.sessionId, + error: errMsg(e.error), + })); + } + /** * Build the bridge context for a per-session call. Echoes the clientId the * bridge STAMPED at create/attach (the connection's own id is unregistered @@ -755,6 +821,18 @@ export class AcpDispatcher { return false; } + private async withMutableOwned( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + fn: () => Promise | void, + ): Promise { + if (!this.requireOwned(conn, sessionId, id)) return; + await this.archiveCoordinator.runSharedMany([sessionId], async () => { + await fn(); + }); + } + private findPendingClientRequest( conn: AcpConnection, id: string, @@ -931,18 +1009,23 @@ export class AcpDispatcher { return; } const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); - const restored = - method === 'session/load' - ? await this.bridge.loadSession({ - sessionId, - workspaceCwd: cwd, - clientId: conn.clientId, - }) - : await this.bridge.resumeSession({ - sessionId, - workspaceCwd: cwd, - clientId: conn.clientId, - }); + const restored = await this.archiveCoordinator.runSharedMany( + [sessionId], + async () => { + await assertSessionLoadable(cwd, sessionId); + return method === 'session/load' + ? await this.bridge.loadSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }) + : await this.bridge.resumeSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }); + }, + ); // Teardown raced the restore — EITHER the whole connection was // destroyed (`conn.destroyed`) OR a `session/close` for this id // started DURING the await (`closingSessions`); in the latter the @@ -995,6 +1078,27 @@ export class AcpDispatcher { } case 'session/list': { + const rawWorkspace = + typeof params['workspaceCwd'] === 'string' + ? params['workspaceCwd'] + : undefined; + let workspaceCwd = + rawWorkspace === undefined + ? this.boundWorkspace + : parseOptionalWorkspaceCwd( + { cwd: rawWorkspace }, + this.boundWorkspace, + ); + if (rawWorkspace !== undefined) { + const requestedWorkspace = canonicalizeWorkspace(workspaceCwd); + if (requestedWorkspace !== this.boundWorkspace) { + throw new WorkspaceMismatchError( + this.boundWorkspace, + requestedWorkspace, + ); + } + workspaceCwd = requestedWorkspace; + } const cursor = typeof params['cursor'] === 'string' ? params['cursor'] : undefined; const meta = isObject(params['_meta']) ? params['_meta'] : undefined; @@ -1002,17 +1106,41 @@ export class AcpDispatcher { typeof meta?.['size'] === 'number' ? (meta['size'] as number) : undefined; + const rawArchiveState = + typeof params['archiveState'] === 'string' + ? params['archiveState'] + : typeof meta?.['archiveState'] === 'string' + ? meta['archiveState'] + : undefined; + let archiveState: SessionArchiveState | undefined; + if (rawArchiveState !== undefined) { + if ( + rawArchiveState !== 'active' && + rawArchiveState !== 'archived' + ) { + throw new AcpParamError( + '`archiveState` must be "active" or "archived"', + ); + } + archiveState = rawArchiveState; + } const result = await listWorkspaceSessionsForResponse( this.bridge, - this.boundWorkspace, - { cursor, size: metaSize }, + workspaceCwd, + { cursor, size: metaSize, archiveState }, ); this.replyConn(conn, id, { sessions: result.sessions.map((s) => ({ sessionId: s.sessionId, + workspaceCwd: s.workspaceCwd, cwd: s.workspaceCwd, - title: s.displayName, + createdAt: s.createdAt, updatedAt: s.updatedAt, + displayName: s.displayName, + title: s.displayName, + clientCount: s.clientCount, + hasActivePrompt: s.hasActivePrompt, + isArchived: s.isArchived === true, })), ...(result.nextCursor != null ? { nextCursor: result.nextCursor } @@ -1024,31 +1152,58 @@ export class AcpDispatcher { case 'session/close': { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; - // Close the ownership gate SYNCHRONOUSLY (before the await) so two - // concurrent `session/close`s don't both pass `requireOwned` — - // the second would otherwise send a misleading error and trigger a - // redundant bridge close. + // Close the ownership gate before the coordinator await so + // concurrent closes from this connection cannot both reach the bridge. conn.ownedSessions.delete(sessionId); - // Mark closing so a concurrent session/load|resume of the SAME id - // can't grant fresh ownership + create a new binding that this - // close's `finally` teardown would then destroy (TOCTOU). conn.closingSessions.add(sessionId); - try { - await this.bridge.closeSession( - sessionId, - this.sessionCtx(conn, sessionId, loopback), - ); - } finally { - // Local teardown must run even if the bridge close throws — - // otherwise the SSE stream, abort controller, buffered frames and - // pending permissions leak until idle TTL. + let closeStarted = false; + const closeSession = async () => { + closeStarted = true; try { - conn.closeSessionStream(sessionId); - } catch (teardownErr) { - writeStderrLine( - `qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(errMsg(teardownErr))}`, + await this.bridge.closeSession( + sessionId, + this.sessionCtx(conn, sessionId, loopback), ); + } finally { + // Local teardown must run even if the bridge close throws — + // otherwise the SSE stream, abort controller, buffered frames and + // pending permissions leak until idle TTL. + try { + conn.closeSessionStream(sessionId); + } catch (teardownErr) { + writeStderrLine( + `qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(errMsg(teardownErr))}`, + ); + } } + }; + try { + try { + await this.archiveCoordinator.runExclusiveMany( + [sessionId], + closeSession, + ); + } catch (err) { + const promptAbort = conn.sessions.get(sessionId)?.promptAbort; + if ( + err instanceof SessionArchivingError && + err.lockKind === 'shared' && + promptAbort !== undefined + ) { + await this.archiveCoordinator.runSharedMany( + [sessionId], + closeSession, + ); + } else { + throw err; + } + } + } catch (err) { + if (!closeStarted) { + conn.ownedSessions.add(sessionId); + } + throw err; + } finally { conn.closingSessions.delete(sessionId); } this.replyConn(conn, id, {}); @@ -1067,61 +1222,67 @@ export class AcpDispatcher { } return; } - if (!this.requireOwned(conn, sessionId, id)) return; - const ctx = this.sessionCtx(conn, sessionId, loopback); - const result = await this.bridge.branchSession( - sessionId, - { - name: - typeof params['name'] === 'string' ? params['name'] : undefined, - }, - ctx, - ); - if (conn.destroyed) { - this.killOrphanSession(result.sessionId); - return; - } - conn.getOrCreateSession(result.sessionId).clientId = result.clientId; - conn.ownSession(result.sessionId); - const configOptions = await this.configOptionsFor(result.sessionId); - const models = this.extractModelState(configOptions); - const modes = this.extractModeState(configOptions); - this.replyConn(conn, id, { - sessionId: result.sessionId, - ...(configOptions ? { configOptions } : {}), - ...(models ? { models } : {}), - ...(modes ? { modes } : {}), + await this.withMutableOwned(conn, sessionId, id, async () => { + const ctx = this.sessionCtx(conn, sessionId, loopback); + const result = await this.bridge.branchSession( + sessionId, + { + name: + typeof params['name'] === 'string' + ? params['name'] + : undefined, + }, + ctx, + ); + if (conn.destroyed) { + this.killOrphanSession(result.sessionId); + return; + } + conn.getOrCreateSession(result.sessionId).clientId = + result.clientId; + conn.ownSession(result.sessionId); + const configOptions = await this.configOptionsFor(result.sessionId); + const models = this.extractModelState(configOptions); + const modes = this.extractModeState(configOptions); + this.replyConn(conn, id, { + sessionId: result.sessionId, + ...(configOptions ? { configOptions } : {}), + ...(models ? { models } : {}), + ...(modes ? { modes } : {}), + }); }); return; } case 'session/cancel': { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - // Abort our local in-flight prompt controller too — cancelSession - // tells the agent to wind down, but the HTTP-side `sendPrompt` - // await must also be released so the session FIFO unblocks. - conn.sessions.get(sessionId)?.promptAbort?.abort(); - await this.bridge.cancelSession( - sessionId, - // Forward client-supplied cancel fields (reason/context) while - // force-stamping sessionId — mirrors the REST surface. - { ...params, sessionId } as Parameters< - HttpAcpBridge['cancelSession'] - >[1], - this.sessionCtx(conn, sessionId, loopback), - ); - // `session/cancel` is normally a notification (no id), but answer - // the request-form so a client that sent an id isn't left hanging. - if (id !== undefined) this.replySession(conn, sessionId, id, {}); + await this.withMutableOwned(conn, sessionId, id, async () => { + // Abort our local in-flight prompt controller too — cancelSession + // tells the agent to wind down, but the HTTP-side `sendPrompt` + // await must also be released so the session FIFO unblocks. + conn.sessions.get(sessionId)?.promptAbort?.abort(); + await this.bridge.cancelSession( + sessionId, + // Forward client-supplied cancel fields (reason/context) while + // force-stamping sessionId — mirrors the REST surface. + { ...params, sessionId } as Parameters< + HttpAcpBridge['cancelSession'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + // `session/cancel` is normally a notification (no id), but answer + // the request-form so a client that sent an id isn't left hanging. + if (id !== undefined) this.replySession(conn, sessionId, id, {}); + }); return; } case 'session/prompt': { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - validatePrompt(params); - await this.handlePrompt(conn, sessionId, id, params, loopback); + await this.withMutableOwned(conn, sessionId, id, async () => { + validatePrompt(params); + await this.handlePrompt(conn, sessionId, id, params, loopback); + }); return; } @@ -1421,38 +1582,13 @@ export class AcpDispatcher { // setters. Replaces the old vendor `_qwen/session/set_model`. case 'session/set_config_option': { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - const configId = String(params['configId'] ?? ''); - const rawValue = params['value']; - const ctx = this.sessionCtx(conn, sessionId, loopback); - // Validate value at the boundary like REST (empty/null is rejected - // rather than forwarded as "" to the bridge). - if (typeof rawValue !== 'string' || rawValue.length === 0) { - if (id !== undefined) { - this.replySession( - conn, - sessionId, - id, - undefined, - error( - id, - RPC.INVALID_PARAMS, - '`value` must be a non-empty string', - ), - ); - } - return; - } - if (configId === 'model') { - await this.bridge.setSessionModel( - sessionId, - { modelId: rawValue } as unknown as Parameters< - HttpAcpBridge['setSessionModel'] - >[1], - ctx, - ); - } else if (configId === 'mode') { - if (!APPROVAL_MODES.includes(rawValue as ApprovalMode)) { + await this.withMutableOwned(conn, sessionId, id, async () => { + const configId = String(params['configId'] ?? ''); + const rawValue = params['value']; + const ctx = this.sessionCtx(conn, sessionId, loopback); + // Validate value at the boundary like REST (empty/null is rejected + // rather than forwarded as "" to the bridge). + if (typeof rawValue !== 'string' || rawValue.length === 0) { if (id !== undefined) { this.replySession( conn, @@ -1462,33 +1598,63 @@ export class AcpDispatcher { error( id, RPC.INVALID_PARAMS, - `invalid mode "${rawValue}" (expected one of: ${APPROVAL_MODES.join(', ')})`, + '`value` must be a non-empty string', ), ); } return; } - await this.bridge.setSessionApprovalMode( - sessionId, - rawValue as ApprovalMode, - { persist: params['persist'] === true }, - ctx, - ); - } else { - if (id !== undefined) { - this.replySession( - conn, + if (configId === 'model') { + await this.bridge.setSessionModel( sessionId, - id, - undefined, - error(id, RPC.INVALID_PARAMS, `Unknown configId: ${configId}`), + { modelId: rawValue } as unknown as Parameters< + HttpAcpBridge['setSessionModel'] + >[1], + ctx, ); + } else if (configId === 'mode') { + if (!APPROVAL_MODES.includes(rawValue as ApprovalMode)) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + `invalid mode "${rawValue}" (expected one of: ${APPROVAL_MODES.join(', ')})`, + ), + ); + } + return; + } + await this.bridge.setSessionApprovalMode( + sessionId, + rawValue as ApprovalMode, + { persist: params['persist'] === true }, + ctx, + ); + } else { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + `Unknown configId: ${configId}`, + ), + ); + } + return; } - return; - } - // Response returns the updated config option set (per ACP). - const configOptions = await this.configOptionsFor(sessionId); - this.replySession(conn, sessionId, id, { configOptions }); + // Response returns the updated config option set (per ACP). + const configOptions = await this.configOptionsFor(sessionId); + this.replySession(conn, sessionId, id, { configOptions }); + }); return; } @@ -1503,32 +1669,33 @@ export class AcpDispatcher { ); return; } - if (!this.requireOwned(conn, sessionId, id)) return; - const modeId = String(params['modeId'] ?? ''); - if (!modeId || !APPROVAL_MODES.includes(modeId as ApprovalMode)) { - if (id !== undefined) { - this.replySession( - conn, - sessionId, - id, - undefined, - error( + await this.withMutableOwned(conn, sessionId, id, async () => { + const modeId = String(params['modeId'] ?? ''); + if (!modeId || !APPROVAL_MODES.includes(modeId as ApprovalMode)) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, id, - RPC.INVALID_PARAMS, - `invalid modeId "${modeId}" (expected one of: ${APPROVAL_MODES.join(', ')})`, - ), - ); + undefined, + error( + id, + RPC.INVALID_PARAMS, + `invalid modeId "${modeId}" (expected one of: ${APPROVAL_MODES.join(', ')})`, + ), + ); + } + return; } - return; - } - const ctx = this.sessionCtx(conn, sessionId, loopback); - await this.bridge.setSessionApprovalMode( - sessionId, - modeId as ApprovalMode, - { persist: false }, - ctx, - ); - this.replySession(conn, sessionId, id, {}); + const ctx = this.sessionCtx(conn, sessionId, loopback); + await this.bridge.setSessionApprovalMode( + sessionId, + modeId as ApprovalMode, + { persist: false }, + ctx, + ); + this.replySession(conn, sessionId, id, {}); + }); return; } @@ -1544,27 +1711,28 @@ export class AcpDispatcher { ); return; } - if (!this.requireOwned(conn, sessionId, id)) return; - const modelId = String(params['modelId'] ?? ''); - if (!modelId) { - if (id !== undefined) { - this.replySession( - conn, - sessionId, - id, - undefined, - error(id, RPC.INVALID_PARAMS, '`modelId` is required'), - ); + await this.withMutableOwned(conn, sessionId, id, async () => { + const modelId = String(params['modelId'] ?? ''); + if (!modelId) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, RPC.INVALID_PARAMS, '`modelId` is required'), + ); + } + return; } - return; - } - const ctx = this.sessionCtx(conn, sessionId, loopback); - await this.bridge.setSessionModel( - sessionId, - { modelId, sessionId }, - ctx, - ); - this.replySession(conn, sessionId, id, {}); + const ctx = this.sessionCtx(conn, sessionId, loopback); + await this.bridge.setSessionModel( + sessionId, + { modelId, sessionId }, + ctx, + ); + this.replySession(conn, sessionId, id, {}); + }); return; } @@ -1603,18 +1771,19 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/update_metadata`: { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - const metadata = isObject(params['metadata']) - ? (params['metadata'] as Record) - : {}; - const result = this.bridge.updateSessionMetadata( - sessionId, - metadata as unknown as Parameters< - HttpAcpBridge['updateSessionMetadata'] - >[1], - this.sessionCtx(conn, sessionId, loopback), - ); - this.replyConn(conn, id, result as unknown); + await this.withMutableOwned(conn, sessionId, id, async () => { + const metadata = isObject(params['metadata']) + ? (params['metadata'] as Record) + : {}; + const result = this.bridge.updateSessionMetadata( + sessionId, + metadata as unknown as Parameters< + HttpAcpBridge['updateSessionMetadata'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); return; } @@ -1993,41 +2162,43 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/recap`: { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.generateSessionRecap( - sessionId, - this.sessionCtx(conn, sessionId, loopback), - ); - this.replyConn(conn, id, result as unknown); + await this.withMutableOwned(conn, sessionId, id, async () => { + const result = await this.bridge.generateSessionRecap( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); return; } case `${QWEN_METHOD_NS}session/btw`: { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - const rawQ = params['question']; - if ( - typeof rawQ !== 'string' || - rawQ.trim().length === 0 || - rawQ.length > BTW_MAX_INPUT_LENGTH - ) { - if (id !== undefined) - conn.sendConn( - error( - id, - RPC.INVALID_PARAMS, - `\`question\` required, non-empty, max ${BTW_MAX_INPUT_LENGTH} chars`, - ), - ); - return; - } - const result = await this.bridge.generateSessionBtw( - sessionId, - rawQ.trim(), - undefined, - this.sessionCtx(conn, sessionId, loopback), - ); - this.replyConn(conn, id, result as unknown); + await this.withMutableOwned(conn, sessionId, id, async () => { + const rawQ = params['question']; + if ( + typeof rawQ !== 'string' || + rawQ.trim().length === 0 || + rawQ.length > BTW_MAX_INPUT_LENGTH + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`question\` required, non-empty, max ${BTW_MAX_INPUT_LENGTH} chars`, + ), + ); + return; + } + const result = await this.bridge.generateSessionBtw( + sessionId, + rawQ.trim(), + undefined, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); return; } @@ -2039,52 +2210,54 @@ export class AcpDispatcher { } return; } - if (!this.requireOwned(conn, sessionId, id)) return; - const binding = conn.sessions.get(sessionId); - const clientId = binding?.clientId; - if (!clientId) { - if (id !== undefined) { - conn.sendConn( - rpcErrorFrame(id, new SessionShellClientRequiredError()), - ); + await this.withMutableOwned(conn, sessionId, id, async () => { + const binding = conn.sessions.get(sessionId); + const clientId = binding?.clientId; + if (!clientId) { + if (id !== undefined) { + conn.sendConn( + rpcErrorFrame(id, new SessionShellClientRequiredError()), + ); + } + return; + } + const rawCmd = params['command']; + if (typeof rawCmd !== 'string' || rawCmd.trim().length === 0) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`command` required and must be non-empty', + ), + ); + return; } - return; - } - const rawCmd = params['command']; - if (typeof rawCmd !== 'string' || rawCmd.trim().length === 0) { - if (id !== undefined) - conn.sendConn( - error( - id, - RPC.INVALID_PARAMS, - '`command` required and must be non-empty', - ), - ); - return; - } - const logSessionId = logSafe(sessionId.slice(0, 8)); - const logClientId = logSafe(String(conn.clientId?.slice(0, 8))); - const logCommand = logSafe(rawCmd.slice(0, 120)); - writeStderrLine( - `qwen serve: /acp session/shell session=${logSessionId} client=${logClientId} cmd=${logCommand}`, - ); - const result = await this.bridge.executeShellCommand( - sessionId, - rawCmd, - binding.abort.signal, - this.sessionCtx(conn, sessionId, loopback), - ); - this.replyConn(conn, id, result as unknown); + const logSessionId = logSafe(sessionId.slice(0, 8)); + const logClientId = logSafe(String(conn.clientId?.slice(0, 8))); + const logCommand = logSafe(rawCmd.slice(0, 120)); + writeStderrLine( + `qwen serve: /acp session/shell session=${logSessionId} client=${logClientId} cmd=${logCommand}`, + ); + const result = await this.bridge.executeShellCommand( + sessionId, + rawCmd, + binding.abort.signal, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); return; } case `${QWEN_METHOD_NS}session/detach`: { const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - const ctx = this.sessionCtx(conn, sessionId, loopback); - await this.bridge.detachClient(sessionId, ctx.clientId); - this.replyConn(conn, id, { ok: true }); + await this.withMutableOwned(conn, sessionId, id, async () => { + const ctx = this.sessionCtx(conn, sessionId, loopback); + await this.bridge.detachClient(sessionId, ctx.clientId); + this.replyConn(conn, id, { ok: true }); + }); return; } @@ -2877,68 +3050,60 @@ export class AcpDispatcher { } case `${QWEN_METHOD_NS}sessions/delete`: { - const sessionIds = params['sessionIds']; - if ( - !Array.isArray(sessionIds) || - sessionIds.length === 0 || - sessionIds.length > 100 || - !sessionIds.every((s) => typeof s === 'string') - ) { - if (id !== undefined) - conn.sendConn( - error( - id, - RPC.INVALID_PARAMS, - '`sessionIds` must be non-empty string array (max 100)', - ), - ); - return; - } - const ids = [...new Set(sessionIds as string[])]; - const closeErrors: Array<{ sessionId: string; error: string }> = []; - const closedIds: string[] = []; - await Promise.allSettled( - ids.map(async (sid) => { - try { - await this.bridge.closeSession(sid); - closedIds.push(sid); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if ( - err instanceof Error && - err.name === 'SessionNotFoundError' - ) { - closedIds.push(sid); - } else { - const safeSessionId = logSafe(sid.slice(0, 8)); - const safeMessage = logSafe(msg); - writeStderrLine( - `qwen serve: /acp sessions/delete closeSession(${safeSessionId}) failed: ${safeMessage}`, - ); - closeErrors.push({ sessionId: sid, error: msg }); - } - } - }), - ); + const ids = this.parseSessionIds(params); const svc = new SessionService(this.boundWorkspace); - const removeResult = await svc.removeSessions(closedIds); - for (const e of removeResult.errors) { - const safeSessionId = logSafe(e.sessionId.slice(0, 8)); - const safeMessage = logSafe(errMsg(e.error)); - writeStderrLine( - `qwen serve: /acp sessions/delete removeSessions(${safeSessionId}) failed: ${safeMessage}`, - ); - } + const result = await deleteDaemonSessions({ + sessionIds: ids, + service: svc, + bridge: this.bridge, + coordinator: this.archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + const safeSessionId = logSafe(sessionId.slice(0, 8)); + const safeMessage = logSafe(error); + writeStderrLine( + `qwen serve: /acp sessions/delete ${phase}Session(${safeSessionId}) failed: ${safeMessage}`, + ); + }, + }); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}sessions/archive`: { + const ids = this.parseSessionIds(params); + const svc = new SessionService(this.boundWorkspace, { + onWarning: logSessionArchiveWarning, + }); + const result = await archiveDaemonSessions({ + sessionIds: ids, + service: svc, + bridge: this.bridge, + coordinator: this.archiveCoordinator, + }); this.replyConn(conn, id, { - removed: removeResult.removed, - notFound: removeResult.notFound, - errors: [ - ...closeErrors, - ...removeResult.errors.map((e) => ({ - sessionId: e.sessionId, - error: errMsg(e.error), - })), - ], + archived: result.archived, + alreadyArchived: result.alreadyArchived, + notFound: result.notFound, + errors: this.serializeSessionErrors(result.errors), + } as unknown); + return; + } + + case `${QWEN_METHOD_NS}sessions/unarchive`: { + const ids = this.parseSessionIds(params); + const svc = new SessionService(this.boundWorkspace, { + onWarning: logSessionArchiveWarning, + }); + const result = await unarchiveDaemonSessions({ + sessionIds: ids, + service: svc, + coordinator: this.archiveCoordinator, + }); + this.replyConn(conn, id, { + unarchived: result.unarchived, + alreadyActive: result.alreadyActive, + notFound: result.notFound, + errors: this.serializeSessionErrors(result.errors), } as unknown); return; } diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index d68106b3ea..74c4f596ab 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -24,6 +24,7 @@ import { import { SseStream } from './sse-stream.js'; import { WsStream } from './ws-stream.js'; import type { RateLimitTier } from '../rate-limit.js'; +import { SessionArchiveCoordinator } from '../server/session-archive.js'; import { RPC, error as rpcError, @@ -193,6 +194,7 @@ export interface MountAcpHttpOptions { allowedOrigins?: ParsedAllowOriginPatterns; /** Effective direct session shell policy for ACP initialize/dispatch. */ sessionShellCommandEnabled?: boolean; + archiveCoordinator?: SessionArchiveCoordinator; /** Shared lane for sessionless workspace remember tasks. */ workspaceRememberLane: WorkspaceRememberTaskLane; /** Rate limit checker for WS messages (WS bypasses Express middleware). */ @@ -324,6 +326,7 @@ export function mountAcpHttp( opts.deviceFlowRegistry, opts.sessionShellCommandEnabled === true, registry, + opts.archiveCoordinator ?? new SessionArchiveCoordinator(), ); dispatcherRef.current = dispatcher; diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 50d04fc266..ad9d810412 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -12,7 +12,10 @@ import type { AddressInfo } from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; import WebSocket from 'ws'; -import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { + BridgeSessionSummary, + HttpAcpBridge, +} from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { CancelSentinelCollisionError, @@ -24,7 +27,7 @@ import { SessionShellClientRequiredError, SessionShellDisabledError, } from '@qwen-code/acp-bridge/bridgeErrors'; -import { SessionService } from '@qwen-code/qwen-code-core'; +import { SessionService, Storage } from '@qwen-code/qwen-code-core'; import { resetHomeEnvBootstrapForTesting, SettingScope, @@ -277,8 +280,10 @@ class FakeBridge { return { sessionId: 'sess-1', lastSeenAt: Date.now() }; } + workspaceSessions: BridgeSessionSummary[] = []; + listWorkspaceSessions() { - return []; + return this.workspaceSessions; } detached: Array<{ sessionId: string; clientId?: string }> = []; @@ -832,6 +837,51 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { await new Promise((r) => setTimeout(r, 30)); // let handle() register ownership } + async function withRuntimeDir( + fn: (runtimeDir: string) => Promise, + ): Promise { + const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + const runtimeDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-acp-archive-'), + ); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; + try { + return await fn(runtimeDir); + } finally { + if (previousRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; + } + await fs.rm(runtimeDir, { recursive: true, force: true }); + } + } + + async function writeStoredSession( + sessionId: string, + state: 'active' | 'archived' = 'active', + ): Promise { + const chatsDir = path.join( + new Storage('/ws').getProjectDir(), + 'chats', + ...(state === 'archived' ? ['archive'] : []), + ); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile( + path.join(chatsDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + uuid: `${sessionId}-user-1`, + parentUuid: null, + sessionId, + timestamp: '2026-06-30T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + cwd: '/ws', + })}\n`, + 'utf8', + ); + } + it('initialize → 200 + Acp-Connection-Id; unknown conn → 404', async () => { await initialize(); const bad = await post('nope', { @@ -2676,6 +2726,102 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(frame.result.ok).toBe(true); }); + it('session/list returns REST-compatible session summary fields', async () => { + bridge.workspaceSessions = [ + { + sessionId: '11111111-bbbb-cccc-dddd-eeeeeeeeeeee', + workspaceCwd: '/ws', + createdAt: '2026-06-30T00:00:00.000Z', + updatedAt: '2026-06-30T00:01:00.000Z', + displayName: 'Listed Session', + clientCount: 1, + hasActivePrompt: false, + isArchived: false, + }, + ]; + + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 13, + method: 'session/list', + params: { workspaceCwd: '/ws' }, + }); + + const [frame] = (await got) as Array<{ + id: number; + result: { sessions: Array> }; + }>; + expect(frame.id).toBe(13); + expect(frame.result.sessions[0]).toMatchObject({ + sessionId: '11111111-bbbb-cccc-dddd-eeeeeeeeeeee', + workspaceCwd: '/ws', + cwd: '/ws', + createdAt: '2026-06-30T00:00:00.000Z', + updatedAt: '2026-06-30T00:01:00.000Z', + displayName: 'Listed Session', + title: 'Listed Session', + clientCount: 1, + hasActivePrompt: false, + isArchived: false, + }); + }); + + it('_qwen/sessions/archive rejects invalid batch params', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 14, + method: '_qwen/sessions/archive', + params: { sessionIds: 'not-an-array' }, + }); + + const [frame] = (await got) as Array<{ + id: number; + error: { code: number; message: string }; + }>; + expect(frame.id).toBe(14); + expect(frame.error.code).toBe(-32602); + expect(frame.error.message).toContain('sessionIds'); + }); + + it('_qwen/sessions/unarchive returns per-id result buckets', async () => { + const sessionId = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee'; + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 15, + method: '_qwen/sessions/unarchive', + params: { sessionIds: [sessionId] }, + }); + + const [frame] = (await got) as Array<{ + id: number; + result: { + unarchived: string[]; + alreadyActive: string[]; + notFound: string[]; + errors: unknown[]; + }; + }>; + expect(frame.id).toBe(15); + expect(frame.result).toEqual({ + unarchived: [], + alreadyActive: [], + notFound: [sessionId], + errors: [], + }); + }); + it('unknown method → JSON-RPC method-not-found on conn stream', async () => { const connId = await initialize(); const connStream = await openStream(connId); @@ -2784,6 +2930,466 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(frame.result.resumed).toBe(true); }); + it.each(['session/load', 'session/resume'])( + '%s rejects archived sessions', + async (method) => { + const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + const runtimeDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-acp-archive-'), + ); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; + const sessionId = '550e8400-e29b-41d4-a716-446655440123'; + try { + const chatsDir = path.join( + new Storage('/ws').getProjectDir(), + 'chats', + 'archive', + ); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile( + path.join(chatsDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + uuid: `${sessionId}-user-1`, + parentUuid: null, + sessionId, + timestamp: '2026-06-30T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'archived' }] }, + cwd: '/ws', + })}\n`, + 'utf8', + ); + + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 211, + method, + params: { sessionId }, + }); + + const [frame] = (await got) as Array<{ + id: number; + error: { code: number; data?: { errorKind?: string } }; + }>; + expect(frame.id).toBe(211); + expect(frame.error.code).toBe(-32603); + expect(frame.error.data?.errorKind).toBe('session_archived'); + } finally { + if (previousRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; + } + await fs.rm(runtimeDir, { recursive: true, force: true }); + } + }, + ); + + it('session/load rejects active/archive conflicts', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440321'; + await writeStoredSession(sessionId); + await writeStoredSession(sessionId, 'archived'); + + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method: 'session/load', + params: { sessionId }, + }); + + const [frame] = (await got) as Array<{ + id: number; + error: { code: number; data?: { errorKind?: string } }; + }>; + expect(frame.id).toBe(212); + expect(frame.error.code).toBe(-32603); + expect(frame.error.data?.errorKind).toBe('session_conflict'); + }); + }); + + it('session/load holds archive gate while restore is in flight', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440124'; + await writeStoredSession(sessionId); + let loadStarted!: () => void; + let releaseLoad!: () => void; + const loadStartedPromise = new Promise((resolve) => { + loadStarted = resolve; + }); + const loadReleasedPromise = new Promise((resolve) => { + releaseLoad = resolve; + }); + bridge.loadSession = async (req) => { + loadStarted(); + await loadReleasedPromise; + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: true, + clientId: 'client-load', + state: { replayed: true }, + }; + }; + + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method: 'session/load', + params: { sessionId }, + }); + await loadStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 213, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + expect(await reader.next()).toMatchObject({ + id: 213, + error: { + code: -32603, + data: { errorKind: 'session_archiving', sessionId }, + }, + }); + + releaseLoad(); + expect(await reader.next()).toMatchObject({ + id: 212, + result: { replayed: true }, + }); + reader.close(); + }); + }); + + it('session/prompt holds archive gate while prompt is in flight', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440127'; + await writeStoredSession(sessionId); + let promptStarted!: () => void; + let releasePrompt!: () => void; + const promptStartedPromise = new Promise((resolve) => { + promptStarted = resolve; + }); + const promptReleasedPromise = new Promise((resolve) => { + releasePrompt = resolve; + }); + bridge.promptBehavior = async () => { + promptStarted(); + await promptReleasedPromise; + return { stopReason: 'end_turn' }; + }; + + const connId = await initialize(); + const connStream = await openStream(connId); + const connReader = frameReader(connStream); + await post(connId, { + jsonrpc: '2.0', + id: 217, + method: 'session/load', + params: { sessionId }, + }); + expect(await connReader.next()).toMatchObject({ id: 217 }); + + const sessionStream = await openStream(connId, sessionId); + const sessionReader = frameReader(sessionStream); + await post(connId, { + jsonrpc: '2.0', + id: 218, + method: 'session/prompt', + params: { + sessionId, + prompt: [{ type: 'text', text: 'hold archive gate' }], + }, + }); + await promptStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 219, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + expect(await connReader.next()).toMatchObject({ + id: 219, + error: { + code: -32603, + data: { errorKind: 'session_archiving', sessionId }, + }, + }); + expect(bridge.closedSessions).toEqual([]); + + releasePrompt(); + expect(await sessionReader.next()).toMatchObject({ + id: 218, + result: { stopReason: 'end_turn' }, + }); + connReader.close(); + sessionReader.close(); + }); + }); + + it('_qwen/session/heartbeat does not wait for archive gate', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440130'; + await writeStoredSession(sessionId); + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + bridge.closeSession = async (sid: string) => { + bridge.closedSessions.push(sid); + closeStarted(); + await closeReleasedPromise; + }; + + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 220, + method: 'session/load', + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ id: 220 }); + + await post(connId, { + jsonrpc: '2.0', + id: 221, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + await closeStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 222, + method: '_qwen/session/heartbeat', + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ + id: 222, + result: { sessionId: 'sess-1' }, + }); + + releaseClose(); + expect(await reader.next()).toMatchObject({ + id: 221, + result: { archived: [sessionId], errors: [] }, + }); + reader.close(); + }); + }); + + it('session/load allows concurrent restores for the same session', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440126'; + await writeStoredSession(sessionId); + let releaseLoad!: () => void; + bridge.gate = new Promise((resolve) => { + releaseLoad = resolve; + }); + + const connId = await initialize(); + const stream = await openStream(connId); + const got = takeFrames(stream, 2); + await post(connId, { + jsonrpc: '2.0', + id: 214, + method: 'session/load', + params: { sessionId }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 215, + method: 'session/load', + params: { sessionId }, + }); + + releaseLoad(); + const frames = (await got) as Array<{ + id: number; + result?: { replayed?: boolean }; + error?: { data?: { errorKind?: string } }; + }>; + expect(frames.map((frame) => frame.id).sort()).toEqual([214, 215]); + expect(frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 214, + result: expect.objectContaining({ replayed: true }), + }), + expect.objectContaining({ + id: 215, + result: expect.objectContaining({ replayed: true }), + }), + ]), + ); + expect(frames.some((frame) => frame.error)).toBe(false); + }); + }); + + it('session/close holds archive gate while close is in flight', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440125'; + await writeStoredSession(sessionId); + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 214, + method: 'session/load', + params: { sessionId }, + }); + expect(await reader.next()).toMatchObject({ id: 214 }); + + let closeStarted!: () => void; + let releaseClose!: () => void; + let secondCloseStarted!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + const secondCloseStartedPromise = new Promise((resolve) => { + secondCloseStarted = resolve; + }); + let closeCount = 0; + bridge.closeSession = async (sid: string) => { + bridge.closedSessions.push(sid); + closeCount++; + if (closeCount === 1) { + closeStarted(); + await closeReleasedPromise; + } else { + secondCloseStarted(); + } + }; + + await post(connId, { + jsonrpc: '2.0', + id: 215, + method: 'session/close', + params: { sessionId }, + }); + await closeStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 216, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + const archiveFrame = await reader.next(); + const raceResult = await Promise.race([ + secondCloseStartedPromise.then(() => 'second-close-started'), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)), + ]); + expect(archiveFrame).toMatchObject({ + id: 216, + error: { + code: -32603, + data: { errorKind: 'session_archiving', sessionId }, + }, + }); + expect(raceResult).toBe('blocked'); + + releaseClose(); + expect(await reader.next()).toMatchObject({ + id: 215, + result: {}, + }); + reader.close(); + }); + }); + + it('session/close falls back to the shared gate for its own in-flight prompt', async () => { + let promptStarted!: () => void; + let promptAborted!: () => void; + const promptStartedPromise = new Promise((resolve) => { + promptStarted = resolve; + }); + const promptAbortedPromise = new Promise((resolve) => { + promptAborted = resolve; + }); + bridge.promptBehavior = async (_s, _q, signal) => { + promptStarted(); + if (signal === undefined) throw new Error('missing prompt signal'); + await new Promise((resolve) => { + signal.addEventListener( + 'abort', + () => { + promptAborted(); + resolve(); + }, + { once: true }, + ); + }); + return { stopReason: 'cancelled' }; + }; + + const connId = await initialize(); + const connStream = await openStream(connId); + const connReader = frameReader(connStream); + await post(connId, { + jsonrpc: '2.0', + id: 230, + method: 'session/new', + params: {}, + }); + expect(await connReader.next()).toMatchObject({ id: 230 }); + const sessionStream = await openStream(connId, 'sess-1'); + + await post(connId, { + jsonrpc: '2.0', + id: 231, + method: 'session/prompt', + params: { + sessionId: 'sess-1', + prompt: [{ type: 'text', text: 'hold shared gate' }], + }, + }); + await promptStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 232, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + + await promptAbortedPromise; + const frames = [await connReader.next(), await connReader.next()]; + expect(frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 231 }), + expect.objectContaining({ id: 232, result: {} }), + ]), + ); + expect(bridge.closedSessions).toEqual(['sess-1']); + connReader.close(); + await sessionStream.body?.cancel().catch(() => {}); + }); + it('session/close reaches the bridge + replies on the conn stream', async () => { const connId = await initialize(); const connStream = await openStream(connId); @@ -4153,22 +4759,18 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { releaseClose(); }); - it('session/load while close races DURING loadSession → post-await reject + rollback', async () => { - // Distinct from the pre-await guard above: here the pre-await - // `closingSessions` check passes, then a `session/close` for the same id - // starts WHILE `loadSession` is awaiting. The post-await re-check - // (dispatch.ts) must detect `closeRaced`, roll back the just-restored - // attach (detachClient, since loadAttached=true), and reply INTERNAL_ERROR. + it('session/close while load is in-flight → close rejected by archive gate', async () => { + // The archive coordinator now covers the whole load/restore await, so a + // same-id close that starts during load is rejected before it can mark the + // session closing or tear down the just-restored binding. let releaseLoad: () => void = () => {}; - let releaseClose: () => void = () => {}; const connId = await initialize(); await newSession(connId); // own sess-1 so session/close passes requireOwned // Arm the gates only AFTER ownership is established — otherwise newSession's // own spawnOrAttach would block on bridge.gate and never grant ownership. bridge.gate = new Promise((r) => (releaseLoad = r)); - bridge.closeGate = new Promise((r) => (releaseClose = r)); const connStream = await openStream(connId); - const got = takeFrames(connStream, 2); // buffered session/new reply + load reject + const got = takeFrames(connStream, 3); // session/new + close reject + load success await new Promise((r) => setTimeout(r, 50)); // Load goes in-flight (awaits bridge.gate); pre-await closingSessions empty. void post(connId, { @@ -4186,18 +4788,34 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { params: { sessionId: 'sess-1' }, }); await new Promise((r) => setTimeout(r, 20)); - releaseLoad(); // loadSession resolves → post-await sees closeRaced + releaseLoad(); // loadSession resolves after close has been rejected. const frames = (await got) as Array<{ id: number; - error?: { code: number; message: string }; + result?: { replayed?: boolean }; + error?: { code: number; message: string; data?: { errorKind?: string } }; }>; + const closeReply = frames.find((f) => f.id === 341); + expect(closeReply?.error?.code).toBe(-32603); + expect(closeReply?.error?.data?.errorKind).toBe('session_archiving'); const loadReply = frames.find((f) => f.id === 340); - expect(loadReply?.error?.code).toBe(-32603); - expect(loadReply?.error?.message).toContain('closed during load'); - // attached:true → rollback is a detach, NOT a kill. - expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + expect(loadReply?.result?.replayed).toBe(true); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(false); expect(bridge.killed).not.toContain('sess-1'); - releaseClose(); + + const retryCloseStream = await openStream(connId); + const retryCloseReader = frameReader(retryCloseStream); + await post(connId, { + jsonrpc: '2.0', + id: 342, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + expect(await retryCloseReader.next()).toMatchObject({ + id: 342, + result: {}, + }); + retryCloseReader.close(); + expect(bridge.closedSessions).toEqual(['sess-1']); }); it('double-failure permission vote → pending retained + retried on teardown', async () => { @@ -4992,18 +5610,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { const bidiOverride = '\u202e'; const sessionId = `sess${lineSep}FAKE\r\x1b[31m`; const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`; - const removeSessionsSpy = vi - .spyOn(SessionService.prototype, 'removeSessions') - .mockResolvedValueOnce({ - removed: [], - notFound: [], - errors: [ - { - sessionId, - error: removeError as unknown as Error, - }, - ], - }); + const removeSessionSpy = vi + .spyOn(SessionService.prototype, 'removeSession') + .mockRejectedValueOnce(new Error(removeError)); try { const connId = await initialize(); @@ -5023,13 +5632,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { errors: [{ sessionId, error: removeError }], }, }); - expect(removeSessionsSpy).toHaveBeenCalledWith([sessionId]); + expect(removeSessionSpy).toHaveBeenCalledWith(sessionId); const deleteLog = stdioMocks.writeStderrLine.mock.calls .map(([line]) => line) .find((line) => line.includes('sessions/delete')); expect(deleteLog).toContain( - 'removeSessions(sess FAK) failed: remove FAILED [31m', + 'removeSession(sess FAK) failed: remove FAILED [31m', ); expect(deleteLog).not.toContain('\n'); expect(deleteLog).not.toContain('\r'); @@ -5037,9 +5646,196 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(deleteLog).not.toContain(lineSep); expect(deleteLog).not.toContain(bidiOverride); } finally { - removeSessionsSpy.mockRestore(); + removeSessionSpy.mockRestore(); } }); + + it('_qwen/sessions/delete deletes available ids when another id is loading', async () => { + await withRuntimeDir(async () => { + const sidOk = '550e8400-e29b-41d4-a716-446655440128'; + const sidBusy = '550e8400-e29b-41d4-a716-446655440129'; + await writeStoredSession(sidOk); + await writeStoredSession(sidBusy); + let loadStarted!: () => void; + let releaseLoad!: () => void; + const loadStartedPromise = new Promise((resolve) => { + loadStarted = resolve; + }); + const loadReleasedPromise = new Promise((resolve) => { + releaseLoad = resolve; + }); + bridge.loadSession = async (req) => { + if (req.sessionId === sidBusy) { + loadStarted(); + await loadReleasedPromise; + } + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: true, + clientId: 'client-load', + state: { replayed: true }, + }; + }; + + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/load', + params: { sessionId: sidBusy }, + }); + await loadStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 71, + method: '_qwen/sessions/delete', + params: { sessionIds: [sidOk, sidBusy] }, + }); + expect(await reader.next()).toMatchObject({ + id: 71, + result: { + removed: [sidOk], + notFound: [], + errors: [expect.objectContaining({ sessionId: sidBusy })], + }, + }); + expect(bridge.closedSessions).toEqual([sidOk]); + + releaseLoad(); + expect(await reader.next()).toMatchObject({ + id: 70, + result: { replayed: true }, + }); + reader.close(); + }); + }); + + it('_qwen/sessions/delete does not make missing archive ids wait on live close', async () => { + const sessionId = 'delete-archive-race'; + let firstCloseStarted!: () => void; + let releaseFirstClose!: () => void; + let secondCloseStarted!: () => void; + const firstCloseStartedPromise = new Promise((resolve) => { + firstCloseStarted = resolve; + }); + const firstCloseReleasedPromise = new Promise((resolve) => { + releaseFirstClose = resolve; + }); + const secondCloseStartedPromise = new Promise((resolve) => { + secondCloseStarted = resolve; + }); + let closeCount = 0; + bridge.closeSession = async (sid: string) => { + bridge.closedSessions.push(sid); + closeCount++; + if (closeCount === 1) { + firstCloseStarted(); + await firstCloseReleasedPromise; + } else { + secondCloseStarted(); + } + }; + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + const deletePost = post(connId, { + jsonrpc: '2.0', + id: 69, + method: '_qwen/sessions/delete', + params: { sessionIds: [sessionId] }, + }); + await deletePost; + await firstCloseStartedPromise; + + const archivePost = post(connId, { + jsonrpc: '2.0', + id: 70, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + await archivePost; + const raceResult = await Promise.race([ + secondCloseStartedPromise.then(() => 'archive-started'), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)), + ]); + + releaseFirstClose(); + try { + expect(raceResult).toBe('blocked'); + const frames = [await reader.next(), await reader.next()]; + expect(frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 69, + result: expect.objectContaining({ notFound: [sessionId] }), + }), + expect.objectContaining({ + id: 70, + result: expect.objectContaining({ notFound: [sessionId] }), + }), + ]), + ); + } finally { + reader.close(); + } + }); + + it('_qwen/sessions/delete returns session_archiving during archive gate', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440132'; + await writeStoredSession(sessionId); + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + bridge.closeSession = async (sid: string) => { + bridge.closedSessions.push(sid); + closeStarted(); + await closeReleasedPromise; + }; + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 72, + method: '_qwen/sessions/archive', + params: { sessionIds: [sessionId] }, + }); + await closeStartedPromise; + + await post(connId, { + jsonrpc: '2.0', + id: 73, + method: '_qwen/sessions/delete', + params: { sessionIds: [sessionId] }, + }); + expect(await reader.next()).toMatchObject({ + id: 73, + error: { + data: { errorKind: 'session_archiving', sessionId }, + }, + }); + + releaseClose(); + try { + expect(await reader.next()).toMatchObject({ + id: 72, + result: expect.objectContaining({ archived: [sessionId] }), + }); + } finally { + reader.close(); + } + }); + }); }); describe('auth methods', () => { diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index a540f42641..3d6f1b3c98 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -87,6 +87,9 @@ export { CdWhilePromptActiveError, SessionNotFoundError, RestoreInProgressError, + SessionArchivedError, + SessionConflictError, + SessionArchivingError, InvalidSessionScopeError, SessionLimitExceededError, PromptQueueFullError, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index a4562c2435..0531d256d2 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -88,6 +88,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_lsp: { since: 'v1' }, session_status: { since: 'v1' }, session_close: { since: 'v1' }, + session_archive: { since: 'v1' }, session_metadata: { since: 'v1' }, // Daemon supports the MCP client guardrail surface: an in-process // counter exposed on `GET /workspace/mcp`, a `--mcp-client-budget=N` diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 85134db8c5..38308fa986 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -12,6 +12,7 @@ import { SessionService, addDaemonRequestAttribute, type ApprovalMode, + type SessionArchiveState, } from '@qwen-code/qwen-code-core'; import type { Application, Request, RequestHandler, Response } from 'express'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; @@ -19,7 +20,6 @@ import { canonicalizeWorkspace, InvalidClientIdError, PromptQueueFullError, - SessionNotFoundError, SessionShellClientRequiredError, SessionShellDisabledError, type AcpSessionBridge, @@ -42,10 +42,19 @@ import { listWorkspaceSessionsForResponse, parseSessionPageSizeQuery, } from '../server/session-list.js'; +import { + archiveDaemonSessions, + assertSessionLoadable, + deleteDaemonSessions, + logSessionArchiveWarning, + type SessionArchiveCoordinator, + unarchiveDaemonSessions, +} from '../server/session-archive.js'; interface RegisterSessionRoutesDeps { boundWorkspace: string; bridge: AcpSessionBridge; + archiveCoordinator: SessionArchiveCoordinator; mutate: (opts?: { strict?: boolean }) => RequestHandler; sendBridgeError: SendBridgeError; daemonLog?: DaemonLogger; @@ -61,6 +70,7 @@ export function registerSessionRoutes( const { boundWorkspace, bridge, + archiveCoordinator, mutate, sendBridgeError, daemonLog, @@ -69,6 +79,56 @@ export function registerSessionRoutes( } = deps; const LANGUAGE_CODES = deps.languageCodes; + const parseSessionIdsBody = ( + req: Request, + res: Response, + ): string[] | undefined => { + const body = safeBody(req); + const sessionIds: unknown = body['sessionIds']; + if ( + !Array.isArray(sessionIds) || + sessionIds.length === 0 || + sessionIds.length > 100 || + !sessionIds.every((id) => typeof id === 'string') + ) { + res.status(400).json({ + error: '`sessionIds` must be a non-empty string array (max 100)', + code: 'invalid_request', + }); + return undefined; + } + return [...new Set(sessionIds as string[])]; + }; + + const serializeSessionErrors = ( + errors: Array<{ sessionId: string; error: unknown }>, + ): Array<{ sessionId: string; error: string }> => + errors.map((e) => ({ + sessionId: e.sessionId, + error: e.error instanceof Error ? e.error.message : String(e.error), + })); + + const withMutableSession = + ( + route: string, + handler: ( + req: Request, + res: Response, + sessionId: string, + ) => Promise | void, + ): RequestHandler => + async (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + try { + await archiveCoordinator.runSharedMany([sessionId], async () => { + await handler(req, res, sessionId); + }); + } catch (err) { + sendBridgeError(res, err, { route, sessionId }); + } + }; + app.post('/session', mutate(), async (req, res) => { const body = safeBody(req); const cwd = parseOptionalWorkspaceCwd(body, boundWorkspace, res); @@ -182,18 +242,23 @@ export function registerSessionRoutes( const clientId = parseClientIdHeader(req, res); if (clientId === null) return; try { - const session = - action === 'load' - ? await bridge.loadSession({ - sessionId, - workspaceCwd: cwd, - ...(clientId !== undefined ? { clientId } : {}), - }) - : await bridge.resumeSession({ - sessionId, - workspaceCwd: cwd, - ...(clientId !== undefined ? { clientId } : {}), - }); + const session = await archiveCoordinator.runSharedMany( + [sessionId], + async () => { + await assertSessionLoadable(cwd, sessionId); + return action === 'load' + ? await bridge.loadSession({ + sessionId, + workspaceCwd: cwd, + ...(clientId !== undefined ? { clientId } : {}), + }) + : await bridge.resumeSession({ + sessionId, + workspaceCwd: cwd, + ...(clientId !== undefined ? { clientId } : {}), + }); + }, + ); if (daemonLog) { daemonLog.info( `session ${action}${session.attached ? ' (attached)' : ''}`, @@ -236,110 +301,102 @@ export function registerSessionRoutes( app.post('/session/:id/load', mutate(), restoreSessionHandler('load')); app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume')); - app.post('/session/:id/branch', mutate(), async (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const body = safeBody(req); - let name = typeof body?.['name'] === 'string' ? body['name'] : undefined; - if (name) { - // eslint-disable-next-line no-control-regex - name = name.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); - if (name.length > 200) { - name = name.slice(0, 200); - } - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - const result = await bridge.branchSession( - sessionId, - { name }, - { clientId }, - ); - if (!res.writable) { - if (!result.attached) { - bridge - .killSession(result.sessionId, { requireZeroAttaches: true }) - .catch(() => { + app.post( + '/session/:id/branch', + mutate(), + withMutableSession( + 'POST /session/:id/branch', + async (req, res, sessionId) => { + const body = safeBody(req); + let name = + typeof body?.['name'] === 'string' ? body['name'] : undefined; + if (name) { + // eslint-disable-next-line no-control-regex + name = name.replace(/[\x00-\x1F\x7F-\x9F]/g, ''); + if (name.length > 200) { + name = name.slice(0, 200); + } + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const result = await bridge.branchSession( + sessionId, + { name }, + { clientId }, + ); + if (!res.writable) { + if (!result.attached) { + bridge + .killSession(result.sessionId, { requireZeroAttaches: true }) + .catch(() => { + // Best-effort cleanup; channel.exited will eventually reap. + }); + } else { + bridge.detachClient(result.sessionId, result.clientId).catch(() => { // Best-effort cleanup; channel.exited will eventually reap. }); - } else { - bridge.detachClient(result.sessionId, result.clientId).catch(() => { - // Best-effort cleanup; channel.exited will eventually reap. - }); + } + return; } + res.status(201).json(result); + }, + ), + ); + + app.post( + '/session/:id/fork', + mutate(), + withMutableSession( + 'POST /session/:id/fork', + async (req, res, sessionId) => { + const body = safeBody(req); + const directive = body['directive']; + if (typeof directive !== 'string' || directive.trim().length === 0) { + res.status(400).json({ + error: '`directive` is required and must be a non-empty string', + code: 'missing_directive', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const result = await bridge.launchSessionForkAgent( + sessionId, + directive, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(202).json(result); + }, + ), + ); + + app.post( + '/session/:id/cd', + mutate(), + withMutableSession('POST /session/:id/cd', async (req, res, sessionId) => { + const body = safeBody(req); + const targetPath = body['path']; + if ( + typeof targetPath !== 'string' || + targetPath.length === 0 || + !path.isAbsolute(targetPath) + ) { + res.status(400).json({ + error: '`path` is required and must be an absolute path', + code: 'invalid_path', + }); return; } - res.status(201).json(result); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/branch', - sessionId, - }); - } - }); - - app.post('/session/:id/fork', mutate(), async (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const body = safeBody(req); - const directive = body['directive']; - if (typeof directive !== 'string' || directive.trim().length === 0) { - res.status(400).json({ - error: '`directive` is required and must be a non-empty string', - code: 'missing_directive', - }); - return; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - const result = await bridge.launchSessionForkAgent( - sessionId, - directive, - clientId !== undefined ? { clientId } : undefined, - ); - res.status(202).json(result); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/fork', - sessionId, - }); - } - }); - - app.post('/session/:id/cd', mutate(), async (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const body = safeBody(req); - const targetPath = body['path']; - if ( - typeof targetPath !== 'string' || - targetPath.length === 0 || - !path.isAbsolute(targetPath) - ) { - res.status(400).json({ - error: '`path` is required and must be an absolute path', - code: 'invalid_path', - }); - return; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; const result = await bridge.changeSessionCwd( sessionId, { path: targetPath }, clientId !== undefined ? { clientId } : undefined, ); res.status(200).json(result); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/cd', - sessionId, - }); - } - }); + }), + ); app.get('/session/:id/status', (req, res) => { const sessionId = requireSessionId(req, res); @@ -452,319 +509,285 @@ export function registerSessionRoutes( app.post( '/session/:id/tasks/:taskId/cancel', mutate({ strict: true }), - async (req, res) => { - const sessionId = req.params['id']; - const taskId = req.params['taskId']; - if (!sessionId || !taskId) { - res.status(400).json({ - error: '`sessionId` and `taskId` route parameters are required', - }); - return; - } - const body = safeBody(req); - const kind = body['kind']; - if (kind !== 'agent' && kind !== 'shell' && kind !== 'monitor') { - res - .status(400) - .json({ error: '`kind` must be "agent", "shell", or "monitor"' }); - return; - } - try { + withMutableSession( + 'POST /session/:id/tasks/:taskId/cancel', + async (req, res, sessionId) => { + const taskId = req.params['taskId']; + if (!taskId) { + res.status(400).json({ + error: '`taskId` route parameter is required', + }); + return; + } + const body = safeBody(req); + const kind = body['kind']; + if (kind !== 'agent' && kind !== 'shell' && kind !== 'monitor') { + res + .status(400) + .json({ error: '`kind` must be "agent", "shell", or "monitor"' }); + return; + } res .status(200) .json(await bridge.cancelSessionTask(sessionId, taskId, kind)); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/tasks/:taskId/cancel', - sessionId, - }); - } - }, + }, + ), ); app.post( '/session/:id/goal/clear', mutate({ strict: true }), - async (req, res) => { - const sessionId = req.params['id']; - if (!sessionId) { - res - .status(400) - .json({ error: '`sessionId` route parameter is required' }); - return; - } - try { + withMutableSession( + 'POST /session/:id/goal/clear', + async (_req, res, sessionId) => { res.status(200).json(await bridge.clearSessionGoal(sessionId)); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/goal/clear', - sessionId, - }); - } - }, + }, + ), ); app.post( '/session/:id/continue', mutate({ strict: true }), - async (req, res) => { - const sessionId = req.params['id']; - if (!sessionId) { - res - .status(400) - .json({ error: '`sessionId` route parameter is required' }); - return; - } - // Forward the originator and a generated promptId so the bridge can - // attribute and correlate the continuation turn (it now runs through the - // prompt-admission path, same as POST /session/:id/prompt). The accepted - // response echoes promptId + lastEventId as the replay/correlation anchor. - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - const promptId = crypto.randomUUID(); - try { + withMutableSession( + 'POST /session/:id/continue', + async (req, res, sessionId) => { + // Forward the originator and a generated promptId so the bridge can + // attribute and correlate the continuation turn (it now runs through the + // prompt-admission path, same as POST /session/:id/prompt). The accepted + // response echoes promptId + lastEventId as the replay/correlation anchor. + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const promptId = crypto.randomUUID(); res.status(200).json( await bridge.continueSession(sessionId, { ...(clientId !== undefined ? { clientId } : {}), promptId, }), ); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/continue', - sessionId, - }); - } - }, + }, + ), ); - app.post('/session/:id/prompt', mutate(), async (req, res) => { - const sessionId = req.params['id']; - const body = safeBody(req); - const prompt = body['prompt']; - if (!Array.isArray(prompt) || prompt.length === 0) { - res.status(400).json({ - error: - '`prompt` is required and must be a non-empty array of content blocks', - }); - return; - } - if ( - !prompt.every( - (item: unknown) => - typeof item === 'object' && item !== null && !Array.isArray(item), - ) - ) { - res.status(400).json({ - error: 'each `prompt` element must be an object (content block)', - }); - return; - } - const rawRequestDeadline = body['deadlineMs']; - let requestDeadlineMs: number | undefined; - if (rawRequestDeadline !== undefined && rawRequestDeadline !== null) { - if ( - typeof rawRequestDeadline !== 'number' || - !Number.isFinite(rawRequestDeadline) || - !Number.isInteger(rawRequestDeadline) || - rawRequestDeadline <= 0 - ) { - res.status(400).json({ - error: '`deadlineMs` must be a positive integer (milliseconds)', - code: 'invalid_deadline_ms', - }); - return; - } - requestDeadlineMs = rawRequestDeadline; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - - const promptId = crypto.randomUUID(); - const forwardedBody = { ...body }; - delete forwardedBody['deadlineMs']; - - let lastEventId: number; - try { - lastEventId = bridge.getSessionLastEventId(sessionId); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/prompt', - sessionId, - }); - return; - } - addDaemonRequestAttribute('qwen-code.prompt_id', promptId); - - const abort = new AbortController(); - let responseFinished = false; - const onResClose = () => { - if (!responseFinished) abort.abort(); - }; - const onResFinish = () => { - responseFinished = true; - res.off('close', onResClose); - }; - res.once('close', onResClose); - res.once('finish', onResFinish); - const effectiveDeadlineMs = resolvePromptDeadlineMs( - promptDeadlineMs, - requestDeadlineMs, - ); - let deadlineTimer: NodeJS.Timeout | undefined; - if (effectiveDeadlineMs !== undefined) { - deadlineTimer = setTimeout(() => { - if (!abort.signal.aborted) { - abort.abort(new PromptDeadlineExceededError(effectiveDeadlineMs)); + app.post( + '/session/:id/prompt', + mutate(), + withMutableSession( + 'POST /session/:id/prompt', + async (req, res, sessionId) => { + const body = safeBody(req); + const prompt = body['prompt']; + if (!Array.isArray(prompt) || prompt.length === 0) { + res.status(400).json({ + error: + '`prompt` is required and must be a non-empty array of content blocks', + }); + return; } - }, effectiveDeadlineMs); - deadlineTimer.unref(); - } + if ( + !prompt.every( + (item: unknown) => + typeof item === 'object' && item !== null && !Array.isArray(item), + ) + ) { + res.status(400).json({ + error: 'each `prompt` element must be an object (content block)', + }); + return; + } + const rawRequestDeadline = body['deadlineMs']; + let requestDeadlineMs: number | undefined; + if (rawRequestDeadline !== undefined && rawRequestDeadline !== null) { + if ( + typeof rawRequestDeadline !== 'number' || + !Number.isFinite(rawRequestDeadline) || + !Number.isInteger(rawRequestDeadline) || + rawRequestDeadline <= 0 + ) { + res.status(400).json({ + error: '`deadlineMs` must be a positive integer (milliseconds)', + code: 'invalid_deadline_ms', + }); + return; + } + requestDeadlineMs = rawRequestDeadline; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; - let promptPromise: ReturnType; - try { - promptPromise = bridge.sendPrompt( - sessionId, - { - ...forwardedBody, - sessionId, - prompt, - } as Parameters[1], - abort.signal, - { - ...(clientId !== undefined ? { clientId } : {}), - promptId, - }, - ); - } catch (err) { - if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); - res.off('close', onResClose); - res.off('finish', onResFinish); - if (daemonLog && err instanceof PromptQueueFullError) { - daemonLog.warn('prompt admission rejected: queue full', { - sessionId, - promptId, - ...(clientId !== undefined ? { clientId } : {}), - limit: err.limit, - pendingCount: err.pendingCount, - }); - } - if (daemonLog && err instanceof InvalidClientIdError) { - daemonLog.warn('prompt admission rejected: invalid client id', { - sessionId, - promptId, - ...(clientId !== undefined ? { clientId } : {}), - }); - } - sendBridgeError(res, err, { - route: 'POST /session/:id/prompt', - sessionId, - }); - return; - } - res.off('close', onResClose); + const promptId = crypto.randomUUID(); + const forwardedBody = { ...body }; + delete forwardedBody['deadlineMs']; - promptPromise - .then( - () => { - if (daemonLog) { - daemonLog.info('prompt turn completed', { + const lastEventId = bridge.getSessionLastEventId(sessionId); + addDaemonRequestAttribute('qwen-code.prompt_id', promptId); + + const abort = new AbortController(); + let responseFinished = false; + const onResClose = () => { + if (!responseFinished) abort.abort(); + }; + const onResFinish = () => { + responseFinished = true; + res.off('close', onResClose); + }; + res.once('close', onResClose); + res.once('finish', onResFinish); + const effectiveDeadlineMs = resolvePromptDeadlineMs( + promptDeadlineMs, + requestDeadlineMs, + ); + let deadlineTimer: NodeJS.Timeout | undefined; + if (effectiveDeadlineMs !== undefined) { + deadlineTimer = setTimeout(() => { + if (!abort.signal.aborted) { + abort.abort(new PromptDeadlineExceededError(effectiveDeadlineMs)); + } + }, effectiveDeadlineMs); + deadlineTimer.unref(); + } + + let promptPromise: ReturnType; + try { + promptPromise = bridge.sendPrompt( + sessionId, + { + ...forwardedBody, + sessionId, + prompt, + } as Parameters[1], + abort.signal, + { + ...(clientId !== undefined ? { clientId } : {}), + promptId, + }, + ); + } catch (err) { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + res.off('close', onResClose); + res.off('finish', onResFinish); + if (daemonLog && err instanceof PromptQueueFullError) { + daemonLog.warn('prompt admission rejected: queue full', { sessionId, promptId, - clientId, + ...(clientId !== undefined ? { clientId } : {}), + limit: err.limit, + pendingCount: err.pendingCount, }); } - }, - (err) => { - if (daemonLog) { - const errName = err instanceof Error ? err.name : undefined; - daemonLog.warn( - `prompt turn failed: ${errName ? `[${errName}] ` : ''}${err instanceof Error ? err.message : String(err)}`, - { sessionId, promptId, clientId }, - ); + if (daemonLog && err instanceof InvalidClientIdError) { + daemonLog.warn('prompt admission rejected: invalid client id', { + sessionId, + promptId, + ...(clientId !== undefined ? { clientId } : {}), + }); } - }, - ) - .finally(() => { - if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); - }) - .catch(() => {}); + sendBridgeError(res, err, { + route: 'POST /session/:id/prompt', + sessionId, + }); + return; + } + res.off('close', onResClose); - if (daemonLog) { - daemonLog.info('prompt enqueued', { sessionId, promptId, clientId }); - } - res.status(202).json({ promptId, lastEventId }); - }); + promptPromise + .then( + () => { + if (daemonLog) { + daemonLog.info('prompt turn completed', { + sessionId, + promptId, + clientId, + }); + } + }, + (err) => { + if (daemonLog) { + const errName = err instanceof Error ? err.name : undefined; + daemonLog.warn( + `prompt turn failed: ${errName ? `[${errName}] ` : ''}${err instanceof Error ? err.message : String(err)}`, + { sessionId, promptId, clientId }, + ); + } + }, + ) + .finally(() => { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + }) + .catch(() => {}); - app.post('/session/:id/heartbeat', mutate(), (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { + if (daemonLog) { + daemonLog.info('prompt enqueued', { sessionId, promptId, clientId }); + } + res.status(202).json({ promptId, lastEventId }); + }, + ), + ); + + app.post( + '/session/:id/heartbeat', + mutate(), + withMutableSession('POST /session/:id/heartbeat', (req, res, sessionId) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; const result = bridge.recordHeartbeat( sessionId, clientId !== undefined ? { clientId } : undefined, ); res.status(200).json(result); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/heartbeat', - sessionId, - }); - } - }); + }), + ); - app.post('/session/:id/detach', mutate(), async (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - await bridge.detachClient(sessionId, clientId); - res.status(204).end(); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/detach', - sessionId, - }); - } - }); + app.post( + '/session/:id/detach', + mutate(), + withMutableSession( + 'POST /session/:id/detach', + async (req, res, sessionId) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + await bridge.detachClient(sessionId, clientId); + res.status(204).end(); + }, + ), + ); - app.post('/session/:id/cancel', mutate(), async (req, res) => { - const sessionId = req.params['id']; - const body = safeBody(req); - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - await bridge.cancelSession( - sessionId, - { - ...(body as object), + app.post( + '/session/:id/cancel', + mutate(), + withMutableSession( + 'POST /session/:id/cancel', + async (req, res, sessionId) => { + const body = safeBody(req); + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + await bridge.cancelSession( sessionId, - } as Parameters[1], - clientId !== undefined ? { clientId } : undefined, - ); - if (daemonLog) { - daemonLog.info('cancel sent', { sessionId, clientId }); - } - res.status(204).end(); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/cancel', - sessionId, - }); - } - }); + { + ...(body as object), + sessionId, + } as Parameters[1], + clientId !== undefined ? { clientId } : undefined, + ); + if (daemonLog) { + daemonLog.info('cancel sent', { sessionId, clientId }); + } + res.status(204).end(); + }, + ), + ); app.delete('/session/:id', async (req, res) => { const sessionId = req.params['id']; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; try { - await bridge.closeSession( - sessionId, - clientId !== undefined ? { clientId } : undefined, + // ACP session/close can fall back to a shared gate because it has + // connection-local promptAbort state; REST close does not. + await archiveCoordinator.runExclusiveMany([sessionId], async () => + bridge.closeSession( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), ); res.status(204).end(); } catch (err) { @@ -778,116 +801,105 @@ export function registerSessionRoutes( app.post('/sessions/delete', mutate(), async (req, res) => { const clientId = parseClientIdHeader(req, res); if (clientId === null) return; - const body = safeBody(req); - const sessionIds: unknown = body['sessionIds']; - if ( - !Array.isArray(sessionIds) || - sessionIds.length === 0 || - sessionIds.length > 100 || - !sessionIds.every((id) => typeof id === 'string') - ) { - res.status(400).json({ - error: '`sessionIds` must be a non-empty string array (max 100)', - code: 'invalid_request', - }); - return; - } + const uniqueIds = parseSessionIdsBody(req, res); + if (uniqueIds === undefined) return; try { - const uniqueIds = [...new Set(sessionIds as string[])]; - const closeResults = await Promise.allSettled( - uniqueIds.map(async (id) => { - // Intentional: no clientId — batch delete bypasses per-tab ownership. - await bridge.closeSession(id); - return id; - }), - ); - const closeErrors: Array<{ sessionId: string; error: string }> = []; - const closedIds: string[] = []; - for (let i = 0; i < closeResults.length; i++) { - const r = closeResults[i]; - const id = uniqueIds[i]; - if (r.status === 'fulfilled') { - closedIds.push(id); - } else { - const closeErr = r.reason; - if (closeErr instanceof SessionNotFoundError) { - // Session not active in bridge — still attempt to remove its transcript file - closedIds.push(id); - } else { - const msg = - closeErr instanceof Error ? closeErr.message : String(closeErr); - writeStderrLine( - `qwen serve: closeSession failed for ${safeLogValue(id)}: ${safeLogValue(msg)}`, - ); - closeErrors.push({ sessionId: id, error: msg }); - } - } - } - const result = await new SessionService(boundWorkspace).removeSessions( - closedIds, - ); - for (const e of result.errors) { - const msg = - e.error instanceof Error ? e.error.message : String(e.error); - writeStderrLine( - `qwen serve: removeSession failed for ${safeLogValue(e.sessionId)}: ${safeLogValue(msg)}`, - ); - } - res.status(200).json({ - removed: result.removed, - notFound: result.notFound, - errors: [ - ...closeErrors, - ...result.errors.map((e) => ({ - sessionId: e.sessionId, - error: e.error instanceof Error ? e.error.message : String(e.error), - })), - ], + const service = new SessionService(boundWorkspace); + const result = await deleteDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge, + coordinator: archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + writeStderrLine( + `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, + ); + }, }); + res.status(200).json(result); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - writeStderrLine( - `qwen serve: failed to batch delete sessions: ${safeLogValue(message)}`, - ); - res.status(500).json({ - error: 'Failed to delete sessions', - code: 'sessions_delete_failed', - }); + sendBridgeError(res, err, { route: 'POST /sessions/delete' }); } }); - app.patch('/session/:id/metadata', async (req, res) => { - const sessionId = req.params['id']; - const body = safeBody(req); - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - const rawDisplayName = body['displayName']; - if (rawDisplayName !== undefined && typeof rawDisplayName !== 'string') { - res.status(400).json({ - error: '`displayName` must be a string', - code: 'invalid_metadata', - field: 'displayName', - }); - return; - } - const displayName = - typeof rawDisplayName === 'string' - ? rawDisplayName.slice(0, 256) - : undefined; + app.post('/sessions/archive', mutate(), async (req, res) => { + const uniqueIds = parseSessionIdsBody(req, res); + if (uniqueIds === undefined) return; + + const service = new SessionService(boundWorkspace, { + onWarning: logSessionArchiveWarning, + }); + try { + const result = await archiveDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge, + coordinator: archiveCoordinator, + }); + res.status(200).json({ + archived: result.archived, + alreadyArchived: result.alreadyArchived, + notFound: result.notFound, + errors: serializeSessionErrors(result.errors), + }); + } catch (err) { + sendBridgeError(res, err, { route: 'POST /sessions/archive' }); + } + }); + + app.post('/sessions/unarchive', mutate(), async (req, res) => { + const uniqueIds = parseSessionIdsBody(req, res); + if (uniqueIds === undefined) return; + + const service = new SessionService(boundWorkspace, { + onWarning: logSessionArchiveWarning, + }); + + try { + const result = await unarchiveDaemonSessions({ + sessionIds: uniqueIds, + service, + coordinator: archiveCoordinator, + }); + res.status(200).json({ + unarchived: result.unarchived, + alreadyActive: result.alreadyActive, + notFound: result.notFound, + errors: serializeSessionErrors(result.errors), + }); + } catch (err) { + sendBridgeError(res, err, { route: 'POST /sessions/unarchive' }); + } + }); + + app.patch( + '/session/:id/metadata', + withMutableSession('PATCH /session/:id/metadata', (req, res, sessionId) => { + const body = safeBody(req); + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const rawDisplayName = body['displayName']; + if (rawDisplayName !== undefined && typeof rawDisplayName !== 'string') { + res.status(400).json({ + error: '`displayName` must be a string', + code: 'invalid_metadata', + field: 'displayName', + }); + return; + } + const displayName = + typeof rawDisplayName === 'string' + ? rawDisplayName.slice(0, 256) + : undefined; const effective = bridge.updateSessionMetadata( sessionId, { displayName }, clientId !== undefined ? { clientId } : undefined, ); res.status(200).json({ sessionId, ...effective }); - } catch (err) { - sendBridgeError(res, err, { - route: 'PATCH /session/:id/metadata', - sessionId, - }); - } - }); + }), + ); app.get('/workspace/:id/sessions', async (req, res) => { // Express decodes URL-encoded path params automatically; clients pass @@ -918,9 +930,25 @@ export function registerSessionRoutes( ? req.query['cursor'] : undefined; const size = parseSessionPageSizeQuery(req.query['size']); + const rawArchiveState = req.query['archiveState']; + let archiveState: SessionArchiveState | undefined; + if (rawArchiveState !== undefined) { + if ( + typeof rawArchiveState !== 'string' || + (rawArchiveState !== 'active' && rawArchiveState !== 'archived') + ) { + res.status(400).json({ + error: '`archiveState` must be "active" or "archived"', + code: 'invalid_archive_state', + }); + return; + } + archiveState = rawArchiveState; + } const result = await listWorkspaceSessionsForResponse(bridge, key, { cursor, size, + archiveState, }); res.status(200).json({ sessions: result.sessions, @@ -946,116 +974,117 @@ export function registerSessionRoutes( } }); - app.post('/session/:id/model', mutate(), async (req, res) => { - const sessionId = req.params['id']; - const body = safeBody(req); - const modelId = body['modelId']; - if (typeof modelId !== 'string' || !modelId) { - res.status(400).json({ - error: '`modelId` is required and must be a non-empty string', - }); - return; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - const response = await bridge.setSessionModel( - sessionId, - { - ...(body as object), + app.post( + '/session/:id/model', + mutate(), + withMutableSession( + 'POST /session/:id/model', + async (req, res, sessionId) => { + const body = safeBody(req); + const modelId = body['modelId']; + if (typeof modelId !== 'string' || !modelId) { + res.status(400).json({ + error: '`modelId` is required and must be a non-empty string', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const response = await bridge.setSessionModel( sessionId, - modelId, - } as Parameters[1], - clientId !== undefined ? { clientId } : undefined, - ); - res.status(200).json(response); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/model', - sessionId, - }); - } - }); - - app.post('/session/:id/recap', mutate(), async (req, res) => { - // Wraps `generateSessionRecap` so daemon clients can fetch a - // one-sentence "where did I leave off" summary without a full - // prompt turn. Best-effort — `recap: null` on short history or - // transient model failure is a normal 200, not an error. - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - const response = await bridge.generateSessionRecap( - sessionId, - clientId !== undefined ? { clientId } : undefined, - ); - if (daemonLog) { - const recap = response.recap; - daemonLog.info( - recap ? `recap generated len=${recap.length}` : 'recap returned null', - { sessionId, clientId }, + { + ...(body as object), + sessionId, + modelId, + } as Parameters[1], + clientId !== undefined ? { clientId } : undefined, ); - } - res.status(200).json(response); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/recap', - sessionId, - }); - } - }); + res.status(200).json(response); + }, + ), + ); - app.post('/session/:id/btw', mutate(), async (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const body = safeBody(req); - const question = body['question']; - if ( - typeof question !== 'string' || - question.trim().length === 0 || - question.length > BTW_MAX_INPUT_LENGTH - ) { - res.status(400).json({ - error: `\`question\` is required, must be a non-empty string, and at most ${BTW_MAX_INPUT_LENGTH} characters`, - }); - return; - } - const abort = new AbortController(); - const onResClose = () => { - if (!res.writableEnded) abort.abort(); - }; - res.once('close', onResClose); - const clientId = parseClientIdHeader(req, res); - if (clientId === null) { - res.off('close', onResClose); - return; - } - try { - const result = await bridge.generateSessionBtw( - sessionId, - question.trim(), - abort.signal, - clientId !== undefined ? { clientId } : undefined, - ); - res.status(200).json(result); - } catch (err) { + app.post( + '/session/:id/recap', + mutate(), + withMutableSession( + 'POST /session/:id/recap', + async (req, res, sessionId) => { + // Wraps `generateSessionRecap` so daemon clients can fetch a + // one-sentence "where did I leave off" summary without a full + // prompt turn. Best-effort — `recap: null` on short history or + // transient model failure is a normal 200, not an error. + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const response = await bridge.generateSessionRecap( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ); + if (daemonLog) { + const recap = response.recap; + daemonLog.info( + recap + ? `recap generated len=${recap.length}` + : 'recap returned null', + { sessionId, clientId }, + ); + } + res.status(200).json(response); + }, + ), + ); + + app.post( + '/session/:id/btw', + mutate(), + withMutableSession('POST /session/:id/btw', async (req, res, sessionId) => { + const body = safeBody(req); + const question = body['question']; if ( - err instanceof DOMException && - err.name === 'AbortError' && - abort.signal.aborted + typeof question !== 'string' || + question.trim().length === 0 || + question.length > BTW_MAX_INPUT_LENGTH ) { + res.status(400).json({ + error: `\`question\` is required, must be a non-empty string, and at most ${BTW_MAX_INPUT_LENGTH} characters`, + }); return; } - sendBridgeError(res, err, { - route: 'POST /session/:id/btw', - sessionId, - }); - } finally { - res.off('close', onResClose); - } - }); + const abort = new AbortController(); + const onResClose = () => { + if (!res.writableEnded) abort.abort(); + }; + res.once('close', onResClose); + const clientId = parseClientIdHeader(req, res); + if (clientId === null) { + res.off('close', onResClose); + return; + } + try { + const result = await bridge.generateSessionBtw( + sessionId, + question.trim(), + abort.signal, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(result); + } catch (err) { + if ( + err instanceof DOMException && + err.name === 'AbortError' && + abort.signal.aborted + ) { + return; + } + sendBridgeError(res, err, { + route: 'POST /session/:id/btw', + sessionId, + }); + } finally { + res.off('close', onResClose); + } + }), + ); // Queue a user message typed while the session's turn is still running. The // ACP child drains it between tool batches (`craft/drainMidTurnQueue`) so the @@ -1071,47 +1100,45 @@ export function registerSessionRoutes( // next-turn prompt — it only bounds how much a single mid-turn push can pin in // the in-memory queue (the queue DEPTH is bounded in `enqueueMidTurnMessage`). const MID_TURN_MESSAGE_MAX_LENGTH = 16 * 1024; - app.post('/session/:id/mid-turn-message', mutate(), (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const body = safeBody(req); - const message = body['message']; - // Validate (and length-check, and enqueue) the TRIMMED value — the bridge - // stores the trimmed string, so checking the raw length would reject input - // whose real content fits but is padded with whitespace. - const trimmed = typeof message === 'string' ? message.trim() : ''; - if (trimmed.length === 0) { - res.status(400).json({ - error: '`message` is required and must be a non-empty string', - }); - return; - } - if (trimmed.length > MID_TURN_MESSAGE_MAX_LENGTH) { - res.status(400).json({ - error: `\`message\` must be at most ${MID_TURN_MESSAGE_MAX_LENGTH} characters`, - }); - return; - } - // Forward the client id so the bridge authorizes it against the session - // (like `/prompt` and `/btw`) — a token-holding client bound to another - // session must not push into this one — and records it as the message's - // originator for SSE echo routing. `null` = malformed id (already answered). - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { - const result = bridge.enqueueMidTurnMessage( - sessionId, - trimmed, - clientId !== undefined ? { clientId } : undefined, - ); - res.status(200).json(result); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/mid-turn-message', - sessionId, - }); - } - }); + app.post( + '/session/:id/mid-turn-message', + mutate(), + withMutableSession( + 'POST /session/:id/mid-turn-message', + (req, res, sessionId) => { + const body = safeBody(req); + const message = body['message']; + // Validate (and length-check, and enqueue) the TRIMMED value — the bridge + // stores the trimmed string, so checking the raw length would reject input + // whose real content fits but is padded with whitespace. + const trimmed = typeof message === 'string' ? message.trim() : ''; + if (trimmed.length === 0) { + res.status(400).json({ + error: '`message` is required and must be a non-empty string', + }); + return; + } + if (trimmed.length > MID_TURN_MESSAGE_MAX_LENGTH) { + res.status(400).json({ + error: `\`message\` must be at most ${MID_TURN_MESSAGE_MAX_LENGTH} characters`, + }); + return; + } + // Forward the client id so the bridge authorizes it against the session + // (like `/prompt` and `/btw`) — a token-holding client bound to another + // session must not push into this one — and records it as the message's + // originator for SSE echo routing. `null` = malformed id (already answered). + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const result = bridge.enqueueMidTurnMessage( + sessionId, + trimmed, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(result); + }, + ), + ); // Pending prompt queue: list and remove. app.get('/session/:id/pending-prompts', (req, res) => { @@ -1133,95 +1160,101 @@ export function registerSessionRoutes( } }); - app.delete('/session/:id/pending-prompts/:promptId', mutate(), (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - const promptId = req.params['promptId']; - if (!promptId) { - res.status(400).json({ error: '`promptId` route parameter is required' }); - return; - } - try { - const result = bridge.removePendingPrompt( - sessionId, - promptId, - clientId !== undefined ? { clientId } : undefined, - ); - res.status(200).json(result); - } catch (err) { - sendBridgeError(res, err, { - route: 'DELETE /session/:id/pending-prompts/:promptId', - sessionId, - }); - } - }); - - app.post('/session/:id/shell', mutate({ strict: true }), async (req, res) => { - const sessionId = req.params['id']; - if (!sessionShellCommandEnabled) { - sendBridgeError(res, new SessionShellDisabledError(), { - route: 'POST /session/:id/shell', - sessionId, - }); - return; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) { - return; - } - if (clientId === undefined) { - sendBridgeError(res, new SessionShellClientRequiredError(), { - route: 'POST /session/:id/shell', - sessionId, - }); - return; - } - const body = safeBody(req); - const command = body['command']; - if (typeof command !== 'string' || command.trim().length === 0) { - res.status(400).json({ - error: '`command` is required and must be a non-empty string', - }); - return; - } - const abort = new AbortController(); - const onResClose = () => { - if (!res.writableEnded) abort.abort(); - }; - res.once('close', onResClose); - try { - const result = await bridge.executeShellCommand( - sessionId, - command.trim(), - abort.signal, - { clientId }, - ); - if (daemonLog) { - daemonLog.info('shell command completed', { + app.delete( + '/session/:id/pending-prompts/:promptId', + mutate(), + withMutableSession( + 'DELETE /session/:id/pending-prompts/:promptId', + (req, res, sessionId) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const promptId = req.params['promptId']; + if (!promptId) { + res + .status(400) + .json({ error: '`promptId` route parameter is required' }); + return; + } + const result = bridge.removePendingPrompt( sessionId, - clientId, - exitCode: result.exitCode, - }); - } - res.status(200).json(result); - } catch (err) { - if ( - err instanceof DOMException && - err.name === 'AbortError' && - abort.signal.aborted - ) { - return; - } - sendBridgeError(res, err, { - route: 'POST /session/:id/shell', - sessionId, - }); - } finally { - res.off('close', onResClose); - } - }); + promptId, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(result); + }, + ), + ); + + app.post( + '/session/:id/shell', + mutate({ strict: true }), + withMutableSession( + 'POST /session/:id/shell', + async (req, res, sessionId) => { + if (!sessionShellCommandEnabled) { + sendBridgeError(res, new SessionShellDisabledError(), { + route: 'POST /session/:id/shell', + sessionId, + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) { + return; + } + if (clientId === undefined) { + sendBridgeError(res, new SessionShellClientRequiredError(), { + route: 'POST /session/:id/shell', + sessionId, + }); + return; + } + const body = safeBody(req); + const command = body['command']; + if (typeof command !== 'string' || command.trim().length === 0) { + res.status(400).json({ + error: '`command` is required and must be a non-empty string', + }); + return; + } + const abort = new AbortController(); + const onResClose = () => { + if (!res.writableEnded) abort.abort(); + }; + res.once('close', onResClose); + try { + const result = await bridge.executeShellCommand( + sessionId, + command.trim(), + abort.signal, + { clientId }, + ); + if (daemonLog) { + daemonLog.info('shell command completed', { + sessionId, + clientId, + exitCode: result.exitCode, + }); + } + res.status(200).json(result); + } catch (err) { + if ( + err instanceof DOMException && + err.name === 'AbortError' && + abort.signal.aborted + ) { + return; + } + sendBridgeError(res, err, { + route: 'POST /session/:id/shell', + sessionId, + }); + } finally { + res.off('close', onResClose); + } + }, + ), + ); app.get('/session/:id/rewind/snapshots', async (req, res) => { const sessionId = req.params['id']; @@ -1244,66 +1277,61 @@ export function registerSessionRoutes( app.post( '/session/:id/rewind', mutate({ strict: true }), - async (req, res) => { - const sessionId = req.params['id']; - const body = safeBody(req); - const promptId = body['promptId']; - if (typeof promptId !== 'string' || promptId.length === 0) { - res.status(400).json({ - error: '`promptId` is required and must be a non-empty string', - code: 'missing_prompt_id', - }); - return; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { + withMutableSession( + 'POST /session/:id/rewind', + async (req, res, sessionId) => { + const body = safeBody(req); + const promptId = body['promptId']; + if (typeof promptId !== 'string' || promptId.length === 0) { + res.status(400).json({ + error: '`promptId` is required and must be a non-empty string', + code: 'missing_prompt_id', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; const response = await bridge.rewindSession( sessionId, { promptId, rewindFiles: body['rewindFiles'] !== false }, clientId !== undefined ? { clientId } : undefined, ); res.status(200).json(response); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/rewind', - sessionId, - }); - } - }, + }, + ), ); app.post( '/session/:id/approval-mode', mutate({ strict: true }), - async (req, res) => { - // Validates `mode` against `APPROVAL_MODES` and an optional - // `persist: boolean` flag. - const sessionId = req.params['id']; - const body = safeBody(req); - const mode = body['mode']; - const persist = body['persist']; - if ( - typeof mode !== 'string' || - !APPROVAL_MODES.includes(mode as ApprovalMode) - ) { - res.status(400).json({ - error: '`mode` is required and must be one of the allowed values', - code: 'invalid_approval_mode', - allowed: APPROVAL_MODES, - }); - return; - } - if (persist !== undefined && typeof persist !== 'boolean') { - res.status(400).json({ - error: '`persist` must be a boolean when provided', - code: 'invalid_persist_flag', - }); - return; - } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - try { + withMutableSession( + 'POST /session/:id/approval-mode', + async (req, res, sessionId) => { + // Validates `mode` against `APPROVAL_MODES` and an optional + // `persist: boolean` flag. + const body = safeBody(req); + const mode = body['mode']; + const persist = body['persist']; + if ( + typeof mode !== 'string' || + !APPROVAL_MODES.includes(mode as ApprovalMode) + ) { + res.status(400).json({ + error: '`mode` is required and must be one of the allowed values', + code: 'invalid_approval_mode', + allowed: APPROVAL_MODES, + }); + return; + } + if (persist !== undefined && typeof persist !== 'boolean') { + res.status(400).json({ + error: '`persist` must be a boolean when provided', + code: 'invalid_persist_flag', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; const response = await bridge.setSessionApprovalMode( sessionId, mode as ApprovalMode, @@ -1311,61 +1339,58 @@ export function registerSessionRoutes( clientId !== undefined ? { clientId } : undefined, ); res.status(200).json(response); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/approval-mode', - sessionId, - }); - } - }, + }, + ), ); - app.post('/session/:id/language', mutate(), async (req, res) => { - const sessionId = req.params['id']; - const body = safeBody(req); - const language = body['language']; - const syncOutputLanguage = body['syncOutputLanguage']; + app.post( + '/session/:id/language', + mutate(), + withMutableSession( + 'POST /session/:id/language', + async (req, res, sessionId) => { + const body = safeBody(req); + const language = body['language']; + const syncOutputLanguage = body['syncOutputLanguage']; - if (typeof language !== 'string' || !LANGUAGE_CODES.includes(language)) { - res.status(400).json({ - error: - '`language` is required and must be one of: ' + - LANGUAGE_CODES.join(', '), - code: 'invalid_language', - allowed: LANGUAGE_CODES, - }); - return; - } + if ( + typeof language !== 'string' || + !LANGUAGE_CODES.includes(language) + ) { + res.status(400).json({ + error: + '`language` is required and must be one of: ' + + LANGUAGE_CODES.join(', '), + code: 'invalid_language', + allowed: LANGUAGE_CODES, + }); + return; + } - if ( - syncOutputLanguage !== undefined && - typeof syncOutputLanguage !== 'boolean' - ) { - res.status(400).json({ - error: '`syncOutputLanguage` must be a boolean when provided', - code: 'invalid_sync_flag', - }); - return; - } + if ( + syncOutputLanguage !== undefined && + typeof syncOutputLanguage !== 'boolean' + ) { + res.status(400).json({ + error: '`syncOutputLanguage` must be a boolean when provided', + code: 'invalid_sync_flag', + }); + return; + } - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; - try { - const response = await bridge.setSessionLanguage( - sessionId, - { - language, - syncOutputLanguage: syncOutputLanguage === true, - }, - clientId !== undefined ? { clientId } : undefined, - ); - res.status(200).json(response); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/language', - sessionId, - }); - } - }); + const response = await bridge.setSessionLanguage( + sessionId, + { + language, + syncOutputLanguage: syncOutputLanguage === true, + }, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(response); + }, + ), + ); } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 93ef9ef77e..54e51d2f22 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -210,6 +210,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_lsp', 'session_status', 'session_close', + 'session_archive', 'session_metadata', // Issue #4175 PR 14. Always-on. Daemon supports the MCP client // guardrail surface (`--mcp-client-budget`, `clientCount` / @@ -528,6 +529,7 @@ interface FakeBridgeOpts { closeImpl?: ( sessionId: string, context?: BridgeClientRequestContext, + closeOpts?: FakeCloseSessionOpts, ) => Promise; updateMetadataImpl?: ( sessionId: string, @@ -556,6 +558,11 @@ interface FakeBridgeOpts { daemonStatusSnapshotImpl?: () => BridgeDaemonStatusSnapshot; } +interface FakeCloseSessionOpts { + reason?: string; + requireAgentClose?: boolean; +} + interface FakeBridge extends AcpSessionBridge { calls: BridgeSpawnRequest[]; loadCalls: BridgeRestoreSessionRequest[]; @@ -695,6 +702,7 @@ interface FakeBridge extends AcpSessionBridge { closeCalls: Array<{ sessionId: string; context?: BridgeClientRequestContext; + closeOpts?: FakeCloseSessionOpts; }>; updateMetadataCalls: Array<{ sessionId: string; @@ -1511,9 +1519,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { removeRuntimeMcpServerCalls.push({ name, originatorClientId }); return removeRuntimeMcpServerImpl(name, originatorClientId); }, - async closeSession(sessionId, context) { - closeCalls.push({ sessionId, ...(context ? { context } : {}) }); - return closeImpl(sessionId, context); + async closeSession(sessionId, context, closeOpts) { + closeCalls.push({ + sessionId, + ...(context ? { context } : {}), + ...(closeOpts ? { closeOpts } : {}), + }); + return closeImpl(sessionId, context, closeOpts); }, updateSessionMetadata(sessionId, metadata, context) { updateMetadataCalls.push({ @@ -6174,10 +6186,12 @@ describe('createServeApp', () => { timestamp: string; prompt: string; mtime: Date; + state?: 'active' | 'archived'; }): Promise { const chatsDir = path.join( new Storage(input.cwd).getProjectDir(), 'chats', + ...(input.state === 'archived' ? ['archive'] : []), ); await fsp.mkdir(chatsDir, { recursive: true }); const filePath = path.join(chatsDir, `${input.sessionId}.jsonl`); @@ -6245,20 +6259,22 @@ describe('createServeApp', () => { expect(res.body.sessions).toHaveLength(2); expect(res.body.sessions).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ sessionId: 's-1', workspaceCwd: WS_BOUND, createdAt: '2026-05-17T12:00:00.000Z', clientCount: 1, hasActivePrompt: false, - }, - { + isArchived: false, + }), + expect.objectContaining({ sessionId: 's-2', workspaceCwd: WS_BOUND, createdAt: '2026-05-17T12:01:00.000Z', clientCount: 0, hasActivePrompt: true, - }, + isArchived: false, + }), ]), ); expect(bridge.listCalls).toEqual([WS_BOUND]); @@ -6459,6 +6475,73 @@ describe('createServeApp', () => { expect(overlap).toHaveLength(0); }); + it('lists archived sessions without merging live sessions', async () => { + for (let i = 0; i < 3; i++) { + await writeStoredSession({ + sessionId: `650e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}`, + cwd: WS_BOUND, + timestamp: `2026-05-17T13:0${i}:00.000Z`, + prompt: `archived prompt ${i}`, + mtime: new Date(`2026-05-17T13:1${i}:00.000Z`), + state: 'archived', + }); + } + const bridge = fakeBridge({ + listImpl: () => [ + { + sessionId: 'live-only', + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T14:00:00.000Z', + clientCount: 1, + hasActivePrompt: true, + }, + ], + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + + const res = await request(app) + .get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?archiveState=archived&size=2`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body.sessions).toHaveLength(2); + expect(res.body.nextCursor).toBeDefined(); + expect( + res.body.sessions.map( + (session: { sessionId: string }) => session.sessionId, + ), + ).not.toContain('live-only'); + expect( + res.body.sessions.every( + (session: { isArchived?: boolean }) => session.isArchived === true, + ), + ).toBe(true); + }); + + it('rejects invalid archiveState values', async () => { + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge(), boundWorkspace: WS_BOUND }, + ); + + const res = await request(app) + .get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?archiveState=all`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'invalid_archive_state', + }); + }); + it('merges live sessions only on first page (no cursor)', async () => { const storedId = '550e8400-e29b-41d4-a716-446655440000'; await writeStoredSession({ @@ -6567,6 +6650,7 @@ describe('createServeApp', () => { expect(listSessionsSpy).toHaveBeenCalledWith({ cursor: 1000123.456, size: 20, + archiveState: 'active', }); } finally { listSessionsSpy.mockRestore(); @@ -9113,8 +9197,15 @@ describe('createServeApp', () => { await fsp.rm(runtimeDir, { recursive: true, force: true }); }); - async function writeSession(sessionId: string): Promise { - const chatsDir = path.join(new Storage(wsDir).getProjectDir(), 'chats'); + async function writeSession( + sessionId: string, + state: 'active' | 'archived' = 'active', + ): Promise { + const chatsDir = path.join( + new Storage(wsDir).getProjectDir(), + 'chats', + ...(state === 'archived' ? ['archive'] : []), + ); await fsp.mkdir(chatsDir, { recursive: true }); const filePath = path.join(chatsDir, `${sessionId}.jsonl`); const record = { @@ -9129,6 +9220,18 @@ describe('createServeApp', () => { await fsp.writeFile(filePath, `${JSON.stringify(record)}\n`, 'utf8'); } + function sessionFilePath( + sessionId: string, + state: 'active' | 'archived' = 'active', + ): string { + return path.join( + new Storage(wsDir).getProjectDir(), + 'chats', + ...(state === 'archived' ? ['archive'] : []), + `${sessionId}.jsonl`, + ); + } + it('400 on missing sessionIds', async () => { const bridge = fakeBridge(); const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { @@ -9202,6 +9305,29 @@ describe('createServeApp', () => { await expect(fsp.access(filePath)).rejects.toThrow(); }); + it('deletes archived session transcript when bridge throws SessionNotFoundError', async () => { + const sid = 'feedbeef-dead-beef-dead-beefdeaddead'; + await writeSession(sid, 'archived'); + const bridge = fakeBridge({ + closeImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + const res = await request(app) + .post('/sessions/delete') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(res.status).toBe(200); + expect(res.body.removed).toEqual([sid]); + await expect( + fsp.access(sessionFilePath(sid, 'archived')), + ).rejects.toThrow(); + }); + it('does not delete when bridge.closeSession throws InvalidClientIdError', async () => { const sid = 'aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb'; await writeSession(sid); @@ -9301,6 +9427,67 @@ describe('createServeApp', () => { ).resolves.toBeUndefined(); }); + it('deletes available sessions when another id is being loaded', async () => { + const sidOk = 'aaaa4444-bbbb-cccc-dddd-eeeeeeeeeeee'; + const sidBusy = 'aaaa5555-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sidOk); + await writeSession(sidBusy); + let loadStarted!: () => void; + let releaseLoad!: () => void; + const loadStartedPromise = new Promise((resolve) => { + loadStarted = resolve; + }); + const loadReleasedPromise = new Promise((resolve) => { + releaseLoad = resolve; + }); + const bridge = fakeBridge({ + loadImpl: async (req) => { + if (req.sessionId === sidBusy) { + loadStarted(); + await loadReleasedPromise; + } + return { + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: 'client-load', + state: {}, + hasActivePrompt: false, + }; + }, + }); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + + const loadPromise = request(app) + .post(`/session/${sidBusy}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}) + .then((res) => res); + await loadStartedPromise; + + const res = await request(app) + .post('/sessions/delete') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sidOk, sidBusy] }); + + expect(res.status).toBe(200); + expect(res.body.removed).toEqual([sidOk]); + expect(res.body.notFound).toEqual([]); + expect( + res.body.errors.map((e: { sessionId: string }) => e.sessionId), + ).toEqual([sidBusy]); + await expect(fsp.access(sessionFilePath(sidOk))).rejects.toThrow(); + await expect( + fsp.access(sessionFilePath(sidBusy)), + ).resolves.toBeUndefined(); + + releaseLoad(); + await expect(loadPromise).resolves.toMatchObject({ status: 200 }); + }); + it('400 when sessionIds exceeds max 100', async () => { const bridge = fakeBridge(); const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { @@ -9372,9 +9559,124 @@ describe('createServeApp', () => { ).resolves.toBeUndefined(); }); - it('returns 500 when removeSessions throws unexpectedly', async () => { + it('keeps archive blocked while batch delete is closing the same session', async () => { + const sid = 'eeee2222-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + let firstCloseStarted!: () => void; + let releaseFirstClose!: () => void; + let secondCloseStarted!: () => void; + const firstCloseStartedPromise = new Promise((resolve) => { + firstCloseStarted = resolve; + }); + const firstCloseReleasedPromise = new Promise((resolve) => { + releaseFirstClose = resolve; + }); + const secondCloseStartedPromise = new Promise((resolve) => { + secondCloseStarted = resolve; + }); + let closeCount = 0; + const bridge = fakeBridge({ + closeImpl: async () => { + closeCount++; + if (closeCount === 1) { + firstCloseStarted(); + await firstCloseReleasedPromise; + } else { + secondCloseStarted(); + } + }, + }); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + + const deletePromise = request(app) + .post('/sessions/delete') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }) + .then((res) => res); + await firstCloseStartedPromise; + + const archivePromise = request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }) + .then((res) => res); + + const raceResult = await Promise.race([ + secondCloseStartedPromise.then(() => 'archive-started'), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)), + ]); + + releaseFirstClose(); + try { + expect(raceResult).toBe('blocked'); + const deleteRes = await deletePromise; + expect(deleteRes.status).toBe(200); + expect(deleteRes.body.removed).toEqual([sid]); + const archiveRes = await archivePromise; + expect(archiveRes.status).toBe(409); + expect(archiveRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sid, + }); + } finally { + await Promise.allSettled([deletePromise, archivePromise]); + } + }); + + it('returns session_archiving when batch delete races an archive gate', async () => { + const sid = 'eeee3333-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + const bridge = fakeBridge({ + closeImpl: async () => { + closeStarted(); + await closeReleasedPromise; + }, + }); + const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + + const archivePromise = request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }) + .then((res) => res); + await closeStartedPromise; + + const deleteRes = await request(app) + .post('/sessions/delete') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + + releaseClose(); + try { + expect(deleteRes.status).toBe(409); + expect(deleteRes.headers['retry-after']).toBe('5'); + expect(deleteRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sid, + }); + await expect(archivePromise).resolves.toMatchObject({ status: 200 }); + } finally { + await Promise.allSettled([archivePromise]); + } + }); + + it('returns per-id errors when removeSession throws unexpectedly', async () => { const spy = vi - .spyOn(SessionService.prototype, 'removeSessions') + .spyOn(SessionService.prototype, 'removeSession') .mockRejectedValueOnce(new Error('disk on fire')); const bridge = fakeBridge(); const app = createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { @@ -9385,12 +9687,622 @@ describe('createServeApp', () => { .post('/sessions/delete') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ sessionIds: ['aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee'] }); - expect(res.status).toBe(500); - expect(res.body.code).toBe('sessions_delete_failed'); + expect(res.status).toBe(200); + expect(res.body.errors).toEqual([ + { + sessionId: 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee', + error: 'disk on fire', + }, + ]); spy.mockRestore(); }); }); + describe('POST /sessions/archive and /sessions/unarchive', () => { + let previousRuntimeDir: string | undefined; + let runtimeDir: string; + let wsDir: string; + + beforeEach(async () => { + previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + runtimeDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-serve-session-archive-'), + ); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; + wsDir = realpathSync(runtimeDir); + }); + + afterEach(async () => { + if (previousRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; + } + await fsp.rm(runtimeDir, { recursive: true, force: true }); + }); + + function sessionFilePath( + sessionId: string, + state: 'active' | 'archived' = 'active', + ): string { + return path.join( + new Storage(wsDir).getProjectDir(), + 'chats', + ...(state === 'archived' ? ['archive'] : []), + `${sessionId}.jsonl`, + ); + } + + async function writeSession( + sessionId: string, + state: 'active' | 'archived' = 'active', + ): Promise { + const filePath = sessionFilePath(sessionId, state); + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + const record = { + uuid: `${sessionId}-user-1`, + parentUuid: null, + sessionId, + timestamp: '2026-05-28T12:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + cwd: wsDir, + }; + await fsp.writeFile(filePath, `${JSON.stringify(record)}\n`, 'utf8'); + } + + function createArchiveApp(bridge = fakeBridge()) { + return createServeApp({ ...baseOpts, workspace: wsDir }, undefined, { + bridge, + boundWorkspace: wsDir, + }); + } + + it('archives an inactive session by moving JSONL into chats/archive', async () => { + const sid = '11111111-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + const bridge = fakeBridge({ + closeImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createArchiveApp(bridge); + const res = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + archived: [sid], + alreadyArchived: [], + notFound: [], + errors: [], + }); + expect(bridge.closeCalls[0]?.closeOpts).toMatchObject({ + requireAgentClose: true, + }); + await expect(fsp.access(sessionFilePath(sid))).rejects.toThrow(); + await expect( + fsp.access(sessionFilePath(sid, 'archived')), + ).resolves.toBeUndefined(); + }); + + it('logs archive result counts and session ids to stderr', async () => { + const archivedId = '11111111-bbbb-cccc-dddd-eeeeeeeeeeee'; + const notFoundId = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(archivedId); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const bridge = fakeBridge({ + closeImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createArchiveApp(bridge); + try { + const res = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [archivedId, notFoundId] }); + + expect(res.status).toBe(200); + const logLine = stderrSpy.mock.calls + .map((call) => String(call[0])) + .find((line) => line.includes('sessions archive result')); + expect(logLine).toContain('requested=2'); + expect(logLine).toContain( + `requestedIds=["${archivedId}","${notFoundId}"]`, + ); + expect(logLine).toContain('archived=1'); + expect(logLine).toContain(`archivedIds=["${archivedId}"]`); + expect(logLine).toContain('notFound=1'); + expect(logLine).toContain(`notFoundIds=["${notFoundId}"]`); + expect(logLine).toContain('errors=0'); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('does not move JSONL when live strict close fails', async () => { + const sid = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + const bridge = fakeBridge({ + closeImpl: async () => { + throw new Error('flush failed'); + }, + }); + const app = createArchiveApp(bridge); + const res = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(res.status).toBe(200); + expect(res.body.archived).toEqual([]); + expect(res.body.errors).toEqual([ + { sessionId: sid, error: 'flush failed' }, + ]); + await expect(fsp.access(sessionFilePath(sid))).resolves.toBeUndefined(); + await expect( + fsp.access(sessionFilePath(sid, 'archived')), + ).rejects.toThrow(); + }); + + it('does not move JSONL when live strict close reports channel unavailable', async () => { + const sid = '22222222-bbbb-cccc-dddd-eeeeeeeeeeef'; + await writeSession(sid); + const closeError = Object.assign( + new Error(`ACP session close channel unavailable for ${sid}`), + { data: { errorKind: 'acp_channel_unavailable' } }, + ); + const bridge = fakeBridge({ + closeImpl: async () => { + throw closeError; + }, + }); + const app = createArchiveApp(bridge); + + const res = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + + expect(res.status).toBe(200); + expect(res.body.archived).toEqual([]); + expect(res.body.errors).toEqual([ + { sessionId: sid, error: closeError.message }, + ]); + await expect(fsp.access(sessionFilePath(sid))).resolves.toBeUndefined(); + await expect( + fsp.access(sessionFilePath(sid, 'archived')), + ).rejects.toThrow(); + }); + + it('does not close a live session when no active JSONL exists', async () => { + const sid = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee'; + const bridge = fakeBridge(); + const app = createArchiveApp(bridge); + + const res = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + archived: [], + alreadyArchived: [], + notFound: [sid], + errors: [], + }); + expect(bridge.closeCalls).toHaveLength(0); + }); + + it('unarchives by moving JSONL back into active chats', async () => { + const sid = '33333333-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid, 'archived'); + const app = createArchiveApp(); + const res = await request(app) + .post('/sessions/unarchive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + unarchived: [sid], + alreadyActive: [], + notFound: [], + errors: [], + }); + await expect(fsp.access(sessionFilePath(sid))).resolves.toBeUndefined(); + await expect( + fsp.access(sessionFilePath(sid, 'archived')), + ).rejects.toThrow(); + }); + + it('logs unarchive result counts and session ids to stderr', async () => { + const unarchivedId = '33333333-bbbb-cccc-dddd-eeeeeeeeeeee'; + const alreadyActiveId = '44444444-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(unarchivedId, 'archived'); + await writeSession(alreadyActiveId); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const app = createArchiveApp(); + try { + const res = await request(app) + .post('/sessions/unarchive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [unarchivedId, alreadyActiveId] }); + + expect(res.status).toBe(200); + const logLine = stderrSpy.mock.calls + .map((call) => String(call[0])) + .find((line) => line.includes('sessions unarchive result')); + expect(logLine).toContain('requested=2'); + expect(logLine).toContain( + `requestedIds=["${unarchivedId}","${alreadyActiveId}"]`, + ); + expect(logLine).toContain('unarchived=1'); + expect(logLine).toContain(`unarchivedIds=["${unarchivedId}"]`); + expect(logLine).toContain('alreadyActive=1'); + expect(logLine).toContain(`alreadyActiveIds=["${alreadyActiveId}"]`); + expect(logLine).toContain('errors=0'); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('rejects load and resume for archived sessions with session_archived', async () => { + const sid = '44444444-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid, 'archived'); + const bridge = fakeBridge(); + const app = createArchiveApp(bridge); + + const loadRes = await request(app) + .post(`/session/${sid}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }); + expect(loadRes.status).toBe(409); + expect(loadRes.body).toMatchObject({ + code: 'session_archived', + sessionId: sid, + }); + + const resumeRes = await request(app) + .post(`/session/${sid}/resume`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }); + expect(resumeRes.status).toBe(409); + expect(resumeRes.body).toMatchObject({ + code: 'session_archived', + sessionId: sid, + }); + expect(bridge.loadCalls).toHaveLength(0); + expect(bridge.resumeCalls).toHaveLength(0); + }); + + it('rejects load for active/archive conflicts with session_conflict', async () => { + const sid = '44444444-bbbb-cccc-dddd-eeeeeeeeeeef'; + await writeSession(sid); + await writeSession(sid, 'archived'); + const bridge = fakeBridge(); + const app = createArchiveApp(bridge); + + const loadRes = await request(app) + .post(`/session/${sid}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }); + expect(loadRes.status).toBe(409); + expect(loadRes.body).toMatchObject({ + code: 'session_conflict', + sessionId: sid, + }); + expect(loadRes.body.error).toContain( + 'Delete the session with POST /sessions/delete', + ); + expect(bridge.loadCalls).toHaveLength(0); + }); + + it('returns session_archiving for prompt while archive is in flight', async () => { + const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + const bridge = fakeBridge({ + closeImpl: async () => { + closeStarted(); + await closeReleasedPromise; + }, + }); + const app = createArchiveApp(bridge); + const archivePromise = request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }) + .then((res) => res); + await closeStartedPromise; + + const promptRes = await request(app) + .post(`/session/${sid}/prompt`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ prompt: [{ type: 'text', text: 'hi' }] }); + expect(promptRes.status).toBe(409); + expect(promptRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sid, + }); + expect(promptRes.headers['retry-after']).toBe('5'); + expect(bridge.promptCalls).toHaveLength(0); + + releaseClose(); + const archiveRes = await archivePromise; + expect(archiveRes.status).toBe(200); + expect(archiveRes.body.archived).toEqual([sid]); + }); + + it('returns session_archiving for pending prompt removal while archive is in flight', async () => { + const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeef'; + await writeSession(sid); + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + const bridge = fakeBridge({ + closeImpl: async () => { + closeStarted(); + await closeReleasedPromise; + }, + removePendingPromptImpl: () => { + throw new Error('removePendingPrompt should not be called'); + }, + }); + const app = createArchiveApp(bridge); + const archivePromise = request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }) + .then((res) => res); + await closeStartedPromise; + + const removeRes = await request(app) + .delete(`/session/${sid}/pending-prompts/p-1`) + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(removeRes.status).toBe(409); + expect(removeRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sid, + }); + + releaseClose(); + const archiveRes = await archivePromise; + expect(archiveRes.status).toBe(200); + expect(archiveRes.body.archived).toEqual([sid]); + }); + + it('returns session_archiving for archive while load is in flight', async () => { + const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + let loadStarted!: () => void; + let releaseLoad!: () => void; + const loadStartedPromise = new Promise((resolve) => { + loadStarted = resolve; + }); + const loadReleasedPromise = new Promise((resolve) => { + releaseLoad = resolve; + }); + const bridge = fakeBridge({ + loadImpl: async (req) => { + loadStarted(); + await loadReleasedPromise; + return { + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: req.clientId ?? 'client-load', + state: {}, + hasActivePrompt: false, + }; + }, + }); + const app = createArchiveApp(bridge); + + const loadPromise = request(app) + .post(`/session/${sid}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }) + .then((res) => res); + await loadStartedPromise; + + const archiveRes = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + expect(archiveRes.status).toBe(409); + expect(archiveRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sid, + }); + expect(archiveRes.body.error).toContain('being archived or unarchived'); + + releaseLoad(); + const loadRes = await loadPromise; + expect(loadRes.status).toBe(200); + expect(loadRes.body.sessionId).toBe(sid); + expect(bridge.closeCalls).toHaveLength(0); + }); + + it('allows concurrent loads for the same session while restore is in flight', async () => { + const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeef'; + await writeSession(sid); + let loadStarted!: () => void; + let releaseLoad!: () => void; + const loadStartedPromise = new Promise((resolve) => { + loadStarted = resolve; + }); + const loadReleasedPromise = new Promise((resolve) => { + releaseLoad = resolve; + }); + let loadCount = 0; + const bridge = fakeBridge({ + loadImpl: async (req) => { + loadCount++; + if (loadCount === 1) { + loadStarted(); + } + await loadReleasedPromise; + return { + sessionId: req.sessionId, + workspaceCwd: req.workspaceCwd, + attached: true, + clientId: req.clientId ?? `client-load-${loadCount}`, + state: {}, + hasActivePrompt: false, + }; + }, + }); + const app = createArchiveApp(bridge); + + const firstLoad = request(app) + .post(`/session/${sid}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }) + .then((res) => res); + await loadStartedPromise; + const secondLoad = request(app) + .post(`/session/${sid}/load`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: wsDir }) + .then((res) => res); + + releaseLoad(); + const [firstRes, secondRes] = await Promise.all([firstLoad, secondLoad]); + expect(firstRes.status).toBe(200); + expect(secondRes.status).toBe(200); + expect(bridge.loadCalls).toHaveLength(2); + }); + + it('returns session_archiving for archive while single close is in flight', async () => { + const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sid); + let closeStarted!: () => void; + let releaseClose!: () => void; + let secondCloseStarted!: () => void; + const closeStartedPromise = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeReleasedPromise = new Promise((resolve) => { + releaseClose = resolve; + }); + const secondCloseStartedPromise = new Promise((resolve) => { + secondCloseStarted = resolve; + }); + let closeCount = 0; + const bridge = fakeBridge({ + closeImpl: async () => { + closeCount++; + if (closeCount === 1) { + closeStarted(); + await closeReleasedPromise; + } else { + secondCloseStarted(); + } + }, + }); + const app = createArchiveApp(bridge); + + const closePromise = request(app) + .delete(`/session/${sid}`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .then((res) => res); + await closeStartedPromise; + + const archiveRes = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sid] }); + const raceResult = await Promise.race([ + secondCloseStartedPromise.then(() => 'second-close-started'), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 25)), + ]); + expect(archiveRes.status).toBe(409); + expect(archiveRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sid, + }); + expect(raceResult).toBe('blocked'); + + releaseClose(); + const closeRes = await closePromise; + expect(closeRes.status).toBe(204); + expect(bridge.closeCalls).toHaveLength(1); + }); + + it('returns session_archiving for overlapping archive batches', async () => { + const sidA = '66666666-bbbb-cccc-dddd-eeeeeeeeeeee'; + const sidB = '77777777-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sidA); + await writeSession(sidB); + + let closeAStarted!: () => void; + let releaseCloseA!: () => void; + let closeBStarted!: () => void; + const closeAStartedPromise = new Promise((resolve) => { + closeAStarted = resolve; + }); + const closeAReleasedPromise = new Promise((resolve) => { + releaseCloseA = resolve; + }); + const closeBStartedPromise = new Promise((resolve) => { + closeBStarted = resolve; + }); + + const bridge = fakeBridge({ + closeImpl: async (sessionId) => { + if (sessionId === sidA) { + closeAStarted(); + await closeAReleasedPromise; + } else if (sessionId === sidB) { + closeBStarted(); + } + }, + }); + const app = createArchiveApp(bridge); + + const firstArchive = request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sidA, sidB] }) + .then((res) => res); + await closeAStartedPromise; + await closeBStartedPromise; + + const secondRes = await request(app) + .post('/sessions/archive') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionIds: [sidB, sidA] }); + expect(secondRes.status).toBe(409); + expect(secondRes.body).toMatchObject({ + code: 'session_archiving', + sessionId: sidB, + }); + expect(bridge.closeCalls.map((call) => call.sessionId)).toEqual([ + sidA, + sidB, + ]); + + releaseCloseA(); + const firstRes = await firstArchive; + expect(firstRes.status).toBe(200); + expect(firstRes.body.archived).toEqual([sidA, sidB]); + }); + }); + describe('PATCH /session/:id/metadata', () => { it('200 on successful metadata update', async () => { const bridge = fakeBridge(); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 75c0ba0209..525760de5f 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -105,6 +105,7 @@ import { } from './server/error-handlers.js'; import { installRateLimiter } from './server/rate-limiter-setup.js'; import { createServeFeatures } from './server/serve-features.js'; +import { SessionArchiveCoordinator } from './server/session-archive.js'; import { installSelfOriginStripMiddleware } from './server/self-origin.js'; import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js'; import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js'; @@ -371,6 +372,7 @@ export function createServeApp( // ext-method by reaching the WS connection that hosts the named server. clientMcpSender: clientMcpSenderRegistry.lookup, }); + const archiveCoordinator = new SessionArchiveCoordinator(); installSelfOriginStripMiddleware(app, getPort); @@ -727,6 +729,7 @@ export function createServeApp( registerSessionRoutes(app, { boundWorkspace, bridge, + archiveCoordinator, mutate, sendBridgeError, daemonLog, @@ -787,6 +790,7 @@ export function createServeApp( // route through the JSON error contract below. acpHandleRef.current = mountAcpHttp(app, bridge, { boundWorkspace, + archiveCoordinator, workspace, fsFactory, deviceFlowRegistry, diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index d4e22cedc4..6c8112e3e5 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -27,7 +27,10 @@ import { PermissionPolicyNotImplementedError, PromptQueueFullError, RestoreInProgressError, + SessionArchivedError, + SessionArchivingError, SessionBusyError, + SessionConflictError, SessionLimitExceededError, SessionNotFoundError, SessionShellClientRequiredError, @@ -241,6 +244,31 @@ export function sendBridgeError( res.status(404).json({ error: err.message, sessionId: err.sessionId }); return; } + if (err instanceof SessionArchivedError) { + res.status(409).json({ + error: err.message, + code: 'session_archived', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof SessionConflictError) { + res.status(409).json({ + error: err.message, + code: 'session_conflict', + sessionId: err.sessionId, + }); + return; + } + if (err instanceof SessionArchivingError) { + res.set('Retry-After', '5'); + res.status(409).json({ + error: err.message, + code: 'session_archiving', + sessionId: err.sessionId, + }); + return; + } if (err instanceof InvalidClientIdError) { res.status(400).json({ error: err.message, diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts new file mode 100644 index 0000000000..043818cbb8 --- /dev/null +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionService, Storage } from '@qwen-code/qwen-code-core'; +import { + SessionArchivedError, + SessionArchivingError, + SessionConflictError, +} from '../acp-session-bridge.js'; +import { + archiveDaemonSessions, + assertSessionLoadable, + SessionArchiveCoordinator, + unarchiveDaemonSessions, +} from './session-archive.js'; + +describe('assertSessionLoadable', () => { + let runtimeDir: string; + let workspaceDir: string; + + beforeEach(() => { + runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-archive-test-')); + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + Storage.setRuntimeBaseDir(runtimeDir); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + fs.rmSync(workspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('rejects archived sessions using project-aware JSONL heads', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + writeSessionFile(workspaceDir, sessionId, 'archived'); + const getLocationSpy = vi.spyOn( + SessionService.prototype, + 'getSessionLocation', + ); + + await expect( + assertSessionLoadable(workspaceDir, sessionId), + ).rejects.toThrow(SessionArchivedError); + expect(getLocationSpy).toHaveBeenCalledWith(sessionId); + }); + + it('rejects active/archive conflicts using project-aware JSONL heads', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + writeSessionFile(workspaceDir, sessionId, 'active'); + writeSessionFile(workspaceDir, sessionId, 'archived'); + const getLocationSpy = vi.spyOn( + SessionService.prototype, + 'getSessionLocation', + ); + + await expect( + assertSessionLoadable(workspaceDir, sessionId), + ).rejects.toThrow(SessionConflictError); + expect(getLocationSpy).toHaveBeenCalledWith(sessionId); + }); + + it('ignores archived files that do not belong to this project', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440010'; + const otherWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), 'qwen-other-workspace-'), + ); + try { + writeSessionFile(workspaceDir, sessionId, 'archived', otherWorkspace); + + await expect( + assertSessionLoadable(workspaceDir, sessionId), + ).resolves.toBeUndefined(); + } finally { + fs.rmSync(otherWorkspace, { recursive: true, force: true }); + } + }); +}); + +describe('SessionArchiveCoordinator', () => { + it('rejects shared access while an exclusive lock is held', async () => { + const coordinator = new SessionArchiveCoordinator(); + const sessionId = '550e8400-e29b-41d4-a716-446655440020'; + + await coordinator.runExclusiveMany([sessionId], async () => { + await expect( + coordinator.runSharedMany([sessionId], async () => 'shared'), + ).rejects.toThrow(SessionArchivingError); + }); + }); + + it('allows concurrent shared access and reference-counts release', async () => { + const coordinator = new SessionArchiveCoordinator(); + const sessionId = '550e8400-e29b-41d4-a716-446655440021'; + let releaseFirst!: () => void; + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve; + }); + const first = coordinator.runSharedMany([sessionId], async () => { + await firstReleased; + return 'first'; + }); + + await expect( + coordinator.runSharedMany([sessionId], async () => 'second'), + ).resolves.toBe('second'); + await expect( + coordinator.runExclusiveMany([sessionId], async () => 'exclusive'), + ).rejects.toThrow(SessionArchivingError); + releaseFirst(); + await expect(first).resolves.toBe('first'); + await expect( + coordinator.runExclusiveMany([sessionId], async () => 'exclusive'), + ).resolves.toBe('exclusive'); + }); + + it('assertNotTransitioning throws during exclusive access', async () => { + const coordinator = new SessionArchiveCoordinator(); + const sessionId = '550e8400-e29b-41d4-a716-446655440022'; + + await coordinator.runExclusiveMany([sessionId], async () => { + expect(() => coordinator.assertNotTransitioning(sessionId)).toThrow( + SessionArchivingError, + ); + }); + }); + + it('releases exclusive locks when the callback throws', async () => { + const coordinator = new SessionArchiveCoordinator(); + const sessionId = '550e8400-e29b-41d4-a716-446655440023'; + + await expect( + coordinator.runExclusiveMany([sessionId], async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + await expect( + coordinator.runExclusiveMany([sessionId], async () => 'ok'), + ).resolves.toBe('ok'); + }); +}); + +describe('archiveDaemonSessions', () => { + let runtimeDir: string; + let workspaceDir: string; + + beforeEach(() => { + runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-archive-test-')); + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + Storage.setRuntimeBaseDir(runtimeDir); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + fs.rmSync(workspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('deduplicates ids and archives one active session', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440002'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const closeSession = vi.fn().mockResolvedValue(undefined); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId, sessionId], + service, + bridge: { closeSession }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + archived: [sessionId], + alreadyArchived: [], + notFound: [], + errors: [], + }); + expect(closeSession).toHaveBeenCalledTimes(1); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + false, + ); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(true); + }); + + it('does not lock ids that are already archived or missing', async () => { + const archivedId = '550e8400-e29b-41d4-a716-446655440003'; + const missingId = '550e8400-e29b-41d4-a716-446655440004'; + writeSessionFile(workspaceDir, archivedId, 'archived'); + const service = new SessionService(workspaceDir); + const closeSession = vi.fn().mockResolvedValue(undefined); + const coordinator = new SessionArchiveCoordinator(); + + await coordinator.runSharedMany([archivedId, missingId], async () => { + const result = await archiveDaemonSessions({ + sessionIds: [archivedId, missingId], + service, + bridge: { closeSession }, + coordinator, + }); + + expect(result).toEqual({ + archived: [], + alreadyArchived: [archivedId], + notFound: [missingId], + errors: [], + }); + }); + expect(closeSession).not.toHaveBeenCalled(); + }); +}); + +describe('unarchiveDaemonSessions', () => { + let runtimeDir: string; + let workspaceDir: string; + + beforeEach(() => { + runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-archive-test-')); + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + Storage.setRuntimeBaseDir(runtimeDir); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + fs.rmSync(workspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('deduplicates ids and does not lock already active or missing ids', async () => { + const archivedId = '550e8400-e29b-41d4-a716-446655440011'; + const activeId = '550e8400-e29b-41d4-a716-446655440012'; + const missingId = '550e8400-e29b-41d4-a716-446655440013'; + writeSessionFile(workspaceDir, archivedId, 'archived'); + writeSessionFile(workspaceDir, activeId, 'active'); + const service = new SessionService(workspaceDir); + const coordinator = new SessionArchiveCoordinator(); + + await coordinator.runSharedMany([activeId, missingId], async () => { + const result = await unarchiveDaemonSessions({ + sessionIds: [archivedId, activeId, missingId, archivedId], + service, + coordinator, + }); + + expect(result).toEqual({ + unarchived: [archivedId], + alreadyActive: [activeId], + notFound: [missingId], + errors: [], + }); + }); + expect(fs.existsSync(sessionPath(workspaceDir, archivedId, 'active'))).toBe( + true, + ); + expect( + fs.existsSync(sessionPath(workspaceDir, archivedId, 'archived')), + ).toBe(false); + }); + + it('reports a single error per archived id when unarchive batch fails', async () => { + const archivedId = '550e8400-e29b-41d4-a716-446655440014'; + writeSessionFile(workspaceDir, archivedId, 'archived'); + const service = new SessionService(workspaceDir); + const failure = new Error('unarchive failed'); + vi.spyOn(service, 'unarchiveSessions').mockRejectedValue(failure); + + const result = await unarchiveDaemonSessions({ + sessionIds: [archivedId, archivedId], + service, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + unarchived: [], + alreadyActive: [], + notFound: [], + errors: [{ sessionId: archivedId, error: failure }], + }); + }); +}); + +function writeSessionFile( + workspaceDir: string, + sessionId: string, + state: 'active' | 'archived', + recordCwd = workspaceDir, +): void { + const chatsDir = path.join( + new Storage(workspaceDir).getProjectDir(), + 'chats', + ); + const targetDir = + state === 'archived' ? path.join(chatsDir, 'archive') : chatsDir; + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync( + path.join(targetDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + uuid: 'record-1', + parentUuid: null, + sessionId, + timestamp: '2024-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + cwd: recordCwd, + version: '1.0.0', + })}\n`, + ); +} + +function sessionPath( + workspaceDir: string, + sessionId: string, + state: 'active' | 'archived', +): string { + const chatsDir = path.join( + new Storage(workspaceDir).getProjectDir(), + 'chats', + ); + return path.join( + state === 'archived' ? path.join(chatsDir, 'archive') : chatsDir, + `${sessionId}.jsonl`, + ); +} diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts new file mode 100644 index 0000000000..d1e391d3b6 --- /dev/null +++ b/packages/cli/src/serve/server/session-archive.ts @@ -0,0 +1,442 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SessionService } from '@qwen-code/qwen-code-core'; +import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import { + SessionArchivedError, + SessionArchivingError, + SessionConflictError, + SessionNotFoundError, +} from '../acp-session-bridge.js'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { safeLogValue } from './request-helpers.js'; + +export interface DaemonArchiveSessionsResult { + archived: string[]; + alreadyArchived: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: unknown }>; +} + +export interface DaemonUnarchiveSessionsResult { + unarchived: string[]; + alreadyActive: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: unknown }>; +} + +export interface DaemonDeleteSessionsResult { + removed: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: unknown }>; +} + +export type DaemonDeleteErrorPhase = 'close' | 'remove' | 'delete'; + +export class SessionArchiveCoordinator { + private readonly exclusive = new Set(); + private readonly shared = new Map(); + + assertNotTransitioning(sessionId: string): void { + if (this.exclusive.has(sessionId)) { + throw new SessionArchivingError(sessionId); + } + } + + async runExclusiveMany( + sessionIds: string[], + fn: () => Promise, + ): Promise { + const uniqueSessionIds = [...new Set(sessionIds)]; + for (const sessionId of uniqueSessionIds) { + this.assertNotTransitioning(sessionId); + if ((this.shared.get(sessionId) ?? 0) > 0) { + throw new SessionArchivingError(sessionId, 'shared'); + } + } + for (const sessionId of uniqueSessionIds) { + this.exclusive.add(sessionId); + } + try { + return await fn(); + } finally { + for (const sessionId of uniqueSessionIds) { + this.exclusive.delete(sessionId); + } + } + } + + async runSharedMany( + sessionIds: string[], + fn: () => Promise, + ): Promise { + const uniqueSessionIds = [...new Set(sessionIds)]; + for (const sessionId of uniqueSessionIds) { + this.assertNotTransitioning(sessionId); + } + for (const sessionId of uniqueSessionIds) { + this.shared.set(sessionId, (this.shared.get(sessionId) ?? 0) + 1); + } + try { + return await fn(); + } finally { + for (const sessionId of uniqueSessionIds) { + const count = (this.shared.get(sessionId) ?? 1) - 1; + if (count <= 0) { + this.shared.delete(sessionId); + } else { + this.shared.set(sessionId, count); + } + } + } + } +} + +export async function deleteDaemonSessions(params: { + sessionIds: string[]; + service: SessionService; + bridge: Pick; + coordinator: SessionArchiveCoordinator; + onError?: (entry: { + phase: DaemonDeleteErrorPhase; + sessionId: string; + error: string; + }) => void; +}): Promise { + const { sessionIds, service, bridge, coordinator, onError } = params; + const uniqueSessionIds = [...new Set(sessionIds)]; + const closeErrors: Array<{ sessionId: string; error: string }> = []; + const removed: string[] = []; + const notFound: string[] = []; + const removeErrors: Array<{ sessionId: string; error: string }> = []; + + for (const sessionId of uniqueSessionIds) { + coordinator.assertNotTransitioning(sessionId); + } + + await Promise.all( + uniqueSessionIds.map(async (sessionId) => { + try { + // Keep close+remove under one gate so load/resume cannot recreate the + // same live session between bridge close and transcript deletion. + await coordinator.runExclusiveMany([sessionId], async () => { + let shouldRemove = false; + try { + // Intentional: batch delete bypasses per-tab ownership. + await bridge.closeSession(sessionId); + shouldRemove = true; + } catch (closeErr) { + if ( + closeErr instanceof SessionNotFoundError || + (closeErr instanceof Error && + closeErr.name === 'SessionNotFoundError') + ) { + shouldRemove = true; + } else { + const message = + closeErr instanceof Error ? closeErr.message : String(closeErr); + onError?.({ phase: 'close', sessionId, error: message }); + closeErrors.push({ sessionId, error: message }); + } + } + + if (!shouldRemove) return; + + try { + if (await service.removeSession(sessionId)) { + removed.push(sessionId); + } else { + notFound.push(sessionId); + } + } catch (removeErr) { + const message = + removeErr instanceof Error + ? removeErr.message + : String(removeErr); + onError?.({ phase: 'remove', sessionId, error: message }); + removeErrors.push({ sessionId, error: message }); + } + }); + } catch (err) { + if ( + err instanceof SessionArchivingError && + err.lockKind === 'exclusive' + ) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + onError?.({ phase: 'delete', sessionId, error: message }); + closeErrors.push({ sessionId, error: message }); + } + }), + ); + + return { removed, notFound, errors: [...closeErrors, ...removeErrors] }; +} + +export async function assertSessionLoadable( + workspaceCwd: string, + sessionId: string, +): Promise { + const location = await new SessionService(workspaceCwd).getSessionLocation( + sessionId, + ); + if (location === 'archived') { + throw new SessionArchivedError(sessionId); + } + if (location === 'conflict') { + throw new SessionConflictError(sessionId); + } +} + +function isSessionNotFoundError(err: unknown): boolean { + return ( + err instanceof SessionNotFoundError || + (err instanceof Error && err.name === 'SessionNotFoundError') + ); +} + +interface SessionLocationBuckets { + active: string[]; + archived: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: unknown }>; +} + +async function classifySessionLocations( + service: SessionService, + sessionIds: string[], +): Promise { + const result: SessionLocationBuckets = { + active: [], + archived: [], + notFound: [], + errors: [], + }; + const locationResults = await Promise.allSettled( + sessionIds.map(async (sessionId) => ({ + sessionId, + location: await service.getSessionLocation(sessionId), + })), + ); + for (let i = 0; i < locationResults.length; i++) { + const sessionId = sessionIds[i]!; + const locationResult = locationResults[i]!; + if (locationResult.status === 'rejected') { + result.errors.push({ sessionId, error: locationResult.reason }); + continue; + } + const location = locationResult.value.location; + if (location === undefined) { + result.notFound.push(sessionId); + } else if (location === 'archived') { + result.archived.push(sessionId); + } else if (location === 'conflict') { + result.errors.push({ + sessionId, + error: new Error(`Session archive conflict: ${sessionId}`), + }); + } else { + result.active.push(sessionId); + } + } + return result; +} + +function logSessionArchiveResult( + action: 'archive' | 'unarchive', + result: { + requested: string[]; + changed: string[]; + already: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: unknown }>; + }, +): void { + const changedLabel = action === 'archive' ? 'archived' : 'unarchived'; + const alreadyLabel = + action === 'archive' ? 'alreadyArchived' : 'alreadyActive'; + const details = [ + `requested=${result.requested.length} requestedIds=${formatSessionIds(result.requested)}`, + `${changedLabel}=${result.changed.length} ${changedLabel}Ids=${formatSessionIds(result.changed)}`, + `${alreadyLabel}=${result.already.length} ${alreadyLabel}Ids=${formatSessionIds(result.already)}`, + `notFound=${result.notFound.length} notFoundIds=${formatSessionIds(result.notFound)}`, + `errors=${result.errors.length} errorIds=${formatSessionErrors(result.errors)}`, + ].join(' '); + writeStderrLine(`qwen serve: sessions ${action} result ${details}`); +} + +function formatSessionIds(sessionIds: string[]): string { + return `[${sessionIds.map((sessionId) => safeLogValue(sessionId)).join(',')}]`; +} + +function formatSessionErrors( + errors: Array<{ sessionId: string; error: unknown }>, +): string { + return `[${errors + .map( + ({ sessionId, error }) => + `${safeLogValue(sessionId)}:${safeLogValue(errorMessage(error))}`, + ) + .join(',')}]`; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function logSessionArchiveWarning(message: string): void { + writeStderrLine(`qwen serve: ${sanitizeLogLine(message)}`); +} + +// Control characters are intentionally stripped from daemon log lines. +/* eslint-disable no-control-regex */ +const LOG_LINE_UNSAFE_RE = + /[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g; +/* eslint-enable no-control-regex */ + +function sanitizeLogLine(message: string): string { + return message.replace(LOG_LINE_UNSAFE_RE, ' ').slice(0, 4096); +} + +export async function archiveDaemonSessions(params: { + sessionIds: string[]; + service: SessionService; + bridge: Pick; + coordinator: SessionArchiveCoordinator; +}): Promise { + const { sessionIds, service, bridge, coordinator } = params; + const uniqueSessionIds = [...new Set(sessionIds)]; + const archived: string[] = []; + const alreadyArchived: string[] = []; + const notFound: string[] = []; + const errors: Array<{ sessionId: string; error: unknown }> = []; + + const initial = await classifySessionLocations(service, uniqueSessionIds); + const activeIds = initial.active; + alreadyArchived.push(...initial.archived); + notFound.push(...initial.notFound); + errors.push(...initial.errors); + + if (activeIds.length > 0) { + await coordinator.runExclusiveMany(activeIds, async () => { + const locked = await classifySessionLocations(service, activeIds); + const closableIds = locked.active; + alreadyArchived.push(...locked.archived); + notFound.push(...locked.notFound); + errors.push(...locked.errors); + + // Close+flush before moving JSONL: live writers keep the active path. + // If the later move fails, the active JSONL remains and a retry treats + // SessionNotFound as the recoverable "already closed" state. + const closeResults = await Promise.allSettled( + closableIds.map(async (sessionId) => { + try { + await bridge.closeSession(sessionId, undefined, { + requireAgentClose: true, + }); + } catch (err) { + if (!isSessionNotFoundError(err)) { + throw err; + } + } + }), + ); + const archiveIds: string[] = []; + for (let i = 0; i < closeResults.length; i++) { + const sessionId = closableIds[i]!; + const result = closeResults[i]!; + if (result.status === 'fulfilled') { + archiveIds.push(sessionId); + } else { + errors.push({ sessionId, error: result.reason }); + } + } + + try { + const archiveResult = await service.archiveSessions(archiveIds, { + knownLocation: 'active', + }); + archived.push(...archiveResult.archived); + alreadyArchived.push(...archiveResult.alreadyArchived); + notFound.push(...archiveResult.notFound); + errors.push(...archiveResult.errors); + } catch (err) { + for (const sessionId of archiveIds) { + errors.push({ sessionId, error: err }); + } + } + }); + } + + logSessionArchiveResult('archive', { + requested: uniqueSessionIds, + changed: archived, + already: alreadyArchived, + notFound, + errors, + }); + + return { archived, alreadyArchived, notFound, errors }; +} + +export async function unarchiveDaemonSessions(params: { + sessionIds: string[]; + service: SessionService; + coordinator: SessionArchiveCoordinator; +}): Promise { + const { sessionIds, service, coordinator } = params; + const uniqueSessionIds = [...new Set(sessionIds)]; + const unarchived: string[] = []; + const alreadyActive: string[] = []; + const notFound: string[] = []; + const errors: Array<{ sessionId: string; error: unknown }> = []; + + const initial = await classifySessionLocations(service, uniqueSessionIds); + const archivedIds = initial.archived; + alreadyActive.push(...initial.active); + notFound.push(...initial.notFound); + errors.push(...initial.errors); + + if (archivedIds.length > 0) { + await coordinator.runExclusiveMany(archivedIds, async () => { + const locked = await classifySessionLocations(service, archivedIds); + const unarchiveIds = locked.archived; + alreadyActive.push(...locked.active); + notFound.push(...locked.notFound); + errors.push(...locked.errors); + + if (unarchiveIds.length > 0) { + try { + const result = await service.unarchiveSessions(unarchiveIds, { + knownLocation: 'archived', + }); + unarchived.push(...result.unarchived); + alreadyActive.push(...result.alreadyActive); + notFound.push(...result.notFound); + errors.push(...result.errors); + } catch (err) { + // The service reports normal per-session failures in `result.errors`. + // Reaching this catch means the batch could not produce a result at all. + for (const sessionId of unarchiveIds) { + errors.push({ sessionId, error: err }); + } + } + } + }); + } + + logSessionArchiveResult('unarchive', { + requested: uniqueSessionIds, + changed: unarchived, + already: alreadyActive, + notFound, + errors, + }); + + return { unarchived, alreadyActive, notFound, errors }; +} diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index a1261676b2..46d3ae0398 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SessionService } from '@qwen-code/qwen-code-core'; +import { + SessionService, + type SessionArchiveState, +} from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge, BridgeSessionSummary, @@ -16,6 +19,7 @@ const MAX_SESSION_PAGE_SIZE = 100; export interface ListWorkspaceSessionsOptions { cursor?: string; size?: number; + archiveState?: SessionArchiveState; } export interface ListWorkspaceSessionsResult { @@ -64,9 +68,11 @@ export async function listWorkspaceSessionsForResponse( const isFirstPage = numericCursor === undefined; const sessionService = new SessionService(workspaceCwd); + const archiveState = options?.archiveState ?? 'active'; const persisted = await sessionService.listSessions({ cursor: numericCursor, size: pageSize, + archiveState, }); const bySessionId = new Map(); @@ -79,9 +85,17 @@ export async function listWorkspaceSessionsForResponse( displayName: item.customTitle || item.prompt, clientCount: 0, hasActivePrompt: false, + isArchived: item.isArchived === true, }); } + if (archiveState === 'archived') { + const sessions = [...bySessionId.values()]; + const nextCursor = + persisted.nextCursor != null ? String(persisted.nextCursor) : undefined; + return { sessions, nextCursor }; + } + const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); for (const live of liveSessions) { const existing = bySessionId.get(live.sessionId); @@ -94,6 +108,7 @@ export async function listWorkspaceSessionsForResponse( updatedAt: live.updatedAt ?? existing.updatedAt, clientCount: live.clientCount, hasActivePrompt: live.hasActivePrompt, + isArchived: false, }); } else if ( isFirstPage && @@ -104,6 +119,7 @@ export async function listWorkspaceSessionsForResponse( createdAt: live.createdAt, clientCount: live.clientCount, hasActivePrompt: live.hasActivePrompt, + isArchived: false, }); } } diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 6599c7605b..4253d53af4 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -40,6 +40,9 @@ describe('SessionService', () => { let readdirSyncSpy: MockInstance; let statSyncSpy: MockInstance; let unlinkSyncSpy: MockInstance; + let existsSyncSpy: MockInstance; + let mkdirSyncSpy: MockInstance; + let renameSyncSpy: MockInstance; beforeEach(() => { vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); @@ -63,6 +66,11 @@ describe('SessionService', () => { unlinkSyncSpy = vi .spyOn(fs, 'unlinkSync') .mockImplementation(() => undefined); + existsSyncSpy = vi.spyOn(fs, 'existsSync'); + mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); + renameSyncSpy = vi + .spyOn(fs, 'renameSync') + .mockImplementation(() => undefined); // Mock jsonl-utils. `parseLineTolerant` defaults to a no-op so any code // path that streams lines through it (e.g. countSessionMessages, @@ -176,6 +184,51 @@ describe('SessionService', () => { expect(result.items[1].sessionId).toBe(sessionIdA); }); + it('should ignore archive directory when listing active sessions', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + 'archive', + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.listSessions(); + + expect(result.items.map((item) => item.sessionId)).toEqual([sessionIdA]); + expect(result.items[0].isArchived).toBe(false); + expect(jsonl.readLines).toHaveBeenCalledTimes(1); + expect(vi.mocked(jsonl.readLines).mock.calls[0][0]).not.toContain( + '/archive/', + ); + }); + + it('should list archived sessions from archive directory only', async () => { + readdirSyncSpy.mockImplementation((dir: fs.PathLike) => { + if (dir.toString().endsWith('/chats/archive')) { + return [`${sessionIdB}.jsonl`] as unknown as Array>; + } + return [`${sessionIdA}.jsonl`] as unknown as Array>; + }); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([recordB1]); + + const result = await sessionService.listSessions({ + archiveState: 'archived', + }); + + expect(result.items.map((item) => item.sessionId)).toEqual([sessionIdB]); + expect(result.items[0].isArchived).toBe(true); + expect(vi.mocked(jsonl.readLines).mock.calls[0][0]).toContain( + '/chats/archive/', + ); + }); + it('should extract prompt text from first record', async () => { const now = Date.now(); @@ -923,6 +976,409 @@ describe('SessionService', () => { expect(result).toBe(false); }); + + it('should remove archived session files and both worktree sidecars', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) return [recordA1]; + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + ); + existsSyncSpy.mockImplementation((filePath: fs.PathLike) => + filePath.toString().endsWith(`${sessionIdA}.worktree.json`), + ); + + const result = await sessionService.removeSession(sessionIdA); + + expect(result).toBe(true); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + ); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.worktree.json`), + ); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`), + ); + }); + + it('should remove both JSONL files when active and archived copies conflict', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + existsSyncSpy.mockImplementation((filePath: fs.PathLike) => + filePath.toString().endsWith(`/chats/archive/${sessionIdA}.jsonl`), + ); + + const result = await sessionService.removeSession(sessionIdA); + + expect(result).toBe(true); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + expect(unlinkSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + ); + }); + }); + + describe('archiveSessions', () => { + beforeEach(() => { + mkdirSyncSpy.mockImplementation(() => undefined); + }); + + const mockActiveSessionOnly = () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + }; + + const mockActiveWorktreeSidecarOnly = () => { + existsSyncSpy.mockImplementation((filePath) => { + const value = filePath.toString(); + if (value.endsWith(`/chats/archive/${sessionIdA}.jsonl`)) { + return false; + } + if (value.endsWith(`/chats/${sessionIdA}.worktree.json`)) { + return true; + } + if (value.endsWith(`/chats/archive/${sessionIdA}.worktree.json`)) { + return false; + } + return false; + }); + }; + + it('should move active sessions into the archive directory', async () => { + mockActiveSessionOnly(); + const result = await sessionService.archiveSessions([sessionIdA]); + + expect(result.archived).toEqual([sessionIdA]); + expect(result.alreadyArchived).toEqual([]); + expect(result.notFound).toEqual([]); + expect(result.errors).toEqual([]); + expect(mkdirSyncSpy).toHaveBeenCalledWith( + expect.stringContaining('/chats/archive'), + { recursive: true }, + ); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + ); + }); + + it('should archive JSONL and warn when archiving worktree sidecar fails', async () => { + mockActiveSessionOnly(); + mockActiveWorktreeSidecarOnly(); + const warnings: string[] = []; + const service = new SessionService('/test/project/root', { + onWarning: (message) => warnings.push(message), + }); + const sidecarError = new Error('sidecar move failed'); + renameSyncSpy.mockImplementation((sourcePath) => { + if (sourcePath.toString().endsWith('.worktree.json')) { + throw sidecarError; + } + return undefined; + }); + + const result = await service.archiveSessions([sessionIdA]); + + expect(result.archived).toEqual([sessionIdA]); + expect(result.errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain( + `archiveSessions: failed to move worktree sidecar for ${sessionIdA}`, + ); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + ); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.worktree.json`), + expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`), + ); + }); + + it('should not move worktree sidecar when archiving JSONL fails', async () => { + mockActiveSessionOnly(); + mockActiveWorktreeSidecarOnly(); + const jsonlError = new Error( + `EACCES: permission denied, rename '/tmp/runtime/chats/${sessionIdA}.jsonl' -> '/tmp/runtime/chats/archive/${sessionIdA}.jsonl'`, + ) as NodeJS.ErrnoException; + jsonlError.code = 'EACCES'; + renameSyncSpy.mockImplementation((sourcePath) => { + if (sourcePath.toString().endsWith('.jsonl')) { + throw jsonlError; + } + return undefined; + }); + + const result = await sessionService.archiveSessions([sessionIdA]); + + expect(result.archived).toEqual([]); + expect(result.errors[0]?.sessionId).toBe(sessionIdA); + expect(result.errors[0]?.error.message).toBe( + 'Failed to archive session file: EACCES', + ); + expect(result.errors[0]?.error.message).not.toContain('/tmp/runtime'); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + ); + expect(renameSyncSpy).not.toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.worktree.json`), + expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`), + ); + }); + + it('should skip location reads when archiving known active sessions', async () => { + const getLocationSpy = vi.spyOn(sessionService, 'getSessionLocation'); + + const result = await sessionService.archiveSessions([sessionIdA], { + knownLocation: 'active', + }); + + expect(result.archived).toEqual([sessionIdA]); + expect(result.errors).toEqual([]); + expect(getLocationSpy).not.toHaveBeenCalled(); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + ); + }); + + it('should report already archived sessions without moving them', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) return [recordA1]; + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + ); + + const result = await sessionService.archiveSessions([sessionIdA]); + + expect(result.archived).toEqual([]); + expect(result.alreadyArchived).toEqual([sessionIdA]); + expect(renameSyncSpy).not.toHaveBeenCalled(); + }); + + it('should report active and archived duplicate ids as errors', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.archiveSessions([sessionIdA]); + + expect(result.archived).toEqual([]); + expect(result.errors[0]?.sessionId).toBe(sessionIdA); + expect(result.errors[0]?.error.message).toMatch(/conflict/i); + expect(renameSyncSpy).not.toHaveBeenCalled(); + }); + }); + + describe('unarchiveSessions', () => { + beforeEach(() => { + mkdirSyncSpy.mockImplementation(() => undefined); + }); + + const mockArchivedSessionOnly = () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) return [recordA1]; + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + ); + }; + + const mockArchivedWorktreeSidecarOnly = () => { + existsSyncSpy.mockImplementation((filePath) => { + const value = filePath.toString(); + if (value.endsWith(`/chats/${sessionIdA}.jsonl`)) { + return false; + } + if (value.endsWith(`/chats/archive/${sessionIdA}.worktree.json`)) { + return true; + } + if (value.endsWith(`/chats/${sessionIdA}.worktree.json`)) { + return false; + } + return false; + }); + }; + + it('should move archived sessions back to the active directory', async () => { + mockArchivedSessionOnly(); + + const result = await sessionService.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([sessionIdA]); + expect(result.alreadyActive).toEqual([]); + expect(result.notFound).toEqual([]); + expect(result.errors).toEqual([]); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + }); + + it('should skip location reads when unarchiving known archived sessions', async () => { + mockArchivedSessionOnly(); + const getLocationSpy = vi.spyOn(sessionService, 'getSessionLocation'); + + const result = await sessionService.unarchiveSessions([sessionIdA], { + knownLocation: 'archived', + }); + + expect(result.unarchived).toEqual([sessionIdA]); + expect(result.errors).toEqual([]); + expect(getLocationSpy).not.toHaveBeenCalled(); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + }); + + it('should recreate active chats directory before moving archived sessions', async () => { + mockArchivedSessionOnly(); + + const result = await sessionService.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([sessionIdA]); + expect(mkdirSyncSpy).toHaveBeenCalledWith( + expect.stringMatching(/\/chats$/), + { recursive: true }, + ); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + }); + + it('should report not found when neither active nor archived file exists', async () => { + vi.mocked(jsonl.readLines).mockImplementation(async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }); + + const result = await sessionService.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([]); + expect(result.alreadyActive).toEqual([]); + expect(result.notFound).toEqual([sessionIdA]); + expect(result.errors).toEqual([]); + expect(renameSyncSpy).not.toHaveBeenCalled(); + }); + + it('should report already active sessions without moving them', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); + + const result = await sessionService.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([]); + expect(result.alreadyActive).toEqual([sessionIdA]); + expect(result.notFound).toEqual([]); + expect(result.errors).toEqual([]); + expect(renameSyncSpy).not.toHaveBeenCalled(); + }); + + it('should unarchive JSONL and warn when worktree sidecar move fails', async () => { + mockArchivedSessionOnly(); + mockArchivedWorktreeSidecarOnly(); + const warnings: string[] = []; + const service = new SessionService('/test/project/root', { + onWarning: (message) => warnings.push(message), + }); + const sidecarError = new Error('sidecar move failed'); + renameSyncSpy.mockImplementation((sourcePath) => { + if (sourcePath.toString().endsWith('.worktree.json')) { + throw sidecarError; + } + return undefined; + }); + + const result = await service.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([sessionIdA]); + expect(result.errors).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain( + `unarchiveSessions: failed to move worktree sidecar for ${sessionIdA}`, + ); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`), + expect.stringContaining(`/chats/${sessionIdA}.worktree.json`), + ); + }); + + it('should not move worktree sidecar when unarchiving JSONL fails', async () => { + mockArchivedSessionOnly(); + mockArchivedWorktreeSidecarOnly(); + const jsonlError = new Error( + `ENOSPC: no space left on device, rename '/tmp/runtime/chats/archive/${sessionIdA}.jsonl' -> '/tmp/runtime/chats/${sessionIdA}.jsonl'`, + ) as NodeJS.ErrnoException; + jsonlError.code = 'ENOSPC'; + renameSyncSpy.mockImplementation((sourcePath) => { + if ( + sourcePath.toString().endsWith('.jsonl') && + sourcePath.toString().includes('/chats/archive/') + ) { + throw jsonlError; + } + return undefined; + }); + + const result = await sessionService.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([]); + expect(result.errors[0]?.sessionId).toBe(sessionIdA); + expect(result.errors[0]?.error.message).toBe( + 'Failed to unarchive session file: ENOSPC', + ); + expect(result.errors[0]?.error.message).not.toContain('/tmp/runtime'); + expect(renameSyncSpy).toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.jsonl`), + expect.stringContaining(`/chats/${sessionIdA}.jsonl`), + ); + expect(renameSyncSpy).not.toHaveBeenCalledWith( + expect.stringContaining(`/chats/archive/${sessionIdA}.worktree.json`), + expect.stringContaining(`/chats/${sessionIdA}.worktree.json`), + ); + }); + + it('should reject unarchive when active and archived files both exist', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.unarchiveSessions([sessionIdA]); + + expect(result.unarchived).toEqual([]); + expect(result.errors[0]?.sessionId).toBe(sessionIdA); + expect(result.errors[0]?.error.message).toMatch(/conflict/i); + expect(renameSyncSpy).not.toHaveBeenCalled(); + }); }); describe('removeSessions', () => { @@ -931,6 +1387,11 @@ describe('SessionService', () => { // never has a backing record (notFound). vi.mocked(jsonl.readLines).mockImplementation( async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } if (filePath.includes(sessionIdA)) return [recordA1]; if (filePath.includes(sessionIdB)) return [recordB1]; return []; @@ -950,7 +1411,16 @@ describe('SessionService', () => { }); it('should de-duplicate input ids', async () => { - vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return [recordA1]; + }, + ); const result = await sessionService.removeSessions([ sessionIdA, @@ -966,6 +1436,11 @@ describe('SessionService', () => { it('should keep going when one removal fails', async () => { vi.mocked(jsonl.readLines).mockImplementation( async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } if (filePath.includes(sessionIdA)) return [recordA1]; if (filePath.includes(sessionIdB)) return [recordB1]; return []; @@ -1144,6 +1619,35 @@ describe('SessionService', () => { }); }); + describe('getSessionLocation', () => { + it('should report conflict when active and archived files both exist', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + await expect(sessionService.getSessionLocation(sessionIdA)).resolves.toBe( + 'conflict', + ); + }); + + it('should warn when reading a session head fails', async () => { + const warnings: string[] = []; + const service = new SessionService('/test/project/root', { + onWarning: (message) => warnings.push(message), + }); + const error = new Error('malformed JSON'); + vi.mocked(jsonl.readLines).mockRejectedValue(error); + + await expect(service.getSessionLocation(sessionIdA)).rejects.toThrow( + error, + ); + expect(warnings).toHaveLength(2); + for (const warning of warnings) { + expect(warning).toContain('readProjectSessionHead: failed to read'); + expect(warning).toContain(`${sessionIdA}.jsonl`); + expect(warning).toContain('malformed JSON'); + } + }); + }); + describe('loadLastSession', () => { it('should return the most recent session (same as getLatestSession)', async () => { const now = Date.now(); @@ -1247,6 +1751,41 @@ describe('SessionService', () => { expect(exists).toBe(true); }); + + it('should keep default existence checks active-only', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) return [recordA1]; + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + ); + + await expect(sessionService.sessionExists(sessionIdA)).resolves.toBe( + false, + ); + await expect( + sessionService.sessionExistsInAnyState(sessionIdA), + ).resolves.toBe(true); + }); + + it('should treat unreadable active or archived files as existing for any-state checks', async () => { + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes('/chats/archive/')) { + throw new Error('malformed jsonl'); + } + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + ); + + await expect( + sessionService.sessionExistsInAnyState(sessionIdA), + ).resolves.toBe(true); + }); }); describe('getResumePromptTokenCount', () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ab4767ccbb..d9404b750a 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -75,8 +75,14 @@ export interface SessionListItem { * chose. */ titleSource?: TitleSource; + /** True when the item was read from the archive directory. */ + isArchived?: boolean; } +export type SessionArchiveState = 'active' | 'archived'; + +export type SessionLocation = SessionArchiveState | 'conflict' | undefined; + /** * Pagination options for listing sessions. */ @@ -92,6 +98,11 @@ export interface ListSessionsOptions { * @default 20 */ size?: number; + /** + * Which session directory to list. + * @default 'active' + */ + archiveState?: SessionArchiveState; } /** @@ -118,6 +129,32 @@ export interface RemoveSessionsResult { errors: Array<{ sessionId: string; error: Error }>; } +export interface ArchiveSessionsResult { + archived: string[]; + alreadyArchived: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: Error }>; +} + +export interface ArchiveSessionsOptions { + knownLocation?: 'active'; +} + +export interface UnarchiveSessionsResult { + unarchived: string[]; + alreadyActive: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: Error }>; +} + +export interface UnarchiveSessionsOptions { + knownLocation?: 'archived'; +} + +export interface SessionServiceOptions { + onWarning?: (message: string) => void; +} + /** * Complete conversation reconstructed from ChatRecords. * Used for resuming sessions and API compatibility. @@ -235,17 +272,51 @@ export class SessionService { private readonly storage: Storage; private readonly projectHash: string; private readonly projectRoot: string; + private readonly onWarning: ((message: string) => void) | undefined; - constructor(cwd: string) { + constructor(cwd: string, options: SessionServiceOptions = {}) { this.storage = new Storage(cwd); this.projectRoot = cwd; this.projectHash = getProjectHash(cwd); + this.onWarning = options.onWarning; + } + + private warn(message: string): void { + debugLogger.warn(message); + this.onWarning?.(message); } private getChatsDir(): string { return path.join(this.storage.getProjectDir(), 'chats'); } + private getArchiveChatsDir(): string { + return path.join(this.getChatsDir(), 'archive'); + } + + private getChatsDirForState(state: SessionArchiveState): string { + return state === 'archived' + ? this.getArchiveChatsDir() + : this.getChatsDir(); + } + + private getSessionFilePath( + sessionId: string, + state: SessionArchiveState, + ): string { + return path.join(this.getChatsDirForState(state), `${sessionId}.jsonl`); + } + + private getWorktreeSessionPathForState( + sessionId: string, + state: SessionArchiveState, + ): string { + return path.join( + this.getChatsDirForState(state), + `${sessionId}.worktree.json`, + ); + } + private async sessionBelongsToCurrentProject( sessionId: string, recordCwd: string, @@ -269,7 +340,93 @@ export class SessionService { * exist yet — consumers must handle ENOENT as "no active worktree". */ getWorktreeSessionPath(sessionId: string): string { - return path.join(this.getChatsDir(), `${sessionId}.worktree.json`); + return this.getWorktreeSessionPathForState(sessionId, 'active'); + } + + private async readProjectSessionHead( + sessionId: string, + filePath: string, + ): Promise { + try { + const records = await jsonl.readLines(filePath, 1); + if (records.length === 0) { + return undefined; + } + const firstRecord = records[0]; + if ( + !(await this.sessionBelongsToCurrentProject(sessionId, firstRecord.cwd)) + ) { + return undefined; + } + return firstRecord; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + this.warn(`readProjectSessionHead: failed to read ${filePath}: ${error}`); + throw error; + } + } + + async getSessionLocation(sessionId: string): Promise { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return undefined; + } + + const [active, archived] = await Promise.all([ + this.readProjectSessionHead( + sessionId, + this.getSessionFilePath(sessionId, 'active'), + ), + this.readProjectSessionHead( + sessionId, + this.getSessionFilePath(sessionId, 'archived'), + ), + ]); + + if (active && archived) return 'conflict'; + if (active) return 'active'; + if (archived) return 'archived'; + return undefined; + } + + private removeFileIfExists(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + + private removeWorktreeSidecars(sessionId: string): void { + for (const state of ['active', 'archived'] as const) { + const sidecar = this.getWorktreeSessionPathForState(sessionId, state); + if (fs.existsSync(sidecar)) { + this.removeFileIfExists(sidecar); + } + } + } + + private moveOptionalFile(sourcePath: string, targetPath: string): boolean { + if (!fs.existsSync(sourcePath)) { + return false; + } + if (fs.existsSync(targetPath)) { + throw new Error('Archive sidecar conflict: destination already exists'); + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.renameSync(sourcePath, targetPath); + return true; + } + + private sessionFileMoveError( + action: 'archive' | 'unarchive', + error: unknown, + ): Error { + const code = (error as NodeJS.ErrnoException).code ?? 'unknown error'; + return new Error(`Failed to ${action} session file: ${code}`); } /** @@ -336,6 +493,9 @@ export class SessionService { * Public accessor: returns both the current custom title and its source * for a given session. Used by `ChatRecordingService` on resume to * preserve the persisted `titleSource` rather than defaulting to manual. + * + * @remarks Only checks active sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ getSessionTitleInfo(sessionId: string): { title?: string; @@ -473,6 +633,9 @@ export class SessionService { * of multi-MB sessions. Call this lazily, only when a specific * session's message count is about to be displayed (e.g., from a * preview panel) or computed from a resumed conversation. + * + * @remarks Only checks active sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ async countSessionMessages(sessionId: string): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { @@ -544,8 +707,9 @@ export class SessionService { async listSessions( options: ListSessionsOptions = {}, ): Promise { - const { cursor, size = 20 } = options; - const chatsDir = this.getChatsDir(); + const { cursor, size = 20, archiveState = 'active' } = options; + const chatsDir = this.getChatsDirForState(archiveState); + const isArchived = archiveState === 'archived'; // Get all valid session files (matching UUID pattern) with their stats let files: Array<{ name: string; mtime: number }> = []; @@ -642,6 +806,7 @@ export class SessionService { // and `countSessionMessages` for the rationale. customTitle: titleInfo.title, titleSource: titleInfo.source, + isArchived, }); } @@ -772,7 +937,9 @@ export class SessionService { * Reconstructs the full conversation from tree-structured records. * * @param sessionId The session ID to load - * @returns Session data for resumption, or null if not found + * @returns Session data for resumption, or undefined if not found + * @remarks Only checks active sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ async loadSession( sessionId: string, @@ -867,23 +1034,29 @@ export class SessionService { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { return false; } - const chatsDir = this.getChatsDir(); - const filePath = path.join(chatsDir, `${sessionId}.jsonl`); try { - // Verify the file exists and belongs to this project - const records = await jsonl.readLines(filePath, 1); - if (records.length === 0) { + const activePath = this.getSessionFilePath(sessionId, 'active'); + const active = await this.readProjectSessionHead(sessionId, activePath); + if (active) { + this.removeFileIfExists(activePath); + const archivedPath = this.getSessionFilePath(sessionId, 'archived'); + if (fs.existsSync(archivedPath)) { + this.removeFileIfExists(archivedPath); + } + this.removeWorktreeSidecars(sessionId); + return true; + } + const archivedPath = this.getSessionFilePath(sessionId, 'archived'); + const archived = await this.readProjectSessionHead( + sessionId, + archivedPath, + ); + if (!archived) { return false; } - - if ( - !(await this.sessionBelongsToCurrentProject(sessionId, records[0].cwd)) - ) { - return false; - } - - fs.unlinkSync(filePath); + this.removeFileIfExists(archivedPath); + this.removeWorktreeSidecars(sessionId); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { @@ -893,6 +1066,140 @@ export class SessionService { } } + async archiveSessions( + sessionIds: string[], + options: ArchiveSessionsOptions = {}, + ): Promise { + const archived: string[] = []; + const alreadyArchived: string[] = []; + const notFound: string[] = []; + const errors: Array<{ sessionId: string; error: Error }> = []; + + for (const sessionId of [...new Set(sessionIds)]) { + try { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + notFound.push(sessionId); + continue; + } + if (options.knownLocation !== 'active') { + const location = await this.getSessionLocation(sessionId); + if (location === undefined) { + notFound.push(sessionId); + continue; + } + if (location === 'archived') { + alreadyArchived.push(sessionId); + continue; + } + if (location === 'conflict') { + throw new Error(`Session archive conflict: ${sessionId}`); + } + } + + const sourcePath = this.getSessionFilePath(sessionId, 'active'); + const targetPath = this.getSessionFilePath(sessionId, 'archived'); + if (fs.existsSync(targetPath)) { + throw new Error(`Session archive conflict: ${sessionId}`); + } + + fs.mkdirSync(this.getArchiveChatsDir(), { recursive: true }); + const activeSidecar = this.getWorktreeSessionPathForState( + sessionId, + 'active', + ); + const archivedSidecar = this.getWorktreeSessionPathForState( + sessionId, + 'archived', + ); + try { + fs.renameSync(sourcePath, targetPath); + } catch (error) { + throw this.sessionFileMoveError('archive', error); + } + try { + this.moveOptionalFile(activeSidecar, archivedSidecar); + } catch (sidecarError) { + this.warn( + `archiveSessions: failed to move worktree sidecar for ${sessionId} from ${activeSidecar} to ${archivedSidecar}: ${sidecarError}`, + ); + } + archived.push(sessionId); + } catch (error) { + errors.push({ + sessionId, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + + return { archived, alreadyArchived, notFound, errors }; + } + + async unarchiveSessions( + sessionIds: string[], + options: UnarchiveSessionsOptions = {}, + ): Promise { + const unarchived: string[] = []; + const alreadyActive: string[] = []; + const notFound: string[] = []; + const errors: Array<{ sessionId: string; error: Error }> = []; + + for (const sessionId of [...new Set(sessionIds)]) { + try { + if (options.knownLocation !== 'archived') { + const location = await this.getSessionLocation(sessionId); + if (location === undefined) { + notFound.push(sessionId); + continue; + } + if (location === 'active') { + alreadyActive.push(sessionId); + continue; + } + if (location === 'conflict') { + throw new Error(`Session archive conflict: ${sessionId}`); + } + } + + const sourcePath = this.getSessionFilePath(sessionId, 'archived'); + const targetPath = this.getSessionFilePath(sessionId, 'active'); + if (fs.existsSync(targetPath)) { + throw new Error(`Session archive conflict: ${sessionId}`); + } + + const archivedSidecar = this.getWorktreeSessionPathForState( + sessionId, + 'archived', + ); + const activeSidecar = this.getWorktreeSessionPathForState( + sessionId, + 'active', + ); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + try { + fs.renameSync(sourcePath, targetPath); + } catch (error) { + throw this.sessionFileMoveError('unarchive', error); + } + try { + this.moveOptionalFile(archivedSidecar, activeSidecar); + } catch (sidecarError) { + this.warn( + `unarchiveSessions: failed to move worktree sidecar for ${sessionId} from ${archivedSidecar} to ${activeSidecar}: ${sidecarError}`, + ); + } + unarchived.push(sessionId); + } catch (error) { + errors.push({ + sessionId, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + + return { unarchived, alreadyActive, notFound, errors }; + } + /** * Removes multiple sessions in one call. * @@ -944,6 +1251,8 @@ export class SessionService { * existing callers are unchanged — pass `'auto'` only for titles produced * by the auto-title generator. * @returns true if renamed successfully, false if session not found + * @remarks Only checks active sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ async renameSession( sessionId: string, @@ -1017,6 +1326,8 @@ export class SessionService { * * @throws If source does not exist, source is empty, source belongs to a * different project, or the target file already exists. + * @remarks Only checks active source sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ async forkSession( sourceSessionId: string, @@ -1147,6 +1458,8 @@ export class SessionService { * * @param sessionId The session ID to look up * @returns The custom title, or undefined if none set + * @remarks Only checks active sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ getSessionTitle(sessionId: string): string | undefined { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { @@ -1163,6 +1476,8 @@ export class SessionService { * * @param title The custom title to search for (case-insensitive exact match) * @returns Array of matching session list items + * @remarks Only searches active sessions. Archived title search is not + * supported. */ async findSessionsByTitle(title: string): Promise { const normalizedTitle = title.toLowerCase().trim(); @@ -1339,6 +1654,8 @@ export class SessionService { * * @param sessionId The session ID to check * @returns true if session exists and belongs to current project + * @remarks Only checks active sessions. Use `getSessionLocation()` or + * `sessionExistsInAnyState()` for archive-aware lookups. */ async sessionExists(sessionId: string): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { @@ -1357,6 +1674,15 @@ export class SessionService { return false; } } + + async sessionExistsInAnyState(sessionId: string): Promise { + try { + const location = await this.getSessionLocation(sessionId); + return location !== undefined; + } catch { + return true; + } + } } /** diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 63fd15fd5c..812b1d1709 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -37,9 +37,10 @@ const rootDir = join(__dirname, '..'); // Bumped from 130KB to 131KB for the workspace MCP resources drill-down // (workspaceMcpResources client method + route + resource status types). // Bumped from 131KB to 132KB for the pending prompt queue feature. -// Bumped from 132KB to 133KB for sessionless workspace remember -// (rememberWorkspaceMemory/getWorkspaceMemoryRememberTask + managed memory event validation). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 133 * 1024; +// Bumped from 132KB to 133KB for session archive/unarchive APIs and sessionless +// workspace remember (managed memory client methods + event validation). +// Bumped from 133KB to 134KB after merging both surfaces with main. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 134 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/AcpHttpTransport.ts b/packages/sdk-typescript/src/daemon/AcpHttpTransport.ts index 1ef809d340..66cf7008e9 100644 --- a/packages/sdk-typescript/src/daemon/AcpHttpTransport.ts +++ b/packages/sdk-typescript/src/daemon/AcpHttpTransport.ts @@ -19,7 +19,7 @@ import { import { matchRoute, synthesizeResponse, - jsonRpcErrorToHttpStatus, + jsonRpcErrorToHttpStatusWithData, isRecord, composeAbortSignals, mergeHeaders, @@ -284,7 +284,10 @@ export class AcpHttpTransport implements DaemonTransport { const httpStatus = isRecord(errorData) && typeof errorData['httpStatus'] === 'number' ? errorData['httpStatus'] - : jsonRpcErrorToHttpStatus(response.error.code); + : jsonRpcErrorToHttpStatusWithData( + response.error.code, + response.error.data, + ); return synthesizeResponse(httpStatus, { error: response.error.message, ...(response.error.data != null ? { data: response.error.data } : {}), diff --git a/packages/sdk-typescript/src/daemon/AcpWsTransport.ts b/packages/sdk-typescript/src/daemon/AcpWsTransport.ts index 329c5ec3e3..d07436bff0 100644 --- a/packages/sdk-typescript/src/daemon/AcpWsTransport.ts +++ b/packages/sdk-typescript/src/daemon/AcpWsTransport.ts @@ -18,7 +18,7 @@ import { import { matchRoute, synthesizeResponse, - jsonRpcErrorToHttpStatus, + jsonRpcErrorToHttpStatusWithData, isRecord, } from './acpTransportUtils.js'; @@ -214,7 +214,10 @@ export class AcpWsTransport implements DaemonTransport { const status = isRecord(errorData) && typeof errorData['httpStatus'] === 'number' ? errorData['httpStatus'] - : jsonRpcErrorToHttpStatus(response.error.code); + : jsonRpcErrorToHttpStatusWithData( + response.error.code, + response.error.data, + ); return synthesizeResponse(status, { error: response.error.message, ...(response.error.data != null ? { data: response.error.data } : {}), diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 4640ce5972..9587804d41 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -21,6 +21,7 @@ import type { DaemonAuthStatusSnapshot, DaemonCapabilities, DaemonCreateAgentRequest, + DaemonArchiveSessionsResult, DaemonGeneratedAgentContent, DaemonDeviceFlowStartResult, DaemonDeviceFlowState, @@ -32,6 +33,7 @@ import type { DaemonForkSessionResult, DaemonRestoredSession, DaemonSession, + DaemonSessionArchiveState, DaemonSessionLspStatus, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, @@ -112,6 +114,7 @@ import type { DaemonWorkspaceTrustChangeRequest, DaemonWorkspaceTrustChangeResult, DaemonWorkspaceTrustStatus, + DaemonUnarchiveSessionsResult, } from './types.js'; const WORKSPACE_MEMORY_REMEMBER_PATH = '/workspace/memory/remember'; @@ -1322,7 +1325,10 @@ export class DaemonClient { */ async listWorkspaceSessions( workspaceCwd: string, - options?: { pageSize?: number }, + options?: { + pageSize?: number; + archiveState?: DaemonSessionArchiveState; + }, ): Promise { const requestedPageSize = options?.pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE; @@ -1337,10 +1343,14 @@ export class DaemonClient { ), ), ); + const query = new URLSearchParams({ size: String(pageSize) }); + if (options?.archiveState !== undefined) { + query.set('archiveState', options.archiveState); + } const body = await this.jsonRequest<{ sessions: DaemonSessionSummary[]; }>( - `/workspace/${encodeURIComponent(workspaceCwd)}/sessions?size=${pageSize}`, + `/workspace/${encodeURIComponent(workspaceCwd)}/sessions?${query.toString()}`, 'GET /workspace/sessions', ); return body.sessions; @@ -2735,6 +2745,46 @@ export class DaemonClient { ); } + async archiveSessionsData( + sessionIds: string[], + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/sessions/archive`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify({ sessionIds }), + }, + async (res) => { + if (res.ok) { + return (await res.json()) as DaemonArchiveSessionsResult; + } + throw await this.failOnError(res, 'POST /sessions/archive'); + }, + ); + } + + async unarchiveSessionsData( + sessionIds: string[], + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/sessions/unarchive`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify({ sessionIds }), + }, + async (res) => { + if (res.ok) { + return (await res.json()) as DaemonUnarchiveSessionsResult; + } + throw await this.failOnError(res, 'POST /sessions/unarchive'); + }, + ); + } + // -- Auth device-flow --------------------------------------------------- /** diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index d00b8963ea..d95faea429 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -655,6 +655,26 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ }, }, + // GET /workspace/:id/sessions → session/list + { + httpMethod: 'GET', + pattern: /^\/workspace\/(.+)\/sessions\/?$/, + mapping: { + method: 'session/list', + extractParams: (segs, _body, _method, query) => { + const size = query?.get('size'); + return { + workspaceCwd: segs[0], + ...strParam(query, 'cursor'), + ...strParam(query, 'archiveState'), + ...(size == null || size === '' + ? {} + : { _meta: { size: Number(size) } }), + }; + }, + }, + }, + // ---- Workspace catch-all (must be AFTER all specific workspace routes) -- // Handles any workspace path not matched above (e.g., /workspace/custom/path). { @@ -765,4 +785,22 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ extractParams: (_s, body) => (isRecord(body) ? body : {}), }, }, + // POST /sessions/archive → _qwen/sessions/archive + { + httpMethod: 'POST', + pattern: /^\/sessions\/archive\/?$/, + mapping: { + method: '_qwen/sessions/archive', + extractParams: (_s, body) => (isRecord(body) ? body : {}), + }, + }, + // POST /sessions/unarchive → _qwen/sessions/unarchive + { + httpMethod: 'POST', + pattern: /^\/sessions\/unarchive\/?$/, + mapping: { + method: '_qwen/sessions/unarchive', + extractParams: (_s, body) => (isRecord(body) ? body : {}), + }, + }, ]; diff --git a/packages/sdk-typescript/src/daemon/acpTransportUtils.ts b/packages/sdk-typescript/src/daemon/acpTransportUtils.ts index c6690a982f..ac1a3bff31 100644 --- a/packages/sdk-typescript/src/daemon/acpTransportUtils.ts +++ b/packages/sdk-typescript/src/daemon/acpTransportUtils.ts @@ -48,6 +48,21 @@ export function synthesizeResponse(status: number, body: unknown): Response { * Map a JSON-RPC error code to an HTTP status code. */ export function jsonRpcErrorToHttpStatus(code: number): number { + return jsonRpcErrorToHttpStatusWithData(code); +} + +export function jsonRpcErrorToHttpStatusWithData( + code: number, + data?: unknown, +): number { + if ( + isRecord(data) && + (data['errorKind'] === 'session_archived' || + data['errorKind'] === 'session_conflict' || + data['errorKind'] === 'session_archiving') + ) { + return 409; + } // JSON-RPC error code → HTTP status mapping. // -32600 = invalid request → 400 // -32601 = method not found → 404 diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index b060535e7d..61ae908077 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -361,6 +361,7 @@ export type { DaemonWorkspaceTrustState, DaemonWorkspaceTrustStatus, DaemonAvailableCommand, + DaemonArchiveSessionsResult, DaemonCapabilities, DaemonContextCategoryBreakdown, DaemonContextFileScope, @@ -383,6 +384,7 @@ export type { ForkSessionRequest, DaemonRestoredSession, DaemonSession, + DaemonSessionArchiveState, DaemonAuthProviderId, DaemonAuthProviderBaseUrlOption, DaemonAuthProviderCatalog, @@ -455,6 +457,7 @@ export type { DaemonWorkspaceToolsStatus, DaemonWriteMemoryRequest, DaemonWriteMemoryResult, + DaemonUnarchiveSessionsResult, DaemonCommandHookConfig, DaemonFunctionHookConfig, DaemonHookConfig, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 3ad06a6c0c..4e241e6bb9 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -211,6 +211,23 @@ export interface DaemonSessionSummary { displayName?: string; clientCount?: number; hasActivePrompt?: boolean; + isArchived?: boolean; +} + +export type DaemonSessionArchiveState = 'active' | 'archived'; + +export interface DaemonArchiveSessionsResult { + archived: string[]; + alreadyArchived: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: string }>; +} + +export interface DaemonUnarchiveSessionsResult { + unarchived: string[]; + alreadyActive: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: string }>; } /** Effective mutable metadata returned from `PATCH /session/:id/metadata`. */ diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index fa114e6d8f..63ca83b6a1 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1680,6 +1680,47 @@ describe('DaemonClient', () => { }); }); + describe('archiveSessionsData / unarchiveSessionsData', () => { + it('POSTs to /sessions/archive with sessionIds in body and returns result', async () => { + const result = { + archived: ['s-1'], + alreadyArchived: ['s-2'], + notFound: [], + errors: [], + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, result)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const res = await client.archiveSessionsData(['s-1', 's-2']); + expect(res).toEqual(result); + expect(calls[0]?.url).toBe('http://daemon/sessions/archive'); + expect(calls[0]?.method).toBe('POST'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + sessionIds: ['s-1', 's-2'], + }); + }); + + it('POSTs to /sessions/unarchive with sessionIds in body and returns result', async () => { + const result = { + unarchived: ['s-1'], + alreadyActive: ['s-2'], + notFound: [], + errors: [], + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, result)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const res = await client.unarchiveSessionsData( + ['s-1', 's-2'], + 'client-1', + ); + expect(res).toEqual(result); + expect(calls[0]?.url).toBe('http://daemon/sessions/unarchive'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + sessionIds: ['s-1', 's-2'], + }); + }); + }); + describe('updateSessionMetadata', () => { it('sends PATCH to /session/:id/metadata and returns effective metadata', async () => { const { fetch, calls } = recordingFetch(() => @@ -1843,6 +1884,28 @@ describe('DaemonClient', () => { ]); }); + it('passes archiveState when listing archived sessions', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessions: [ + { + sessionId: 's-archived', + workspaceCwd: '/work/a', + isArchived: true, + }, + ], + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const sessions = await client.listWorkspaceSessions('/work/a', { + archiveState: 'archived', + }); + expect(sessions[0]?.isArchived).toBe(true); + expect(calls[0]?.url).toBe( + 'http://daemon/workspace/%2Fwork%2Fa/sessions?size=20&archiveState=archived', + ); + }); + it('throws on non-2xx (e.g. 400 from a relative path)', async () => { const { fetch } = recordingFetch(() => jsonResponse(400, { error: 'must be absolute' }), diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 07fa180cda..883f5aed5d 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -191,6 +191,24 @@ describe('acpRouteTable – matchRoute', () => { expect(result!.mapping.method).toBe('_qwen/health'); }); + it('GET /workspace/:id/sessions maps to session/list', () => { + const result = matchRoute('/workspace/%2Fwork%2Fa/sessions', 'GET'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('session/list'); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + new URLSearchParams('size=50&archiveState=archived&cursor=123'), + ); + expect(params).toEqual({ + workspaceCwd: '/work/a', + cursor: '123', + archiveState: 'archived', + _meta: { size: 50 }, + }); + }); + // ---- POST /session/:id/model → session/set_model -------------------- it('POST /session/:id/model maps to session/set_model', () => { @@ -663,6 +681,21 @@ describe('acpRouteTable – matchRoute', () => { expect(params).toEqual({ sessionIds: ['a', 'b'] }); }); + it('POST /sessions/archive maps to _qwen/sessions/archive', () => { + const result = matchRoute('/sessions/archive', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/sessions/archive'); + expect( + result!.mapping.extractParams([], { sessionIds: ['s-1'] }, 'POST'), + ).toEqual({ sessionIds: ['s-1'] }); + }); + + it('POST /sessions/unarchive maps to _qwen/sessions/unarchive', () => { + const result = matchRoute('/sessions/unarchive', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/sessions/unarchive'); + }); + // ---- Removed routes (no dispatcher handler) ---------------------------- it('returns null for removed route /session/:id/approval-mode', () => { diff --git a/packages/sdk-typescript/test/unit/acpTransportUtils.test.ts b/packages/sdk-typescript/test/unit/acpTransportUtils.test.ts new file mode 100644 index 0000000000..82608c65fc --- /dev/null +++ b/packages/sdk-typescript/test/unit/acpTransportUtils.test.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { jsonRpcErrorToHttpStatusWithData } from '../../src/daemon/acpTransportUtils.js'; + +describe('jsonRpcErrorToHttpStatusWithData', () => { + it.each(['session_archived', 'session_conflict', 'session_archiving'])( + 'maps %s to HTTP 409', + (errorKind) => { + expect( + jsonRpcErrorToHttpStatusWithData(-32603, { + errorKind, + sessionId: 's-1', + }), + ).toBe(409); + }, + ); +});