mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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.
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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-')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user