diff --git a/docs/design/daemon-multi-workspace-phase2a-sessions.md b/docs/design/daemon-multi-workspace-phase2a-sessions.md index 81c89399f3c..ad7491762a5 100644 --- a/docs/design/daemon-multi-workspace-phase2a-sessions.md +++ b/docs/design/daemon-multi-workspace-phase2a-sessions.md @@ -109,8 +109,8 @@ Later or primary-only routes: - Keep scan misses as `404 session_not_found`; never fall back to primary. - Fail closed if more than one runtime reports the same live session id. -- Keep non-primary session listing live-only unless persisted entries are - explicitly marked non-resumable. +- Keep non-primary persisted session listing gated until restore ownership, + trust checks, and active-session discovery are implemented together. - Reuse PR 1 runtime-local env overlays before non-primary child spawn. - Reuse PR 1 `maxTotalSessions` admission at every future fresh-creation seam so REST and primary `/acp` cannot bypass it, while attach still bypasses @@ -233,10 +233,24 @@ not part of the sessions-only closed loop. `GET /workspaces/:workspace/sessions` is a plural alias for `GET /workspace/:id/sessions`. Both resolve exact workspace id first and exact canonical cwd second. Primary workspaces keep persisted/live merge semantics. -Non-primary workspaces stay live-only and continue rejecting archived or +Phase 2b PR 1 kept non-primary workspaces live-only and rejecting archived or organized list views. -Phase 2b PR 1 does not add new capability tags, does not alter the +## Phase 2b PR 2 Persisted Session Discovery + +Trusted non-primary workspace session listing now includes active persisted +sessions from that workspace's session store and merges matching live summaries +without duplicates. This completes the discovery side of the Phase 2b restore +flow: clients can list a trusted secondary workspace, find an active persisted +session, and then call workspace-aware `POST /session/:id/load` or +`POST /session/:id/resume` from Phase 2b PR 1. + +If a trusted non-primary workspace has no active persisted sessions, listing +keeps the previous live-only cursor behavior. Archived, organized, and grouped +non-primary list views remain rejected because archive/unarchive/delete and +session organization surfaces are still primary-only/later-phase work. + +The Phase 2b work so far does not add new capability tags, does not alter the `/capabilities` schema, does not change SDK types, and does not route ACP, voice, channel-worker, file, memory, MCP, settings, branch/fork/cd/rewind, shell/model/language, export, archive, delete, or organization surfaces to diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index d8c337ab8dc..b66b1ac8ced 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1333,7 +1333,7 @@ Use `/load` when the client has no history rendered (cold reconnect, picker → ### `GET /workspace/:id/sessions` and `GET /workspaces/:workspace/sessions` -List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` is a plural alias with the same response shape. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Non-primary workspaces are live-only in the current multi-workspace sessions surface and reject archived, organized, or grouped queries. Untrusted non-primary workspaces return `403 { code: "untrusted_workspace" }`. `archiveState=all` is not supported in v1. The default response and numeric `cursor` semantics are unchanged by `session_organization`. +List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` is a plural alias with the same response shape. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted non-primary workspaces return `403 { code: "untrusted_workspace" }`. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor. ```bash curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 56e1a75c627..9a40115793b 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -5,8 +5,11 @@ */ import * as path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import * as os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; import request from 'supertest'; +import { Storage } from '@qwen-code/qwen-code-core'; import { SessionNotFoundError, type AcpSessionBridge, @@ -83,6 +86,47 @@ function makeSummary( }; } +async function writeStoredSession(input: { + sessionId: string; + cwd: string; + timestamp: string; + prompt: string; + mtime: Date; +}): Promise { + const chatsDir = path.join(new Storage(input.cwd).getProjectDir(), 'chats'); + await fsp.mkdir(chatsDir, { recursive: true }); + const filePath = path.join(chatsDir, `${input.sessionId}.jsonl`); + const record = { + uuid: `${input.sessionId}-user-1`, + parentUuid: null, + sessionId: input.sessionId, + timestamp: input.timestamp, + type: 'user', + message: { role: 'user', parts: [{ text: input.prompt }] }, + cwd: input.cwd, + }; + await fsp.writeFile(filePath, `${JSON.stringify(record)}\n`, 'utf8'); + await fsp.utimes(filePath, input.mtime, input.mtime); +} + +async function withRuntimeDir(fn: () => Promise): Promise { + const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + const runtimeDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-multi-workspace-sessions-'), + ); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; + try { + return await fn(); + } finally { + 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 makeBridge( workspaceCwd: string, summaries: BridgeSessionSummary[] = [], @@ -715,26 +759,87 @@ describe('multi-workspace session dispatch', () => { expect(secondaryBridge.restoreCalls).toEqual([]); }); - it('lists non-primary workspace sessions live-only by workspace id', async () => { - const { app } = makeHarness(); + it('lists active persisted and live non-primary workspace sessions by workspace id', async () => { + await withRuntimeDir(async () => { + const storedOnlyId = '550e8400-e29b-41d4-a716-446655440101'; + const liveAndStoredId = '550e8400-e29b-41d4-a716-446655440102'; + await writeStoredSession({ + sessionId: storedOnlyId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:00:00.000Z', + prompt: 'secondary stored only prompt', + mtime: new Date('2026-07-08T00:04:00.000Z'), + }); + await writeStoredSession({ + sessionId: liveAndStoredId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:01:00.000Z', + prompt: 'secondary stored live prompt', + mtime: new Date('2026-07-08T00:05:00.000Z'), + }); + const { app } = makeHarness({ + secondarySummaries: [ + makeSummary(liveAndStoredId, SECONDARY_CWD, { + displayName: 'secondary live title', + }), + ], + }); - const res = await request(app) - .get('/workspace/secondary-id/sessions') - .set('Host', host()); + const res = await request(app) + .get('/workspace/secondary-id/sessions') + .set('Host', host()); - expect(res.status).toBe(200); - expect(res.body.sessions).toEqual([ - expect.objectContaining({ - sessionId: 'secondary-session', - workspaceCwd: SECONDARY_CWD, - }), - ]); + expect(res.status).toBe(200); + expect(res.body.sessions).toHaveLength(2); + expect(res.body.sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: liveAndStoredId, + workspaceCwd: SECONDARY_CWD, + displayName: 'secondary live title', + clientCount: 1, + hasActivePrompt: false, + }), + expect.objectContaining({ + sessionId: storedOnlyId, + workspaceCwd: SECONDARY_CWD, + displayName: 'secondary stored only prompt', + clientCount: 0, + hasActivePrompt: false, + }), + ]), + ); + }); + }); + + it('rejects unsupported non-primary persisted session list views', async () => { + const { app } = makeHarness(); const archived = await request(app) .get('/workspace/secondary-id/sessions?archiveState=archived') .set('Host', host()); expect(archived.status).toBe(400); - expect(archived.body.code).toBe('non_primary_live_sessions_only'); + expect(archived.body.code).toBe( + 'non_primary_session_list_option_not_supported', + ); + + const organized = await request(app) + .get('/workspace/secondary-id/sessions?view=organized') + .set('Host', host()); + expect(organized.status).toBe(400); + expect(organized.body.code).toBe( + 'non_primary_session_list_option_not_supported', + ); + + const group = await request(app) + .get('/workspace/secondary-id/sessions?group=pinned') + .set('Host', host()); + expect(group.status).toBe(400); + expect(group.body.code).toBe('invalid_session_group_filter'); + }); + + it('returns workspace_mismatch for unknown absolute workspace session lists', async () => { + const { app } = makeHarness(); const unknown = await request(app) .get(`/workspace/${encodeURIComponent(UNKNOWN_CWD)}/sessions`) @@ -744,6 +849,120 @@ describe('multi-workspace session dispatch', () => { expect(unknown.body.workspaceCount).toBe(2); }); + it('lists active persisted non-primary sessions by encoded workspace cwd', async () => { + await withRuntimeDir(async () => { + const storedId = '550e8400-e29b-41d4-a716-446655440103'; + await writeStoredSession({ + sessionId: storedId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:00:00.000Z', + prompt: 'secondary stored by cwd', + mtime: new Date('2026-07-08T00:04:00.000Z'), + }); + const { app } = makeHarness({ secondarySummaries: [] }); + + const res = await request(app) + .get(`/workspaces/${encodeURIComponent(SECONDARY_CWD)}/sessions`) + .set('Host', host()); + + expect(res.status).toBe(200); + expect(res.body.sessions).toEqual([ + expect.objectContaining({ + sessionId: storedId, + workspaceCwd: SECONDARY_CWD, + displayName: 'secondary stored by cwd', + }), + ]); + }); + }); + + it('pages active persisted non-primary workspace sessions with numeric cursors', async () => { + await withRuntimeDir(async () => { + const newestId = '550e8400-e29b-41d4-a716-446655440104'; + const middleId = '550e8400-e29b-41d4-a716-446655440105'; + const oldestId = '550e8400-e29b-41d4-a716-446655440106'; + await writeStoredSession({ + sessionId: newestId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:03:00.000Z', + prompt: 'secondary newest', + mtime: new Date('2026-07-08T00:03:00.000Z'), + }); + await writeStoredSession({ + sessionId: middleId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:02:00.000Z', + prompt: 'secondary middle', + mtime: new Date('2026-07-08T00:02:00.000Z'), + }); + await writeStoredSession({ + sessionId: oldestId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:01:00.000Z', + prompt: 'secondary oldest', + mtime: new Date('2026-07-08T00:01:00.000Z'), + }); + const { app } = makeHarness({ secondarySummaries: [] }); + + const first = await request(app) + .get('/workspace/secondary-id/sessions?size=2') + .set('Host', host()) + .expect(200); + expect( + first.body.sessions.map( + (session: { sessionId: string }) => session.sessionId, + ), + ).toEqual([newestId, middleId]); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + const second = await request(app) + .get( + `/workspace/secondary-id/sessions?size=2&cursor=${encodeURIComponent( + first.body.nextCursor as string, + )}`, + ) + .set('Host', host()) + .expect(200); + expect( + second.body.sessions.map( + (session: { sessionId: string }) => session.sessionId, + ), + ).toEqual([oldestId]); + expect(second.body.nextCursor).toBeUndefined(); + }); + }); + + it('falls back to live-only listing when persisted probing fails', async () => { + await withRuntimeDir(async () => { + const chatsDir = path.join( + new Storage(SECONDARY_CWD).getProjectDir(), + 'chats', + ); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.chmod(chatsDir, 0o000); + try { + const { app } = makeHarness({ + secondarySummaries: [ + makeSummary('secondary-live-fallback', SECONDARY_CWD), + ], + }); + + const res = await request(app) + .get('/workspace/secondary-id/sessions') + .set('Host', host()) + .expect(200); + expect(res.body.sessions).toEqual([ + expect.objectContaining({ + sessionId: 'secondary-live-fallback', + workspaceCwd: SECONDARY_CWD, + }), + ]); + } finally { + await fsp.chmod(chatsDir, 0o700); + } + }); + }); + it('preserves the legacy invalid workspace selector message', async () => { const { app } = makeHarness(); @@ -823,4 +1042,56 @@ describe('multi-workspace session dispatch', () => { ).toEqual(['secondary-c']); expect(second.body.nextCursor).toBeUndefined(); }); + + it('keeps live cursor pagination stable when persisted sessions appear mid-page', async () => { + await withRuntimeDir(async () => { + const { app } = makeHarness({ + secondarySummaries: [ + makeSummary('secondary-b', SECONDARY_CWD, { + updatedAt: '2026-07-08T00:03:00.000Z', + }), + makeSummary('secondary-a', SECONDARY_CWD, { + updatedAt: '2026-07-08T00:03:00.000Z', + }), + makeSummary('secondary-c', SECONDARY_CWD, { + updatedAt: '2026-07-08T00:02:00.000Z', + }), + ], + }); + + const first = await request(app) + .get('/workspace/secondary-id/sessions?size=2') + .set('Host', host()) + .expect(200); + expect( + first.body.sessions.map( + (session: { sessionId: string }) => session.sessionId, + ), + ).toEqual(['secondary-a', 'secondary-b']); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + await writeStoredSession({ + sessionId: '550e8400-e29b-41d4-a716-446655440107', + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:04:00.000Z', + prompt: 'secondary persisted appeared mid-page', + mtime: new Date('2026-07-08T00:04:00.000Z'), + }); + + const second = await request(app) + .get( + `/workspace/secondary-id/sessions?size=2&cursor=${encodeURIComponent( + first.body.nextCursor as string, + )}`, + ) + .set('Host', host()) + .expect(200); + expect( + second.body.sessions.map( + (session: { sessionId: string }) => session.sessionId, + ), + ).toEqual(['secondary-c']); + expect(second.body.nextCursor).toBeUndefined(); + }); + }); }); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 31433f78bd8..92ba0f4cc7b 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -249,6 +249,29 @@ export function registerSessionRoutes( return runtime; }; + const hasActivePersistedSessions = async (workspaceCwd: string) => { + try { + const page = await new SessionService(workspaceCwd).listSessions({ + archiveState: 'active', + size: 1, + }); + return page.items.length > 0; + } catch { + return false; + } + }; + + const isNumericSessionCursor = (cursor: string): boolean => { + const trimmed = cursor.trim(); + if (trimmed === '') return false; + const parsed = Number(trimmed); + return ( + Number.isFinite(parsed) && + parsed >= 0 && + parsed <= Number.MAX_SAFE_INTEGER + ); + }; + const sendAmbiguousSessionOwner = ( res: Response, route: string, @@ -1755,8 +1778,8 @@ export function registerSessionRoutes( if (!runtime.primary && (archiveState === 'archived' || view)) { res.status(400).json({ error: - 'Non-primary workspace session listing is live-only in Phase 2a.', - code: 'non_primary_live_sessions_only', + 'Non-primary workspace session listing only supports active recent view in this phase.', + code: 'non_primary_session_list_option_not_supported', }); return; } @@ -1767,7 +1790,12 @@ export function registerSessionRoutes( ...(view !== undefined ? { view } : {}), ...(group !== undefined ? { group } : {}), }; - const result = runtime.primary + const usePersisted = + runtime.primary || + (cursor !== undefined && cursor !== '' + ? isNumericSessionCursor(cursor) + : await hasActivePersistedSessions(key)); + const result = usePersisted ? await listWorkspaceSessionsForResponse(runtime.bridge, key, options) : listLiveWorkspaceSessionsForResponse(runtime.bridge, key, options); res.status(200).json({