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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions docs/design/daemon-multi-workspace-phase2a-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
297 changes: 284 additions & 13 deletions packages/cli/src/serve/multi-workspace-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,6 +86,47 @@ function makeSummary(
};
}

async function writeStoredSession(input: {
sessionId: string;
cwd: string;
timestamp: string;
prompt: string;
mtime: Date;
}): Promise<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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[] = [],
Expand Down Expand Up @@ -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`)
Expand All @@ -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 () => {
Comment on lines +935 to +936
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();

Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading