feat(serve): persist dynamic workspace registrations (#6716)

* feat(serve): persist dynamic workspace registrations

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6716)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6716)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6716)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6716)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6716)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
jinyeandQwen-Coder authored and GitHub committed 2026-07-11 16:49:40 +00:00
1 parent 000eee56ed
commit 51d4ce48db
25 files changed
+1964 -62

No files matched your search

@@ -0,0 +1,91 @@
# Persistent daemon workspace registration
## Goal
Workspaces added from the Web Shell survive a `qwen serve` process restart
when the daemon is relaunched with the same primary workspace and `QWEN_HOME`.
## State ownership
Dynamic workspace registration is user-private daemon configuration, not
project configuration and not disposable runtime output. Registrations are
stored under:
```text
${QWEN_HOME:-~/.qwen}/daemon/workspaces/<primary-scope-sha256>.json
```
The scope hash is the full SHA-256 of the canonical primary workspace path
(lower-cased on Windows). The file repeats the primary path so a mismatched or
corrupt scope is rejected rather than silently applied.
```json
{
"schemaVersion": 1,
"primaryWorkspace": "/repo/main",
"workspaces": ["/repo/service-a"]
}
```
Only canonical secondary paths are stored. Trust, environment, workspace ids,
sessions, and runtime errors are re-derived on every daemon start.
## Lifecycle
The production daemon reads the small registration file after resolving and
canonicalizing the primary workspace. Valid stored paths are merged after
explicit `--workspace` inputs. Explicit inputs are authoritative: a malformed
or unavailable explicit path remains a boot error, while an unavailable stored
path is skipped with a warning and retained on disk for a later restart.
Recovered paths enter the normal secondary-runtime construction loop before
`WorkspaceRegistry` and the Express/ACP surfaces are assembled. This keeps
capabilities, workspace-qualified ACP mounts, status aggregation, and the
default total-session limit consistent with the restored runtime set.
For process-local additions after app assembly, workspace-qualified ACP routes
remain mounted whenever a registry exists and create a trusted secondary mount
lazily on first use. This avoids a single-workspace startup snapshot making a
later Web Shell registration unusable until restart.
`POST /workspaces` accepts `persist: true`. A successful persistent request is
not acknowledged until the registration-file update completes successfully.
Repeating a
persistent request for an already-active workspace promotes or confirms its
stored registration and succeeds idempotently. Existing callers that omit
`persist` keep the current process-local behavior.
`GET /workspace-registrations` exposes the desired stored set for management.
`DELETE /workspace-registrations/:id` forgets a stored registration; an active
runtime remains live until restart. The primary workspace can never be stored
or forgotten through this surface.
## Safety and failure behavior
- The store is bounded to 24 secondary paths, each no longer than the daemon
workspace-path limit.
- Reads reject symlinks, non-regular files, oversized files, malformed JSON,
unknown schema versions, and primary-scope mismatches.
- Writes use an in-process mutex, a cross-process lock, and the shared atomic
file-write helper with mode `0600` and no symlink following.
- Corrupt stores are never treated as empty by mutation paths, preventing a
later add from overwriting recoverable data.
- Persisted trust is deliberately absent; restored workspaces pass through the
current trusted-folder calculation.
- Stored entries that are missing, inaccessible, nested, or over the active
limit are skipped without deleting the desired entry. Duplicate entries make
the store invalid and are never rewritten implicitly.
## Compatibility
The additive `persistent_workspace_registration` capability advertises the new
contract. The SDK request option and `persisted` response field are additive.
`runQwenServe` owns automatic startup restoration. Direct `createServeApp`
embeds gain the persistence management routes only when a registration store
is explicitly supplied, and remain responsible for restoring their injected
workspace registry before app creation.
## Follow-up boundary
Hot removal remains separate: forgetting a registration affects the next
restart but does not terminate sessions or dispose an active workspace bridge.
+75 -16
View File
@@ -187,7 +187,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'non_blocking_prompt', 'session_language', 'session_rewind',
'workspace_hooks', 'session_hooks', 'workspace_extensions',
'session_branch', 'rate_limit', 'workspace_reload',
'multi_workspace_sessions', 'workspace_qualified_rest_core',
'multi_workspace_sessions', 'persistent_workspace_registration',
'workspace_qualified_rest_core',
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
```
@@ -195,6 +196,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
`session_scope_override` is the negotiation handle for the per-request `sessionScope` field on `POST /session` (see below). Older daemons silently ignore the field, so SDK clients should pre-flight `caps.features` for this tag before sending it.
`persistent_workspace_registration` advertises durable registration for workspaces added at runtime. `POST /workspaces` accepts `{ "cwd": "/absolute/path", "persist": true }`; success includes `persisted: true`. Registrations are scoped to the daemon's canonical primary workspace under the user's Qwen home and are restored on the next daemon start. Omitting `persist` preserves process-local registration. `GET /workspace-registrations` lists the stored desired set, and `DELETE /workspace-registrations/:id` forgets an entry for the next restart without hot-removing an active runtime.
`session_load` and `session_resume` advertise the explicit-restore routes (`POST /session/:id/load` and `POST /session/:id/resume`). Older daemons return `404` for these paths, so SDK clients should pre-flight `caps.features` before calling. `unstable_session_resume` is still advertised as a deprecated alias for compatibility with SDKs that shipped while the underlying ACP method was named `connection.unstable_resumeSession`; new clients should gate on `session_resume`.
`session_transcript` advertises `GET /session/:id/transcript`, a read-only paged replay view over the persisted active-session JSONL. It is separate from `/load`: it does not attach a client, seed the live EventBus, create a live session, or change the live replay window. Clients should use it when they need the complete on-disk transcript for a long session, and continue using `/load` only for bounded live replay during cold UI restore.
@@ -241,21 +244,22 @@ operator diagnostic snapshot documented below.
**Conditional tags.** A small number of feature tags are advertised only when the matching deployment toggle is on. Tag presence = behavior is on; absence = either an older daemon predating the tag, OR a current daemon where the operator did not opt in. Currently:
| Tag | Advertised when … |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. |
| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. |
| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. |
| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin <pattern>` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. |
| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. |
| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |
| `workspace_settings` | the daemon was created with settings persistence available. |
| `session_shell_command` | session shell execution is explicitly enabled. |
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
| `workspace_reload` | workspace reload support is available in the embedded route configuration. |
| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. |
| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. |
| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. |
| Tag | Advertised when … |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. |
| `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. |
| `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. |
| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin <pattern>` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers — browser webui already knows its own origin. |
| `prompt_absolute_deadline` | `--prompt-deadline-ms` / `QWEN_SERVE_PROMPT_DEADLINE_MS` / `ServeOptions.promptDeadlineMs` is set to a positive integer. |
| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |
| `workspace_settings` | the daemon was created with settings persistence available. |
| `session_shell_command` | session shell execution is explicitly enabled. |
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
| `workspace_reload` | workspace reload support is available in the embedded route configuration. |
| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. |
| `client_mcp_over_ws` | the daemon accepts client-hosted MCP servers over the ACP WebSocket. This is an explicit opt-in, not required for the CDP tunnel path. |
| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. |
| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. |
`mcp_guardrails` is **not** in this conditional table — it's an always-on tag, advertised whenever the binary supports the new `/workspace/mcp` budget fields, regardless of whether the operator configured a budget. Operators who haven't set `--mcp-client-budget` still get the new fields (with `budgetMode: 'off'`, `budgets: []`).
@@ -504,6 +508,61 @@ Stable contract: when `v` increments the frame layout has changed in a backwards
> **`workspaces[]`** is present only when `features` contains `multi_workspace_sessions`. Each entry is `{ id, cwd, primary, trusted }`. The first/primary workspace remains mirrored by `workspaceCwd`; new clients choose a non-primary runtime by passing that entry's `cwd` to `POST /session`. Untrusted workspaces are advertised for diagnostics but reject fresh session creation with `403 untrusted_workspace` until trust changes.
The workspace feature tags and `workspaces[]` are dynamic. Clients that add a workspace must fetch `/capabilities` again after the mutation completes; the daemon does not broadcast capability changes to clients that cached an earlier response. Forgetting persistence does not unload an active runtime, so that runtime remains advertised until restart.
### `POST /workspaces`
Register an additional workspace runtime. The path must be an existing, accessible, absolute directory that does not duplicate or nest with another registered workspace. Registration is process-local unless the client sends `persist: true`; clients must pre-flight `persistent_workspace_registration` before requesting persistence.
```json
{ "cwd": "/canonical/path/to/secondary-workspace", "persist": true }
```
A newly created runtime returns `201`; promoting an already-active secondary workspace to persistent returns `200`. Persistent success includes `persisted: true`:
```json
{
"id": "stable-workspace-id",
"cwd": "/canonical/path/to/secondary-workspace",
"primary": false,
"trusted": true,
"persisted": true
}
```
Errors include `400 invalid_path` / `invalid_persist_flag` / `invalid_persist_target`, `409 workspace_exists` / `workspace_nested` / `workspace_limit_reached`, `500 workspace_registration_store_error` / `runtime_creation_failed`, and `501 persistence_not_available` / `not_implemented`.
### `GET /workspace-registrations`
List the persisted desired workspace set for this primary workspace. Entries remain visible with `active: false` when a stored directory could not be restored during the current start.
```json
{
"schemaVersion": 1,
"primaryWorkspace": "/canonical/path/to/primary-workspace",
"entries": [
{
"id": "stable-registration-id",
"cwd": "/canonical/path/to/secondary-workspace",
"active": true,
"persisted": true
}
]
}
```
Returns `501 persistence_not_available` when no registration store is configured and `500 workspace_registration_store_error` when the store cannot be read.
### `DELETE /workspace-registrations/:id`
Forget one persisted registration. This does not unload an active runtime or terminate its sessions; `restartRequired: true` means the active runtime disappears on the next daemon restart.
```json
{ "removed": true, "active": true, "restartRequired": true }
```
Returns `404 workspace_registration_not_found`, `500 workspace_registration_store_error`, or `501 persistence_not_available`. Like other mutation routes, this endpoint requires mutation authentication when daemon authentication is enabled.
### Read-only runtime status routes
These routes report daemon-side runtime snapshots. They are additive v1 routes,
+1
View File
@@ -24220,6 +24220,7 @@
"lowlight": "^3.3.0",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.4",
"read-package-up": "^11.0.0",
"shell-quote": "^1.9.0",
+1
View File
@@ -74,6 +74,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"p-limit": "^7.3.0",
"proper-lockfile": "^4.1.2",
"prompts": "^2.4.2",
"react": "^19.2.4",
"read-package-up": "^11.0.0",
+14 -5
View File
@@ -1146,13 +1146,23 @@ export function mountAcpHttp(
};
const secondaryMounts = new Map<string, RuntimeAcpMount>();
const getOrCreateSecondaryMount = (
rt: WorkspaceRuntime,
): RuntimeAcpMount | undefined => {
if (rt.primary || !rt.trusted) return undefined;
const existing = secondaryMounts.get(rt.workspaceId);
if (existing) return existing;
const mount = createSecondaryAcpMount(rt);
secondaryMounts.set(rt.workspaceId, mount);
return mount;
};
for (const rt of opts.workspaceRegistry?.list() ?? []) {
// Only trusted non-primary runtimes get a mount: untrusted workspaces are
// rejected (403) before any mount lookup on both the HTTP and WS paths, so
// allocating a dispatcher/registry/remember-lane for them is pure waste and
// would pollute the aggregate snapshot with always-zero entries.
if (!rt.primary && rt.trusted) {
secondaryMounts.set(rt.workspaceId, createSecondaryAcpMount(rt));
getOrCreateSecondaryMount(rt);
}
}
@@ -1184,7 +1194,7 @@ export function mountAcpHttp(
return null;
}
if (rt.primary) return primaryMount;
const mount = secondaryMounts.get(rt.workspaceId);
const mount = getOrCreateSecondaryMount(rt);
if (!mount) {
res.status(400).json({
error: `Workspace "${rt.workspaceCwd}" has no registered ACP mount`,
@@ -1197,8 +1207,7 @@ export function mountAcpHttp(
return mount;
};
const workspaceQualifiedAcpEnabled =
(opts.workspaceRegistry?.list().length ?? 0) > 1;
const workspaceQualifiedAcpEnabled = opts.workspaceRegistry !== undefined;
const pluralAcpPath = '/workspaces/:workspace/acp';
if (workspaceQualifiedAcpEnabled) {
app.post(pluralAcpPath, (req: Request, res: Response) => {
@@ -1434,7 +1443,7 @@ export function mountAcpHttp(
}
const resolvedMount = rt.primary
? primaryMount
: secondaryMounts.get(rt.workspaceId);
: getOrCreateSecondaryMount(rt);
if (!resolvedMount) {
logReject(`workspace-acp-no-mount ${rt.workspaceId}`);
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
@@ -462,7 +462,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
expect(res.status).toBe(500);
});
it('does not expose qualified HTTP or WS routes with one runtime', async () => {
it('exposes qualified HTTP and WS routes with one runtime', async () => {
const primaryBridge = makeBridge();
const registry = createWorkspaceRegistry([
makeRuntime({
@@ -497,7 +497,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
body: INITIALIZE,
},
);
expect(qualified.status).toBe(404);
expect(qualified.status).toBe(200);
const upgradeStatus = await new Promise<number>((resolve) => {
const ws = new WebSocket(
@@ -513,7 +513,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
});
ws.on('error', () => resolve(0));
});
expect(upgradeStatus).not.toBe(101);
expect(upgradeStatus).toBe(101);
const legacy = await fetch(`http://127.0.0.1:${singlePort}/acp`, {
method: 'POST',
@@ -521,6 +521,25 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
body: INITIALIZE,
});
expect(legacy.status).toBe(200);
registry.add(
makeRuntime({
id: 'dynamic-id',
cwd: '/dynamic',
primary: false,
trusted: true,
bridge: makeBridge(),
}),
);
const dynamic = await fetch(
`http://127.0.0.1:${singlePort}/workspaces/dynamic-id/acp`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: INITIALIZE,
},
);
expect(dynamic.status).toBe(200);
} finally {
singleHandle.dispose();
singleServer.closeAllConnections?.();
+10
View File
@@ -270,6 +270,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// Multi-workspace sessions closed loop (issue #6378 Phase 2a). Advertised
// only when one daemon hosts more than one registered workspace runtime.
multi_workspace_sessions: { since: 'v1' },
persistent_workspace_registration: { since: 'v1' },
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
// Covers core file/status/permissions/trust/lifecycle/MCP/tool, memory,
// workspace agent CRUD, and persisted session organization surfaces.
@@ -360,6 +361,7 @@ export interface AdvertiseFeatureToggles {
browserAutomationMcpAvailable?: boolean;
voiceWsAvailable?: boolean;
multiWorkspaceSessionsEnabled?: boolean;
persistentWorkspaceRegistrationAvailable?: boolean;
/**
* Whether the HTTP ACP surface is enabled (default on; opts out via
* QWEN_SERVE_ACP_HTTP=0). Workspace-qualified ACP is only advertised when on.
@@ -440,8 +442,16 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
'multi_workspace_sessions',
(toggles) => toggles.multiWorkspaceSessionsEnabled === true,
],
[
'persistent_workspace_registration',
(toggles) => toggles.persistentWorkspaceRegistrationAvailable === true,
],
[
'workspace_qualified_acp',
// The plural routes are pre-mounted for workspaces registered after app
// creation, but the capability becomes meaningful only once a secondary
// runtime exists. Until then the qualified primary route is only an alias
// for the always-available legacy `/acp` surface.
(toggles) =>
toggles.acpHttpEnabled === true &&
toggles.multiWorkspaceSessionsEnabled === true,
@@ -17,6 +17,11 @@ import type {
} from '../workspace-registry.js';
import { tmpdir } from 'node:os';
import { realpathSync } from 'node:fs';
import {
workspaceRegistrationId,
WorkspaceRegistrationStoreLimitError,
type WorkspaceRegistrationStore,
} from '../workspace-registration-store.js';
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStderrLine: vi.fn(),
@@ -29,14 +34,20 @@ function createMockRegistry(
runtimes: WorkspaceRuntime[] = [],
): WorkspaceRegistry {
const byCwd = new Map(runtimes.map((r) => [r.workspaceCwd, r]));
const byId = new Map(runtimes.map((r) => [r.workspaceId, r]));
const add = vi.fn((runtime: WorkspaceRuntime) => {
runtimes.push(runtime);
byCwd.set(runtime.workspaceCwd, runtime);
byId.set(runtime.workspaceId, runtime);
});
return {
primary: runtimes[0]!,
list: () => Object.freeze([...runtimes]) as readonly WorkspaceRuntime[],
getByWorkspaceCwd: (cwd: string) => byCwd.get(cwd),
getByWorkspaceId: () => undefined,
getByWorkspaceId: (id: string) => byId.get(id),
resolveWorkspaceCwd: () => undefined,
resolveLiveSessionOwner: () => ({ kind: 'not_found' }),
add: vi.fn(),
add,
} as unknown as WorkspaceRegistry;
}
@@ -46,6 +57,9 @@ function makeRuntime(cwd: string): WorkspaceRuntime {
workspaceCwd: cwd,
primary: false,
trusted: true,
bridge: {
shutdown: vi.fn().mockResolvedValue(undefined),
},
} as unknown as WorkspaceRuntime;
}
@@ -102,6 +116,15 @@ describe('POST /workspaces', () => {
expect(res.body.code).toBe('invalid_path');
});
it('returns 400 for a non-boolean persist flag', async () => {
const { app } = createApp();
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: 'yes' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_persist_flag');
});
it('returns 400 for path exceeding max length', async () => {
const { app } = createApp();
const longPath = '/' + 'a'.repeat(5000);
@@ -140,6 +163,7 @@ describe('POST /workspaces', () => {
primary: false,
trusted: true,
});
expect(res.body).not.toHaveProperty('persisted');
});
it('does not echo resolved paths in 409 error messages', async () => {
@@ -148,4 +172,312 @@ describe('POST /workspaces', () => {
// Generic error message — does not reveal canonical/internal paths.
expect(res.body.error).toBe('Workspace already registered');
});
it('persists a newly registered workspace before returning success', async () => {
const add = vi.fn().mockResolvedValue(true);
const { app, deps } = createApp({
workspaceRegistry: createMockRegistry([makeRuntime('/some-other-dir')]),
workspaceRegistrationStore: {
add,
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(201);
expect(res.body.persisted).toBe(true);
expect(add).toHaveBeenCalledWith(REAL_DIR);
expect(deps.workspaceRegistry.add).toHaveBeenCalledTimes(1);
});
it('promotes an existing secondary workspace to persistent idempotently', async () => {
const add = vi.fn().mockResolvedValue(false);
const { app } = createApp({
workspaceRegistrationStore: {
add,
read: vi.fn().mockResolvedValue({ workspaces: [REAL_DIR] }),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(200);
expect(res.body.persisted).toBe(true);
expect(add).toHaveBeenCalledWith(REAL_DIR);
});
it('promotes an existing workspace without a dynamic runtime factory', async () => {
const add = vi.fn().mockResolvedValue(true);
const { app } = createApp({
createWorkspaceRuntime: undefined,
workspaceRegistrationStore: {
add,
read: vi.fn().mockResolvedValue({ workspaces: [] }),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(200);
expect(res.body.persisted).toBe(true);
expect(add).toHaveBeenCalledWith(REAL_DIR);
});
it('rejects persistence for the primary workspace', async () => {
const primary = { ...makeRuntime(REAL_DIR), primary: true };
const { app } = createApp({
workspaceRegistry: createMockRegistry([primary]),
workspaceRegistrationStore: {} as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_persist_target');
});
it('rejects promotion of a nested active workspace', async () => {
const { app } = createApp({
workspaceRegistry: createMockRegistry([
makeRuntime(realpathSync.native('/')),
makeRuntime(REAL_DIR),
]),
workspaceRegistrationStore: {} as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(409);
expect(res.body.code).toBe('workspace_nested');
});
it('returns the documented limit error when promoting at store capacity', async () => {
const add = vi.fn();
const { app } = createApp({
workspaceRegistrationStore: {
add,
read: vi.fn().mockResolvedValue({
workspaces: Array.from({ length: 24 }, (_, index) => `/w/${index}`),
}),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(409);
expect(res.body.code).toBe('workspace_limit_reached');
expect(add).not.toHaveBeenCalled();
});
it('returns the limit error when a concurrent writer fills the store', async () => {
const { app } = createApp({
workspaceRegistrationStore: {
read: vi.fn().mockResolvedValue({ workspaces: [] }),
add: vi
.fn()
.mockRejectedValue(
new WorkspaceRegistrationStoreLimitError('limit reached'),
),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(409);
expect(res.body.code).toBe('workspace_limit_reached');
});
it('rejects persist when no registration store is available', async () => {
const { app } = createApp();
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(501);
expect(res.body.code).toBe('persistence_not_available');
});
it('reports filesystem persistence failures without registering runtime', async () => {
const registry = createMockRegistry([makeRuntime('/some-other-dir')]);
const { app } = createApp({
workspaceRegistry: registry,
workspaceRegistrationStore: {
add: vi.fn().mockRejectedValue(new Error('disk full')),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(500);
expect(res.body.code).toBe('workspace_registration_store_error');
expect(registry.add).not.toHaveBeenCalled();
});
it('preserves runtime creation failures before persistence begins', async () => {
const add = vi.fn();
const { app } = createApp({
workspaceRegistry: createMockRegistry([makeRuntime('/some-other-dir')]),
createWorkspaceRuntime: vi
.fn()
.mockRejectedValue(new Error('runtime failed')),
workspaceRegistrationStore: {
add,
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(500);
expect(res.body.code).toBe('runtime_creation_failed');
expect(add).not.toHaveBeenCalled();
});
it('rolls back a newly persisted record when runtime registration fails', async () => {
const runtime = makeRuntime(REAL_DIR);
const registry = createMockRegistry([makeRuntime('/some-other-dir')]);
registry.add = vi.fn(() => {
throw new Error('workspace id collision');
});
const removeById = vi.fn().mockResolvedValue(true);
const { app } = createApp({
workspaceRegistry: registry,
createWorkspaceRuntime: vi.fn().mockResolvedValue(runtime),
workspaceRegistrationStore: {
add: vi.fn().mockResolvedValue(true),
removeById,
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app)
.post('/workspaces')
.send({ cwd: REAL_DIR, persist: true });
expect(res.status).toBe(500);
expect(res.body.code).toBe('runtime_creation_failed');
expect(removeById).toHaveBeenCalledWith(
expect.stringMatching(/^[a-f0-9]{16}$/),
);
expect(runtime.bridge.shutdown).toHaveBeenCalledTimes(1);
});
});
describe('persistent workspace registrations', () => {
it('returns 501 for registration management without a store', async () => {
const { app } = createApp();
const list = await request(app).get('/workspace-registrations');
expect(list.status).toBe(501);
expect(list.body.code).toBe('persistence_not_available');
const remove = await request(app).delete(
'/workspace-registrations/missing',
);
expect(remove.status).toBe(501);
expect(remove.body.code).toBe('persistence_not_available');
});
it('lists desired registrations and whether they are active', async () => {
const read = vi.fn().mockResolvedValue({
schemaVersion: 1,
primaryWorkspace: '/primary',
workspaces: [REAL_DIR, '/currently-unavailable'],
});
const { app } = createApp({
workspaceRegistrationStore: {
read,
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app).get('/workspace-registrations');
expect(res.status).toBe(200);
expect(res.body.entries).toEqual([
expect.objectContaining({
id: workspaceRegistrationId(REAL_DIR),
cwd: REAL_DIR,
active: true,
persisted: true,
}),
expect.objectContaining({
cwd: '/currently-unavailable',
active: false,
persisted: true,
}),
]);
});
it('forgets persistence without unloading an active runtime', async () => {
const active = makeRuntime(REAL_DIR);
const removeById = vi.fn().mockResolvedValue(true);
const { app } = createApp({
workspaceRegistry: createMockRegistry([active]),
workspaceRegistrationStore: {
removeById,
} as unknown as WorkspaceRegistrationStore,
});
const registrationId = workspaceRegistrationId(REAL_DIR);
const res = await request(app).delete(
`/workspace-registrations/${registrationId}`,
);
expect(res.status).toBe(200);
expect(removeById).toHaveBeenCalledWith(registrationId);
expect(res.body).toEqual({
removed: true,
active: true,
restartRequired: true,
});
});
it('returns 404 when a registration does not exist', async () => {
const { app } = createApp({
workspaceRegistrationStore: {
removeById: vi.fn().mockResolvedValue(false),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app).delete('/workspace-registrations/missing');
expect(res.status).toBe(404);
expect(res.body.code).toBe('workspace_registration_not_found');
});
it('returns a store error when registrations cannot be read', async () => {
const { app } = createApp({
workspaceRegistrationStore: {
read: vi.fn().mockRejectedValue(new Error('read failed')),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app).get('/workspace-registrations');
expect(res.status).toBe(500);
expect(res.body.code).toBe('workspace_registration_store_error');
});
it('returns a store error when a registration cannot be forgotten', async () => {
const { app } = createApp({
workspaceRegistrationStore: {
removeById: vi.fn().mockRejectedValue(new Error('write failed')),
} as unknown as WorkspaceRegistrationStore,
});
const res = await request(app).delete('/workspace-registrations/id');
expect(res.status).toBe(500);
expect(res.body.code).toBe('workspace_registration_store_error');
});
});
@@ -11,30 +11,41 @@ import type { Application, Request, Response } from 'express';
import { isWithinRoot } from '@qwen-code/qwen-code-core';
import { MAX_WORKSPACE_PATH_LENGTH } from '@qwen-code/acp-bridge/workspacePaths';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import { MAX_REGISTERED_WORKSPACES } from '../workspace-inputs.js';
import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from '../workspace-registry.js';
import {
workspaceRegistrationId,
WorkspaceRegistrationStoreLimitError,
type WorkspaceRegistrationStore,
} from '../workspace-registration-store.js';
// Upper bound on total registered workspaces (startup + dynamic). Each
// registration allocates a full runtime (bridge, channel factory, sub-session
// launcher), so an unbounded POST /workspaces would let an authenticated
// client exhaust memory / file descriptors. There is no DELETE yet, so this is
// the sole backpressure.
const MAX_REGISTERED_WORKSPACES = 25;
// client exhaust memory / file descriptors. Forgetting persistence does not
// unload an active runtime, so this remains the runtime backpressure.
export interface WorkspaceManagementRouteDeps {
workspaceRegistry: WorkspaceRegistry;
mutate: (opts?: { strict?: boolean }) => import('express').RequestHandler;
safeBody: (req: Request) => Record<string, unknown>;
createWorkspaceRuntime?: (cwd: string) => Promise<WorkspaceRuntime>;
workspaceRegistrationStore?: WorkspaceRegistrationStore;
}
export function registerWorkspaceManagementRoutes(
app: Application,
deps: WorkspaceManagementRouteDeps,
): void {
const { workspaceRegistry, mutate, safeBody, createWorkspaceRuntime } = deps;
const {
workspaceRegistry,
mutate,
safeBody,
createWorkspaceRuntime,
workspaceRegistrationStore,
} = deps;
// Canonical cwds with a registration in flight, so two concurrent POSTs for
// the same directory can't both pass the duplicate check and both build
// runtime infrastructure before one add() throws.
@@ -44,16 +55,9 @@ export function registerWorkspaceManagementRoutes(
'/workspaces',
mutate({ strict: true }),
async (req: Request, res: Response) => {
if (!createWorkspaceRuntime) {
res.status(501).json({
error: 'Dynamic workspace registration is not available',
code: 'not_implemented',
});
return;
}
const body = safeBody(req);
const cwd = body['cwd'];
const persist = body['persist'] ?? false;
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
res.status(400).json({
error: '`cwd` must be a non-empty string',
@@ -61,6 +65,27 @@ export function registerWorkspaceManagementRoutes(
});
return;
}
if (typeof persist !== 'boolean') {
res.status(400).json({
error: '`persist` must be a boolean',
code: 'invalid_persist_flag',
});
return;
}
if (persist && !workspaceRegistrationStore) {
res.status(501).json({
error: 'Persistent workspace registration is not available',
code: 'persistence_not_available',
});
return;
}
if (!createWorkspaceRuntime && !persist) {
res.status(501).json({
error: 'Dynamic workspace registration is not available',
code: 'not_implemented',
});
return;
}
if (!isAbsolute(cwd)) {
res.status(400).json({
@@ -118,16 +143,90 @@ export function registerWorkspaceManagementRoutes(
// same canonical cwd can't race past registration. Error messages stay
// generic and never echo a resolved path (which could reveal symlink
// targets or another workspace's location).
if (
workspaceRegistry.getByWorkspaceCwd(canonical) ||
inFlight.has(canonical)
) {
const existingRuntime = workspaceRegistry.getByWorkspaceCwd(canonical);
if (existingRuntime?.primary && persist) {
res.status(400).json({
error: 'Primary workspace cannot be persisted',
code: 'invalid_persist_target',
});
return;
}
if (existingRuntime && persist && !existingRuntime.primary) {
const nested = [
...workspaceRegistry.list().map((runtime) => runtime.workspaceCwd),
...inFlight,
].some(
(boundCwd) =>
boundCwd !== canonical &&
(isWithinRoot(canonical, boundCwd) ||
isWithinRoot(boundCwd, canonical)),
);
if (nested) {
res.status(409).json({
error: 'Workspace path nests with an existing workspace',
code: 'workspace_nested',
});
return;
}
try {
const snapshot = await workspaceRegistrationStore!.read();
const alreadyPersisted = snapshot.workspaces.some((stored) =>
process.platform === 'win32'
? stored.toLowerCase() === canonical.toLowerCase()
: stored === canonical,
);
if (
!alreadyPersisted &&
snapshot.workspaces.length >= MAX_REGISTERED_WORKSPACES - 1
) {
res.status(409).json({
error: 'Workspace registration limit reached',
code: 'workspace_limit_reached',
});
return;
}
await workspaceRegistrationStore!.add(canonical);
res.status(200).json({
id: existingRuntime.workspaceId,
cwd: existingRuntime.workspaceCwd,
primary: existingRuntime.primary,
trusted: existingRuntime.trusted,
persisted: true,
});
} catch (err) {
if (err instanceof WorkspaceRegistrationStoreLimitError) {
res.status(409).json({
error: 'Workspace registration limit reached',
code: 'workspace_limit_reached',
});
return;
}
writeStderrLine(
`qwen serve: failed to persist existing workspace registration: ${
err instanceof Error ? err.message : String(err)
}`,
);
res.status(500).json({
error: 'Failed to persist workspace registration',
code: 'workspace_registration_store_error',
});
}
return;
}
if (existingRuntime || inFlight.has(canonical)) {
res.status(409).json({
error: 'Workspace already registered',
code: 'workspace_exists',
});
return;
}
if (!createWorkspaceRuntime) {
res.status(501).json({
error: 'Dynamic workspace registration is not available',
code: 'not_implemented',
});
return;
}
// Nesting guard checks registered workspaces AND in-flight registrations,
// so two concurrent POSTs for parent/child paths (e.g. /project and
@@ -162,14 +261,46 @@ export function registerWorkspaceManagementRoutes(
}
inFlight.add(canonical);
let persistenceFailed = false;
try {
const runtime = await createWorkspaceRuntime(canonical);
workspaceRegistry.add(runtime);
let persistedRecordAdded = false;
try {
if (persist) {
try {
persistedRecordAdded =
await workspaceRegistrationStore!.add(canonical);
} catch (err) {
persistenceFailed = true;
throw err;
}
}
workspaceRegistry.add(runtime);
} catch (err) {
if (persistedRecordAdded) {
try {
await workspaceRegistrationStore!.removeById(
workspaceRegistrationId(canonical),
);
} catch (rollbackErr) {
writeStderrLine(
`qwen serve: failed to roll back workspace persistence after runtime registration failure: ${
rollbackErr instanceof Error
? rollbackErr.message
: String(rollbackErr)
}`,
);
}
}
await runtime.bridge.shutdown().catch(() => undefined);
throw err;
}
res.status(201).json({
id: runtime.workspaceId,
cwd: runtime.workspaceCwd,
primary: runtime.primary,
trusted: runtime.trusted,
...(persist ? { persisted: true } : {}),
});
} catch (err) {
// Log the full error server-side but return a generic message so the
@@ -179,13 +310,103 @@ export function registerWorkspaceManagementRoutes(
err instanceof Error ? err.message : String(err)
}`,
);
res.status(500).json({
error: 'Failed to register workspace',
code: 'runtime_creation_failed',
});
if (persistenceFailed) {
res.status(500).json({
error: 'Failed to persist workspace registration',
code: 'workspace_registration_store_error',
});
} else {
res.status(500).json({
error: 'Failed to register workspace',
code: 'runtime_creation_failed',
});
}
} finally {
inFlight.delete(canonical);
}
},
);
app.get('/workspace-registrations', async (_req, res) => {
if (!workspaceRegistrationStore) {
res.status(501).json({
error: 'Persistent workspace registration is not available',
code: 'persistence_not_available',
});
return;
}
try {
const snapshot = await workspaceRegistrationStore.read();
res.json({
schemaVersion: snapshot.schemaVersion,
primaryWorkspace: snapshot.primaryWorkspace,
entries: snapshot.workspaces.map((cwd) => {
const runtime = workspaceRegistry.getByWorkspaceCwd(cwd);
return {
id: workspaceRegistrationId(cwd),
cwd,
active: runtime !== undefined,
persisted: true,
};
}),
});
} catch (err) {
writeStderrLine(
`qwen serve: failed to read workspace registrations: ${
err instanceof Error ? err.message : String(err)
}`,
);
res.status(500).json({
error: 'Failed to read workspace registrations',
code: 'workspace_registration_store_error',
});
}
});
app.delete(
'/workspace-registrations/:id',
mutate({ strict: true }),
async (req, res) => {
if (!workspaceRegistrationStore) {
res.status(501).json({
error: 'Persistent workspace registration is not available',
code: 'persistence_not_available',
});
return;
}
try {
const registrationId = String(req.params['id']);
const active = workspaceRegistry
.list()
.some(
(runtime) =>
workspaceRegistrationId(runtime.workspaceCwd) === registrationId,
);
const removed =
await workspaceRegistrationStore.removeById(registrationId);
if (!removed) {
res.status(404).json({
error: 'Workspace registration not found',
code: 'workspace_registration_not_found',
});
return;
}
res.json({
removed: true,
active,
restartRequired: active,
});
} catch (err) {
writeStderrLine(
`qwen serve: failed to forget workspace registration: ${
err instanceof Error ? err.message : String(err)
}`,
);
res.status(500).json({
error: 'Failed to forget workspace registration',
code: 'workspace_registration_store_error',
});
}
},
);
}
@@ -48,6 +48,7 @@ import type {
} from '../commands/channel/pidfile.js';
import { LARGE_PIPE_FRAME_THRESHOLD_BYTES } from './large-pipe-frame-observer.js';
import type { ChannelWebhookEnqueueError } from './channel-webhook-ipc.js';
import type { WorkspaceRegistrationStore } from './workspace-registration-store.js';
const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = {
limits: {
@@ -1931,6 +1932,256 @@ describe('runQwenServe runtime startup failures', () => {
}
});
it('restores persisted workspaces through the normal secondary runtime path', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-restored-workspace-')),
);
const primary = path.join(tmpDir, 'primary');
const explicitSecondary = path.join(tmpDir, 'explicit-secondary');
const restoredSecondary = path.join(tmpDir, 'restored-secondary');
const nestedSecondary = path.join(explicitSecondary, 'nested');
fs.mkdirSync(primary);
fs.mkdirSync(explicitSecondary);
fs.mkdirSync(restoredSecondary);
fs.mkdirSync(nestedSecondary);
const canonicalPrimary = canonicalizeWorkspace(primary);
const canonicalExplicitSecondary = canonicalizeWorkspace(explicitSecondary);
const canonicalRestoredSecondary = canonicalizeWorkspace(restoredSecondary);
const missingPersistedWorkspace = path.join(tmpDir, 'missing-secondary');
const stderrWrite = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
vi.spyOn(settingsRuntime, 'loadSettings').mockReturnValue({
merged: {},
} as ReturnType<typeof settingsRuntime.loadSettings>);
vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({
effective: { state: 'trusted' },
} as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>);
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockImplementation(() => makeRuntimeBridge());
let restoredCwds: string[] = [];
let advertisedMaxTotalSessions: number | undefined;
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
(opts, _getPort, deps) => {
restoredCwds =
deps?.workspaceRegistry
?.list()
.map((runtime) => runtime.workspaceCwd) ?? [];
advertisedMaxTotalSessions = opts.maxTotalSessions;
return express();
},
);
const store = {
read: vi.fn().mockResolvedValue({
schemaVersion: 1,
primaryWorkspace: canonicalPrimary,
workspaces: [
missingPersistedWorkspace,
canonicalExplicitSecondary,
nestedSecondary,
canonicalRestoredSecondary,
],
}),
} as unknown as WorkspaceRegistrationStore;
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: [primary, explicitSecondary],
maxSessions: 1,
serveWebShell: false,
},
{
workspaceRegistrationStore: store,
daemonLogBaseDir: path.join(tmpDir, 'debug'),
resolveOnListen: true,
},
);
try {
await handle.runtimeReady;
expect(store.read).toHaveBeenCalledTimes(1);
expect(restoredCwds).toEqual([
canonicalPrimary,
canonicalExplicitSecondary,
canonicalRestoredSecondary,
]);
expect(createBridge).toHaveBeenCalledTimes(3);
expect(advertisedMaxTotalSessions).toBe(3);
expect(
stderrWrite.mock.calls.some(([message]) =>
String(message).includes(
`skipping persisted workspace registration ${JSON.stringify(
missingPersistedWorkspace,
)}`,
),
),
).toBe(true);
expect(
stderrWrite.mock.calls.some(([message]) =>
String(message).includes('path nests with an explicit'),
),
).toBe(true);
} finally {
await handle.close();
}
});
it('continues with explicit workspaces when the registration store read fails', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-restored-read-error-')),
);
const primary = path.join(tmpDir, 'primary');
fs.mkdirSync(primary);
const canonicalPrimary = canonicalizeWorkspace(primary);
const stderrWrite = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
vi.spyOn(settingsRuntime, 'loadSettings').mockReturnValue({
merged: {},
} as ReturnType<typeof settingsRuntime.loadSettings>);
vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({
effective: { state: 'trusted' },
} as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>);
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockImplementation(() => makeRuntimeBridge());
let restoredCwds: string[] = [];
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
(_opts, _getPort, deps) => {
restoredCwds =
deps?.workspaceRegistry
?.list()
.map((runtime) => runtime.workspaceCwd) ?? [];
return express();
},
);
const store = {
read: vi.fn().mockRejectedValue(new Error('store unavailable')),
} as unknown as WorkspaceRegistrationStore;
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: primary,
maxSessions: 1,
serveWebShell: false,
},
{
workspaceRegistrationStore: store,
daemonLogBaseDir: path.join(tmpDir, 'debug'),
resolveOnListen: true,
},
);
try {
await handle.runtimeReady;
expect(restoredCwds).toEqual([canonicalPrimary]);
expect(createBridge).toHaveBeenCalledTimes(1);
expect(
stderrWrite.mock.calls.some(([message]) =>
String(message).includes(
'failed to read persisted workspace registrations',
),
),
).toBe(true);
} finally {
await handle.close();
}
});
it('skips persisted workspaces after the runtime limit is reached', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-restored-limit-')),
);
const explicitWorkspaces = Array.from({ length: 25 }, (_, index) => {
const workspace = path.join(tmpDir, `explicit-${index}`);
fs.mkdirSync(workspace);
return workspace;
});
const overflow = path.join(tmpDir, 'persisted-overflow');
fs.mkdirSync(overflow);
const canonicalExplicit = explicitWorkspaces.map((workspace) =>
canonicalizeWorkspace(workspace),
);
const canonicalOverflow = canonicalizeWorkspace(overflow);
const stderrWrite = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
vi.spyOn(settingsRuntime, 'loadSettings').mockReturnValue({
merged: {},
} as ReturnType<typeof settingsRuntime.loadSettings>);
vi.spyOn(trustedFoldersRuntime, 'getWorkspaceTrustStatus').mockReturnValue({
effective: { state: 'trusted' },
} as ReturnType<typeof trustedFoldersRuntime.getWorkspaceTrustStatus>);
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockImplementation(() => makeRuntimeBridge());
let restoredCwds: string[] = [];
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
(_opts, _getPort, deps) => {
restoredCwds =
deps?.workspaceRegistry
?.list()
.map((runtime) => runtime.workspaceCwd) ?? [];
return express();
},
);
const store = {
read: vi.fn().mockResolvedValue({
schemaVersion: 1,
primaryWorkspace: canonicalExplicit[0],
workspaces: [canonicalOverflow],
}),
} as unknown as WorkspaceRegistrationStore;
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace: explicitWorkspaces,
maxSessions: 1,
serveWebShell: false,
},
{
workspaceRegistrationStore: store,
daemonLogBaseDir: path.join(tmpDir, 'debug'),
resolveOnListen: true,
},
);
try {
await handle.runtimeReady;
expect(restoredCwds).toEqual(canonicalExplicit);
expect(createBridge).toHaveBeenCalledTimes(25);
expect(
stderrWrite.mock.calls.some(([message]) =>
String(message).includes('workspace limit reached'),
),
).toBe(true);
} finally {
await handle.close();
}
});
it('filters secondary workspace roots before constructing the bridge filesystem', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-roots-')),
+72 -1
View File
@@ -19,6 +19,7 @@ import express, {
type Response,
} from 'express';
import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js';
import { isWithinRoot } from '../config/path-comparison.js';
import {
DEFAULT_COMPACTED_REPLAY_MAX_BYTES,
normalizeCompactedReplayMaxBytes,
@@ -31,7 +32,10 @@ import {
preResolveServeFastPathHomeEnvOverrides,
type ServeFastPathSettings,
} from './fast-path-settings.js';
import { resolveWorkspaceInputs } from './workspace-inputs.js';
import {
MAX_REGISTERED_WORKSPACES,
resolveWorkspaceInputs,
} from './workspace-inputs.js';
import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes';
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import type {
@@ -83,6 +87,7 @@ import type {
WorkspaceRegistry,
WorkspaceRuntime,
} from './workspace-registry.js';
import type { WorkspaceRegistrationStore } from './workspace-registration-store.js';
import type { PermissionPolicy } from '@qwen-code/acp-bridge';
import { getCliVersion } from '../utils/version.js';
import { getRateLimiter } from './rate-limit.js';
@@ -733,6 +738,7 @@ export interface RunQwenServeDeps {
opts: CreateChannelWorkerSupervisorOptions,
) => ChannelWorkerSupervisor;
channelServicePidfile?: ChannelServicePidfile;
workspaceRegistrationStore?: WorkspaceRegistrationStore;
}
function shouldPreheatBridge(deps: RunQwenServeDeps): boolean {
@@ -1959,6 +1965,70 @@ export async function runQwenServe(
}
}
}
if (workspaceInputs.length > MAX_REGISTERED_WORKSPACES) {
throw new Error(
`At most ${MAX_REGISTERED_WORKSPACES} --workspace values may be registered.`,
);
}
let workspaceRegistrationStore = deps.workspaceRegistrationStore;
if (
workspaceRegistrationStore === undefined &&
process.env['QWEN_SERVE_NO_PERSISTENT_REGISTRATION'] !== '1'
) {
const { WorkspaceRegistrationStore } = await import(
'./workspace-registration-store.js'
);
workspaceRegistrationStore = new WorkspaceRegistrationStore(boundWorkspace);
}
if (workspaceRegistrationStore) {
try {
const stored = await workspaceRegistrationStore.read();
for (const storedWorkspace of stored.workspaces) {
let cwd: string;
try {
cwd = validateAndCanonicalizeWorkspace(storedWorkspace);
} catch (err) {
writeStderrLine(
`qwen serve: skipping persisted workspace registration ${JSON.stringify(
storedWorkspace,
)}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
}
if (workspaceInputs.some((workspace) => workspace.cwd === cwd)) {
continue;
}
const nested = workspaceInputs.some(
(workspace) =>
isWithinRoot(cwd, workspace.cwd) ||
isWithinRoot(workspace.cwd, cwd),
);
if (nested) {
writeStderrLine(
`qwen serve: skipping persisted workspace registration ${JSON.stringify(
storedWorkspace,
)}: path nests with an explicit or earlier restored workspace`,
);
continue;
}
if (workspaceInputs.length >= MAX_REGISTERED_WORKSPACES) {
writeStderrLine(
`qwen serve: skipping persisted workspace registration ${JSON.stringify(
storedWorkspace,
)}: workspace limit reached`,
);
continue;
}
workspaceInputs.push({ raw: storedWorkspace, cwd });
}
} catch (err) {
writeStderrLine(
`qwen serve: failed to read persisted workspace registrations: ${
err instanceof Error ? err.message : String(err)
}; continuing with explicit workspaces only`,
);
}
}
if (workspaceInputs.length > 1 && deps.bridge) {
throw new Error(
'Injected bridge dependencies are only supported with a single workspace; ' +
@@ -3588,6 +3658,7 @@ export async function runQwenServe(
const app = runtime.createServeApp(opts, () => actualPort, {
workspaceRegistry,
createWorkspaceRuntime: createDynamicWorkspaceRuntime,
workspaceRegistrationStore,
bridge,
webShellDir,
boundWorkspace,
+62
View File
@@ -116,6 +116,7 @@ import type { DaemonLogger } from './daemon-logger.js';
import { FsError, type WorkspaceFileSystemFactory } from './fs/index.js';
import { getRateLimiter } from './rate-limit.js';
import type { DaemonWorkspaceService } from './workspace-service/types.js';
import type { WorkspaceRegistrationStore } from './workspace-registration-store.js';
import {
createWorkspaceRegistry,
type WorkspaceRegistry,
@@ -385,6 +386,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'workspace_reload',
'channel_reload',
'multi_workspace_sessions',
'persistent_workspace_registration',
'workspace_qualified_rest_core',
'workspace_qualified_acp',
'client_mcp_over_ws',
@@ -2187,6 +2189,24 @@ describe('createServeApp', () => {
);
continue;
}
if (feature === 'persistent_workspace_registration') {
expect(
predicate({ persistentWorkspaceRegistrationAvailable: true }),
).toBe(true);
expect(
predicate({ persistentWorkspaceRegistrationAvailable: false }),
).toBe(false);
expect(predicate({})).toBe(false);
expect(
getAdvertisedServeFeatures(undefined, {
persistentWorkspaceRegistrationAvailable: true,
}),
).toContain(feature);
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
feature,
);
continue;
}
if (feature === 'workspace_qualified_acp') {
// Advertised only when BOTH multi-workspace sessions and the HTTP ACP
// surface are enabled.
@@ -2653,6 +2673,48 @@ describe('createServeApp', () => {
expect(res.body.features).not.toContain('session_artifacts_persistence');
});
it('reflects a dynamically added workspace and persistence support', async () => {
const primaryBridge = fakeBridge();
const registry = createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'primary-id',
workspaceCwd: WS_BOUND,
primary: true,
bridge: primaryBridge,
}),
]);
const app = createServeApp(baseOpts, undefined, {
bridge: primaryBridge,
workspaceRegistry: registry,
workspaceRegistrationStore: {} as unknown as WorkspaceRegistrationStore,
});
const before = await request(app)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(before.status).toBe(200);
expect(before.body.features).toContain(
'persistent_workspace_registration',
);
expect(before.body.features).not.toContain('multi_workspace_sessions');
expect(before.body.workspaces).toBeUndefined();
registry.add(
makeWorkspaceRuntimeForTest({
workspaceId: 'secondary-id',
workspaceCwd: '/workspace/secondary',
primary: false,
bridge: fakeBridge(),
}),
);
const after = await request(app)
.get('/capabilities')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(after.status).toBe(200);
expect(after.body.features).toContain('multi_workspace_sessions');
expect(after.body.workspaces).toHaveLength(2);
});
it('advertises workspace voice transcription when a batch ASR model is configured', async () => {
const previousQwenHome = process.env['QWEN_HOME'];
const tempHome = await fsp.mkdtemp(
+7 -4
View File
@@ -167,6 +167,7 @@ import {
registerWorkspaceQualifiedLifecycleRoutes,
} from './routes/workspace-lifecycle.js';
import { registerWorkspaceManagementRoutes } from './routes/workspace-management.js';
import type { WorkspaceRegistrationStore } from './workspace-registration-store.js';
import {
registerWorkspaceMcpControlRoutes,
registerWorkspaceQualifiedMcpControlRoutes,
@@ -420,6 +421,7 @@ export interface ServeAppDeps {
clientMcpSenderRegistry?: ClientMcpSenderRegistry;
workspaceRegistry?: WorkspaceRegistry;
createWorkspaceRuntime?: (cwd: string) => Promise<WorkspaceRuntime>;
workspaceRegistrationStore?: WorkspaceRegistrationStore;
primaryWorkspaceTrusted?: boolean;
primaryRuntimeEnv?: WorkspaceRuntimeEnvMetadata;
voiceTranscriber?: WorkspaceVoiceRouteDeps['transcribe'];
@@ -633,8 +635,9 @@ export function createServeApp(
deps.getChannelWorkerSnapshot !== undefined &&
deps.reloadChannelWorker !== undefined,
sessionShellCommandEnabled,
multiWorkspaceSessionsEnabled:
(injectedWorkspaceRegistry?.list().length ?? 1) > 1,
multiWorkspaceSessionsEnabled: () => workspaceRegistry.list().length > 1,
persistentWorkspaceRegistrationAvailable:
deps.workspaceRegistrationStore !== undefined,
...(primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {}),
});
const statusProvider =
@@ -800,8 +803,7 @@ export function createServeApp(
const primaryBridge = primaryRuntime.bridge;
const primaryWorkspace = primaryRuntime.workspaceService;
const primaryRouteFileSystemFactory = primaryRuntime.routeFileSystemFactory;
const workspaceQualifiedAcpEnabled =
resolveAcpHttpEnabled() && workspaceRegistry.list().length > 1;
const workspaceQualifiedAcpEnabled = resolveAcpHttpEnabled();
// Order matters: rejection guards (CORS / Host allowlist / bearer auth)
// run BEFORE the JSON body parser. Otherwise an unauthenticated POST
@@ -1108,6 +1110,7 @@ export function createServeApp(
mutate,
safeBody,
createWorkspaceRuntime: deps.createWorkspaceRuntime,
workspaceRegistrationStore: deps.workspaceRegistrationStore,
});
const broadcastSettingsChanged = (
@@ -46,7 +46,8 @@ interface CreateServeFeaturesDeps {
reloadAvailable: boolean;
channelReloadAvailable: boolean;
sessionShellCommandEnabled: boolean;
multiWorkspaceSessionsEnabled: boolean;
multiWorkspaceSessionsEnabled: () => boolean;
persistentWorkspaceRegistrationAvailable: boolean;
env?: Readonly<Record<string, string | undefined>>;
}
@@ -68,6 +69,7 @@ export function createServeFeatures(
channelReloadAvailable,
sessionShellCommandEnabled,
multiWorkspaceSessionsEnabled,
persistentWorkspaceRegistrationAvailable,
} = deps;
const env = deps.env ?? process.env;
let cachedVoiceTranscriptionAvailable: boolean | undefined;
@@ -101,7 +103,8 @@ export function createServeFeatures(
rateLimit: opts.rateLimit === true,
reloadAvailable,
channelReloadAvailable,
multiWorkspaceSessionsEnabled,
multiWorkspaceSessionsEnabled: multiWorkspaceSessionsEnabled(),
persistentWorkspaceRegistrationAvailable,
acpHttpEnabled: resolveAcpHttpEnabled(),
clientMcpOverWsEnabled: opts.clientMcpOverWs === true,
cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true,
@@ -7,6 +7,8 @@
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
import { isWithinRoot } from '../config/path-comparison.js';
export const MAX_REGISTERED_WORKSPACES = 25;
export class DuplicateWorkspaceInputError extends Error {
constructor(workspace: string) {
super(
@@ -0,0 +1,224 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import {
WorkspaceRegistrationStore,
WorkspaceRegistrationStoreError,
getWorkspaceRegistrationStorePath,
workspaceRegistrationId,
workspaceRegistrationScopeHash,
} from './workspace-registration-store.js';
const cleanup: string[] = [];
async function tempHome(): Promise<string> {
const dir = await fs.mkdtemp(path.join(tmpdir(), 'qwen-workspace-store-'));
cleanup.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(
cleanup
.splice(0)
.map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe('WorkspaceRegistrationStore', () => {
it('uses a full stable scope hash and returns an empty missing store', async () => {
const home = await tempHome();
const hash = workspaceRegistrationScopeHash('/work/primary');
expect(hash).toMatch(/^[a-f0-9]{64}$/);
expect(getWorkspaceRegistrationStorePath('/work/primary', home)).toBe(
path.join(home, 'daemon', 'workspaces', `${hash}.json`),
);
await expect(
new WorkspaceRegistrationStore('/work/primary', home).read(),
).resolves.toEqual({
schemaVersion: 1,
primaryWorkspace: '/work/primary',
workspaces: [],
});
});
it('persists, deduplicates, and removes workspace paths', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await expect(store.add('/work/secondary')).resolves.toBe(true);
await expect(store.add('/work/secondary')).resolves.toBe(false);
await expect(store.read()).resolves.toMatchObject({
workspaces: ['/work/secondary'],
});
if (process.platform !== 'win32') {
expect((await fs.stat(store.filePath)).mode & 0o777).toBe(0o600);
}
const id = workspaceRegistrationId('/work/secondary');
await expect(store.removeById(id)).resolves.toBe(true);
await expect(store.read()).resolves.toMatchObject({ workspaces: [] });
});
it('returns false when removing a missing workspace id', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await expect(store.add('/work/secondary')).resolves.toBe(true);
await expect(store.removeById('missing')).resolves.toBe(false);
await expect(store.read()).resolves.toMatchObject({
workspaces: ['/work/secondary'],
});
});
it.skipIf(process.platform !== 'win32')(
'deduplicates workspace paths case-insensitively on Windows',
async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('C:\\work\\primary', home);
await expect(store.add('C:\\Work\\Secondary')).resolves.toBe(true);
await expect(store.add('c:\\work\\secondary')).resolves.toBe(false);
await expect(store.read()).resolves.toMatchObject({
workspaces: ['C:\\Work\\Secondary'],
});
},
);
it('rejects invalid primary and primary-as-secondary inputs', async () => {
const home = await tempHome();
expect(() => new WorkspaceRegistrationStore('relative', home)).toThrow(
/primaryWorkspace must be an absolute path/,
);
const store = new WorkspaceRegistrationStore('/work/primary', home);
await expect(store.add('/work/primary')).rejects.toThrow(
/Primary workspace cannot be stored/,
);
await expect(fs.stat(store.filePath)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('serializes concurrent updates without losing either workspace', async () => {
const home = await tempHome();
const first = new WorkspaceRegistrationStore('/work/primary', home);
const second = new WorkspaceRegistrationStore('/work/primary', home);
await Promise.all([
first.add('/work/secondary-a'),
second.add('/work/secondary-b'),
]);
expect((await first.read()).workspaces.sort()).toEqual([
'/work/secondary-a',
'/work/secondary-b',
]);
});
it('refuses a malformed store without overwriting it', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
await fs.writeFile(store.filePath, '{broken', 'utf8');
await expect(store.add('/work/secondary')).rejects.toBeInstanceOf(
WorkspaceRegistrationStoreError,
);
expect(await fs.readFile(store.filePath, 'utf8')).toBe('{broken');
});
it('refuses a primary-scope mismatch without overwriting it', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
const mismatched = JSON.stringify({
schemaVersion: 1,
primaryWorkspace: '/work/different-primary',
workspaces: ['/work/secondary'],
});
await fs.writeFile(store.filePath, mismatched, 'utf8');
await expect(store.add('/work/other')).rejects.toThrow(
/primary does not match/,
);
expect(await fs.readFile(store.filePath, 'utf8')).toBe(mismatched);
});
it('rejects unknown schema versions and duplicate entries', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
await fs.writeFile(
store.filePath,
JSON.stringify({
schemaVersion: 2,
primaryWorkspace: '/work/primary',
workspaces: [],
}),
);
await expect(store.read()).rejects.toThrow(/Unsupported.*schema 2/);
await fs.writeFile(
store.filePath,
JSON.stringify({
schemaVersion: 1,
primaryWorkspace: '/work/primary',
workspaces: ['/work/secondary', '/work/secondary'],
}),
);
await expect(store.read()).rejects.toThrow(/duplicate paths/);
});
it('rejects additions after reaching the secondary workspace limit', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
const workspaces = Array.from(
{ length: 24 },
(_, index) => `/work/secondary-${index}`,
);
await fs.writeFile(
store.filePath,
JSON.stringify({
schemaVersion: 1,
primaryWorkspace: '/work/primary',
workspaces,
}),
);
await expect(store.add('/work/overflow')).rejects.toThrow(
/limit of 24 reached/,
);
await expect(store.read()).resolves.toMatchObject({ workspaces });
});
it('rejects a symlink store', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
const target = path.join(home, 'target.json');
await fs.writeFile(target, '{}');
await fs.symlink(target, store.filePath);
await expect(store.read()).rejects.toThrow(/regular file/);
});
it('rejects an oversized store', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
await fs.mkdir(path.dirname(store.filePath), { recursive: true });
await fs.writeFile(store.filePath, Buffer.alloc(256 * 1024 + 1));
await expect(store.read()).rejects.toThrow(/exceeds 262144 bytes/);
});
it('recovers an orphaned stale lock', async () => {
const home = await tempHome();
const store = new WorkspaceRegistrationStore('/work/primary', home);
const lockPath = `${store.filePath}.lock`;
await fs.mkdir(lockPath, { recursive: true });
const staleTime = new Date(Date.now() - 20_000);
await fs.utimes(lockPath, staleTime, staleTime);
await expect(store.add('/work/secondary')).resolves.toBe(true);
await expect(fs.stat(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
});
@@ -0,0 +1,376 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import lockfile from 'proper-lockfile';
import { MAX_WORKSPACE_PATH_LENGTH } from '@qwen-code/acp-bridge/workspacePaths';
import { getGlobalQwenDirLite } from '../config/storage-paths-lite.js';
import { MAX_REGISTERED_WORKSPACES } from './workspace-inputs.js';
const SCHEMA_VERSION = 1;
const MAX_SECONDARY_WORKSPACES = MAX_REGISTERED_WORKSPACES - 1;
const MAX_STORE_BYTES = 256 * 1024;
const LOCK_OPTIONS: lockfile.LockOptions = {
realpath: false,
stale: 10_000,
update: 2_000,
retries: {
retries: 120,
minTimeout: 10,
maxTimeout: 100,
factor: 1.2,
randomize: true,
},
};
export interface WorkspaceRegistrationSnapshot {
schemaVersion: 1;
primaryWorkspace: string;
workspaces: string[];
}
export class WorkspaceRegistrationStoreError extends Error {
constructor(message: string) {
super(message);
this.name = 'WorkspaceRegistrationStoreError';
}
}
export class WorkspaceRegistrationStoreLimitError extends WorkspaceRegistrationStoreError {}
function normalizedScopePath(primaryWorkspace: string): string {
return os.platform() === 'win32'
? primaryWorkspace.toLowerCase()
: primaryWorkspace;
}
export function workspaceRegistrationScopeHash(
primaryWorkspace: string,
): string {
return createHash('sha256')
.update(normalizedScopePath(primaryWorkspace))
.digest('hex');
}
export function workspaceRegistrationId(workspace: string): string {
return createHash('sha256').update(workspace).digest('hex').slice(0, 16);
}
export function getWorkspaceRegistrationStorePath(
primaryWorkspace: string,
qwenHome = getGlobalQwenDirLite(),
): string {
return path.join(
qwenHome,
'daemon',
'workspaces',
`${workspaceRegistrationScopeHash(primaryWorkspace)}.json`,
);
}
function emptySnapshot(
primaryWorkspace: string,
): WorkspaceRegistrationSnapshot {
return {
schemaVersion: SCHEMA_VERSION,
primaryWorkspace,
workspaces: [],
};
}
function validateWorkspacePath(value: unknown, label: string): string {
if (typeof value !== 'string' || !path.isAbsolute(value)) {
throw new WorkspaceRegistrationStoreError(
`${label} must be an absolute path`,
);
}
if (value.includes('\0') || value.length > MAX_WORKSPACE_PATH_LENGTH) {
throw new WorkspaceRegistrationStoreError(`${label} is invalid`);
}
return value;
}
function parseSnapshot(
raw: string,
primaryWorkspace: string,
): WorkspaceRegistrationSnapshot {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store contains malformed JSON',
);
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store root must be an object',
);
}
const record = value as Record<string, unknown>;
if (record['schemaVersion'] !== SCHEMA_VERSION) {
throw new WorkspaceRegistrationStoreError(
`Unsupported workspace registration schema ${String(record['schemaVersion'])}`,
);
}
const storedPrimary = validateWorkspacePath(
record['primaryWorkspace'],
'primaryWorkspace',
);
if (
normalizedScopePath(storedPrimary) !== normalizedScopePath(primaryWorkspace)
) {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store primary does not match this daemon',
);
}
if (!Array.isArray(record['workspaces'])) {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store workspaces must be an array',
);
}
if (record['workspaces'].length > MAX_SECONDARY_WORKSPACES) {
throw new WorkspaceRegistrationStoreError(
`Workspace registration store exceeds ${MAX_SECONDARY_WORKSPACES} entries`,
);
}
const seen = new Set<string>();
const workspaces = record['workspaces'].map((entry, index) => {
const workspace = validateWorkspacePath(entry, `workspaces[${index}]`);
const normalizedWorkspace = normalizedScopePath(workspace);
if (normalizedWorkspace === normalizedScopePath(primaryWorkspace)) {
throw new WorkspaceRegistrationStoreError(
'Primary workspace cannot be stored as a secondary registration',
);
}
if (seen.has(normalizedWorkspace)) {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store contains duplicate paths',
);
}
seen.add(normalizedWorkspace);
return workspace;
});
return { schemaVersion: SCHEMA_VERSION, primaryWorkspace, workspaces };
}
const updateChains = new Map<string, Promise<unknown>>();
async function withInProcessLock<T>(
filePath: string,
work: () => Promise<T>,
): Promise<T> {
const previous = updateChains.get(filePath) ?? Promise.resolve();
const next = previous.then(work, work);
const settled = next
.catch(() => undefined)
.finally(() => {
if (updateChains.get(filePath) === settled) updateChains.delete(filePath);
});
updateChains.set(filePath, settled);
return next;
}
interface AcquiredFileLock {
assertOwned(): void;
release(): Promise<void>;
}
function compromisedLockError(): WorkspaceRegistrationStoreError {
return new WorkspaceRegistrationStoreError(
'Workspace registration store lock was compromised',
);
}
async function acquireFileLock(filePath: string): Promise<AcquiredFileLock> {
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
let compromised = false;
const release = await lockfile.lock(filePath, {
...LOCK_OPTIONS,
onCompromised: () => {
compromised = true;
},
});
return {
assertOwned: () => {
if (compromised) throw compromisedLockError();
},
release: async () => {
try {
await release();
} catch (err) {
if (!compromised) throw err;
}
if (compromised) throw compromisedLockError();
},
};
}
export class WorkspaceRegistrationStore {
readonly filePath: string;
constructor(
readonly primaryWorkspace: string,
qwenHome?: string,
) {
validateWorkspacePath(primaryWorkspace, 'primaryWorkspace');
this.filePath = getWorkspaceRegistrationStorePath(
primaryWorkspace,
qwenHome,
);
}
/** Returns an unlocked point-in-time snapshot; mutations re-read under lock. */
async read(): Promise<WorkspaceRegistrationSnapshot> {
try {
const entry = await fs.lstat(this.filePath);
if (entry.isSymbolicLink() || !entry.isFile()) {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store must be a regular file',
);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return emptySnapshot(this.primaryWorkspace);
}
throw err;
}
let file: Awaited<ReturnType<typeof fs.open>>;
try {
file = await fs.open(
this.filePath,
(constants.O_RDONLY ?? 0) | (constants.O_NOFOLLOW ?? 0),
);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return emptySnapshot(this.primaryWorkspace);
}
if ((err as NodeJS.ErrnoException).code === 'ELOOP') {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store must be a regular file',
);
}
throw err;
}
try {
const stat = await file.stat();
if (!stat.isFile()) {
throw new WorkspaceRegistrationStoreError(
'Workspace registration store must be a regular file',
);
}
if (stat.size > MAX_STORE_BYTES) {
throw new WorkspaceRegistrationStoreError(
`Workspace registration store exceeds ${MAX_STORE_BYTES} bytes`,
);
}
let buffer = Buffer.alloc(
Math.max(1, Math.min(stat.size + 1, MAX_STORE_BYTES + 1)),
);
let totalBytesRead = 0;
for (;;) {
if (totalBytesRead === buffer.length) {
if (totalBytesRead > MAX_STORE_BYTES) break;
const grown = Buffer.alloc(
Math.min(
MAX_STORE_BYTES + 1,
Math.max(buffer.length * 2, totalBytesRead + 1),
),
);
buffer.copy(grown, 0, 0, totalBytesRead);
buffer = grown;
}
const { bytesRead } = await file.read(
buffer,
totalBytesRead,
buffer.length - totalBytesRead,
totalBytesRead,
);
if (bytesRead === 0) break;
totalBytesRead += bytesRead;
}
if (totalBytesRead > MAX_STORE_BYTES) {
throw new WorkspaceRegistrationStoreError(
`Workspace registration store exceeds ${MAX_STORE_BYTES} bytes`,
);
}
return parseSnapshot(
buffer.toString('utf8', 0, totalBytesRead),
this.primaryWorkspace,
);
} finally {
await file.close();
}
}
async add(workspace: string): Promise<boolean> {
validateWorkspacePath(workspace, 'workspace');
if (
normalizedScopePath(workspace) ===
normalizedScopePath(this.primaryWorkspace)
) {
throw new WorkspaceRegistrationStoreError(
'Primary workspace cannot be stored as a secondary registration',
);
}
return this.update((snapshot) => {
const normalizedWorkspace = normalizedScopePath(workspace);
if (
snapshot.workspaces.some(
(stored) => normalizedScopePath(stored) === normalizedWorkspace,
)
) {
return false;
}
if (snapshot.workspaces.length >= MAX_SECONDARY_WORKSPACES) {
throw new WorkspaceRegistrationStoreLimitError(
`Workspace registration store limit of ${MAX_SECONDARY_WORKSPACES} reached`,
);
}
snapshot.workspaces.push(workspace);
return true;
});
}
async removeById(id: string): Promise<boolean> {
return this.update((snapshot) => {
const index = snapshot.workspaces.findIndex(
(workspace) => workspaceRegistrationId(workspace) === id,
);
if (index < 0) return false;
snapshot.workspaces.splice(index, 1);
return true;
});
}
private async update(
mutate: (snapshot: WorkspaceRegistrationSnapshot) => boolean,
): Promise<boolean> {
const { atomicWriteFile } = await import('@qwen-code/qwen-code-core');
return withInProcessLock(this.filePath, async () => {
const lock = await acquireFileLock(this.filePath);
try {
const snapshot = await this.read();
lock.assertOwned();
const changed = mutate(snapshot);
if (!changed) return false;
lock.assertOwned();
await atomicWriteFile(
this.filePath,
`${JSON.stringify(snapshot, null, 2)}\n`,
{ mode: 0o600, forceMode: true, noFollow: true },
);
return true;
} finally {
await lock.release();
}
});
}
}
+4
View File
@@ -15,6 +15,10 @@ if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) {
process.env['QWEN_DEBUG_LOG_FILE'] = '0';
}
if (process.env['QWEN_SERVE_NO_PERSISTENT_REGISTRATION'] === undefined) {
process.env['QWEN_SERVE_NO_PERSISTENT_REGISTRATION'] = '1';
}
import './src/test-utils/customMatchers.js';
// Lowlight is loaded asynchronously in production to keep it out of the
@@ -3355,13 +3355,23 @@ export class DaemonClient {
async addWorkspace(
cwd: string,
): Promise<{ id: string; cwd: string; primary: boolean; trusted: boolean }> {
options: { persist?: boolean } = {},
): Promise<{
id: string;
cwd: string;
primary: boolean;
trusted: boolean;
persisted?: boolean;
}> {
return await this.fetchWithTimeout(
`${this.baseUrl}/workspaces`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ cwd }),
body: JSON.stringify({
cwd,
...(options.persist ? { persist: true } : {}),
}),
},
async (res) => {
if (!res.ok) {
@@ -3372,6 +3382,7 @@ export class DaemonClient {
cwd: string;
primary: boolean;
trusted: boolean;
persisted?: boolean;
};
},
);
@@ -4945,4 +4945,46 @@ describe('DaemonClient', () => {
expect(a).toBe(b);
});
});
describe('workspace registration persistence', () => {
it('forwards persist:true and returns the persisted marker', async () => {
const response = {
id: 'workspace-id',
cwd: '/work/secondary',
primary: false,
trusted: true,
persisted: true,
};
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, response),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.addWorkspace('/work/secondary', { persist: true }),
).resolves.toEqual(response);
expect(calls[0]?.url).toBe('http://daemon/workspaces');
expect(calls[0]?.method).toBe('POST');
expect(JSON.parse(calls[0]!.body!)).toEqual({
cwd: '/work/secondary',
persist: true,
});
});
it('keeps the existing ephemeral request shape by default', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, {
id: 'workspace-id',
cwd: '/work/secondary',
primary: false,
trusted: true,
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client.addWorkspace('/work/secondary');
expect(JSON.parse(calls[0]!.body!)).toEqual({
cwd: '/work/secondary',
});
});
});
});
@@ -59,6 +59,7 @@ const {
updateSessionGroup: vi.fn(),
deleteSessionGroup: vi.fn(),
updateSessionOrganization: vi.fn(),
addWorkspace: vi.fn().mockResolvedValue({ persisted: true }),
},
mockWorkspace: {
client: {
@@ -224,6 +225,8 @@ beforeEach(() => {
mockWorkspaceActions.updateSessionGroup.mockReset();
mockWorkspaceActions.deleteSessionGroup.mockReset();
mockWorkspaceActions.updateSessionOrganization.mockReset();
mockWorkspaceActions.addWorkspace.mockReset();
mockWorkspaceActions.addWorkspace.mockResolvedValue({ persisted: true });
});
afterEach(() => {
@@ -265,6 +268,33 @@ describe('WebShellSidebar — workspace picker', () => {
);
}
async function submitAddWorkspace(container: HTMLElement): Promise<void> {
const addButton = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent?.includes('Add workspace'));
expect(addButton).toBeDefined();
act(() => {
addButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const input = document.body.querySelector<HTMLInputElement>(
'#add-workspace-path',
);
expect(input).not.toBeNull();
const setInputValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
act(() => {
setInputValue?.call(input, '/tmp/new-workspace');
input!.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => {
input!.form!.dispatchEvent(
new SubmitEvent('submit', { bubbles: true, cancelable: true }),
);
});
}
it('renders a workspace entry per registered workspace', () => {
mockWorkspace.capabilities = multiWorkspaceCaps;
const { container } = renderSidebar(false);
@@ -605,6 +635,69 @@ describe('WebShellSidebar — workspace picker', () => {
);
expect(secondary).toBeUndefined();
});
it('persists workspaces when the daemon advertises support', async () => {
mockWorkspace.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['persistent_workspace_registration'],
workspaces: [
{ id: 'ws-primary', cwd: '/tmp/project', primary: true, trusted: true },
],
};
const { container } = renderSidebar(false);
await submitAddWorkspace(container);
expect(mockWorkspaceActions.addWorkspace).toHaveBeenCalledWith(
'/tmp/new-workspace',
{ persist: true },
);
});
it('waits for capabilities before choosing persistence', async () => {
mockWorkspace.capabilities = undefined;
mockWorkspace.getCapabilities.mockResolvedValue({
qwenCodeVersion: '1.2.3',
features: ['persistent_workspace_registration'],
workspaces: [],
});
const { container } = renderSidebar(false);
await submitAddWorkspace(container);
expect(mockWorkspace.getCapabilities).toHaveBeenCalledTimes(1);
expect(mockWorkspaceActions.addWorkspace).toHaveBeenCalledWith(
'/tmp/new-workspace',
{ persist: true },
);
});
it('keeps the legacy one-argument call when persistence is unsupported', async () => {
mockWorkspace.capabilities = {
qwenCodeVersion: '1.2.3',
features: [],
workspaces: [],
};
const { container } = renderSidebar(false);
await submitAddWorkspace(container);
expect(mockWorkspaceActions.addWorkspace).toHaveBeenCalledWith(
'/tmp/new-workspace',
);
});
it('rejects success without the persisted confirmation marker', async () => {
mockWorkspace.capabilities = {
qwenCodeVersion: '1.2.3',
features: ['persistent_workspace_registration'],
workspaces: [],
};
mockWorkspaceActions.addWorkspace.mockResolvedValue({});
const { container } = renderSidebar(false);
await submitAddWorkspace(container);
expect(document.body.textContent).toContain(
'The daemon did not confirm persistent workspace registration',
);
});
});
describe('WebShellSidebar — version footer', () => {
@@ -909,7 +909,17 @@ export function WebShellSidebar({
const handleAddWorkspace = useCallback(
async (cwd: string) => {
await workspaceActions.addWorkspace(cwd);
const capabilities =
workspace.capabilities ?? (await workspace.getCapabilities?.());
const supportsPersistence = Boolean(
capabilities?.features?.includes('persistent_workspace_registration'),
);
const result = supportsPersistence
? await workspaceActions.addWorkspace(cwd, { persist: true })
: await workspaceActions.addWorkspace(cwd);
if (supportsPersistence && result.persisted !== true) {
throw new Error(t('sidebar.addWorkspacePersistenceError'));
}
// Force a fresh capabilities fetch so the new workspace appears
// immediately. Best-effort: registration already succeeded, so a
// refresh failure must not surface as an add-workspace error — the
@@ -921,7 +931,7 @@ export function WebShellSidebar({
// ignore — the workspace is registered; the list reconciles on reload
}
},
[workspaceActions, workspace],
[t, workspaceActions, workspace],
);
const handleNewSession = useCallback(() => {
+3
View File
@@ -799,6 +799,8 @@ const EN: Messages = {
'sidebar.addWorkspaceRegister': 'Register',
'sidebar.addWorkspaceCancel': 'Cancel',
'sidebar.addWorkspaceError': 'Failed to add workspace',
'sidebar.addWorkspacePersistenceError':
'The daemon did not confirm persistent workspace registration',
'sidebar.addWorkspaceAbsError': 'Path must be absolute',
'sidebar.addWorkspaceHint': 'Enter the absolute path to a project directory.',
'sidebar.addWorkspaceAdding': 'Adding…',
@@ -2553,6 +2555,7 @@ const ZH: Messages = {
'sidebar.addWorkspaceRegister': '注册',
'sidebar.addWorkspaceCancel': '取消',
'sidebar.addWorkspaceError': '添加工作区失败',
'sidebar.addWorkspacePersistenceError': '守护进程未确认工作区已持久化注册',
'sidebar.addWorkspaceAbsError': '路径必须是绝对路径',
'sidebar.addWorkspaceHint': '请输入项目目录的绝对路径。',
'sidebar.addWorkspaceAdding': '添加中…',
@@ -707,10 +707,10 @@ export function createDaemonWorkspaceActions({
);
},
async addWorkspace(cwd) {
async addWorkspace(cwd, options) {
const client = requireClient(getClient, 'Add workspace failed');
return withActionTimeout(
client.addWorkspace(cwd),
client.addWorkspace(cwd, options),
'Add workspace timed out',
);
},
+5 -1
View File
@@ -234,6 +234,7 @@ export interface DaemonAddWorkspaceResult {
cwd: string;
primary: boolean;
trusted: boolean;
persisted?: boolean;
}
export interface DaemonWorkspaceActions {
@@ -425,5 +426,8 @@ export interface DaemonWorkspaceActions {
): Promise<DaemonAuthProviderInstallResult>;
// Workspace management
addWorkspace(cwd: string): Promise<DaemonAddWorkspaceResult>;
addWorkspace(
cwd: string,
options?: { persist?: boolean },
): Promise<DaemonAddWorkspaceResult>;
}