From d696a6bd639f78639b9ed3487aeefc7e935591c2 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 19 May 2026 01:53:04 +0800 Subject: [PATCH 1/4] refactor(acp-bridge): lift status + paths + errors (#4175 PR 22b checkpoint 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First three slices of the PR 22b bulk lift. All purely mechanical moves with re-export wrappers at the old locations so every existing import keeps resolving. What moves - `packages/cli/src/serve/status.ts` (600 LOC) → `packages/acp-bridge/src/status.ts` (acpAgent.ts:85-109 imports 25 symbols from this; lifting unifies the wire-contract owner.) - `packages/cli/src/serve/status.test.ts` → `packages/acp-bridge/src/status.test.ts` - `canonicalizeWorkspace` from `cli/src/serve/fs/paths.ts:61-97` → `packages/acp-bridge/src/workspacePaths.ts` (cross-module contract; bridge package now owns it directly) - `MAX_WORKSPACE_PATH_LENGTH` from `httpAcpBridge.ts:762` → same `workspacePaths.ts` file - 11 bridge error classes from `httpAcpBridge.ts` (`SessionNotFoundError`, `RestoreInProgressError`, `InvalidSessionScopeError`, `SessionLimitExceededError`, `WorkspaceMismatchError`, `InvalidClientIdError`, `InvalidPermissionOptionError`, `InvalidSessionMetadataError`, `WorkspaceInitConflictError`, `McpServerNotFoundError`, `McpServerRestartFailedError`) → new `packages/acp-bridge/src/bridgeErrors.ts` Wrappers - `cli/src/serve/status.ts` shrinks to a 1-line `export *` re-export - `cli/src/serve/fs/paths.ts` keeps its other exports (`resolveWithinWorkspace`, `Intent`, `ResolvedPath`, `hasSuspiciousPathPattern`); imports + re-exports the lifted two - `httpAcpBridge.ts` consolidated `import` + `export` block for all 11 error classes plus `MAX_WORKSPACE_PATH_LENGTH`; the local factory code keeps using these symbols since they're now imported into module scope Package wiring - `packages/acp-bridge/package.json` — adds `@qwen-code/qwen-code-core` dependency (status types reference `SkillError` from core); adds subpath exports for `./status`, `./workspacePaths`, `./bridgeErrors`, plus future `/bridgeTypes`, `/bridgeOptions`, `/bridge`, `/spawnChannel` - `packages/acp-bridge/tsconfig.json` — adds `paths` mapping for `@qwen-code/qwen-code-core` and a project reference so typecheck resolves core sources directly without needing dist/ first - `packages/cli/tsconfig.json` already has `@qwen-code/acp-bridge/*` path mapping from PR 22a, no change needed - `package-lock.json` — surgical patch adding `@qwen-code/qwen-code-core` dep to acp-bridge's workspace entry; restored from origin/main first to avoid the npm-11 peer-flag churn that broke PR 22a's first push Backward compatibility - Every existing relative import from `cli/src/serve/` keeps working through wrappers (commands/serve.ts, server.ts, runQwenServe.ts, workspaceAgents.ts, workspaceMemory.ts, acpAgent.ts) - Zero `/capabilities`, route, SDK, or behavior changes Verification - `npm ci --no-audit --ignore-scripts` clean (1453 packages) - `cd packages/core && npx tsc --build` clean - `cd packages/acp-bridge && npx tsc --noEmit` clean (with moved tests) - `cd packages/cli && npx tsc --noEmit` clean Remaining PR 22b checkpoints - 22b/2: bridge types + BridgeOptions + DaemonStatusProvider seam - 22b/3: BridgeClient class - 22b/4: defaultSpawnChannelFactory - 22b/5: createHttpAcpBridge factory (~2240 LOC of moves) - 22b/6: serve/daemonStatusProvider.ts + runQwenServe wire - 22b/7: move bridge tests - 22b/8: shrink httpAcpBridge.ts to final re-export shim --- package-lock.json | 3 +- packages/acp-bridge/package.json | 31 +- packages/acp-bridge/src/bridgeErrors.ts | 214 +++++++ .../serve => acp-bridge/src}/status.test.ts | 0 packages/acp-bridge/src/status.ts | 600 +++++++++++++++++ packages/acp-bridge/src/workspacePaths.ts | 79 +++ packages/acp-bridge/tsconfig.json | 10 +- packages/cli/src/serve/fs/paths.ts | 100 +-- packages/cli/src/serve/httpAcpBridge.ts | 266 ++------ packages/cli/src/serve/status.ts | 601 +----------------- 10 files changed, 994 insertions(+), 910 deletions(-) create mode 100644 packages/acp-bridge/src/bridgeErrors.ts rename packages/{cli/src/serve => acp-bridge/src}/status.test.ts (100%) create mode 100644 packages/acp-bridge/src/status.ts create mode 100644 packages/acp-bridge/src/workspacePaths.ts diff --git a/package-lock.json b/package-lock.json index a3bc7c6aa2d..4ddeda80f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17224,7 +17224,8 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.14.1" + "@agentclientprotocol/sdk": "^0.14.1", + "@qwen-code/qwen-code-core": "file:../core" }, "devDependencies": { "typescript": "^5.3.3", diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 0375860f320..19f076f13bf 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -31,6 +31,34 @@ "types": "./dist/permission.d.ts", "import": "./dist/permission.js" }, + "./status": { + "types": "./dist/status.d.ts", + "import": "./dist/status.js" + }, + "./workspacePaths": { + "types": "./dist/workspacePaths.d.ts", + "import": "./dist/workspacePaths.js" + }, + "./bridgeErrors": { + "types": "./dist/bridgeErrors.d.ts", + "import": "./dist/bridgeErrors.js" + }, + "./bridgeTypes": { + "types": "./dist/bridgeTypes.d.ts", + "import": "./dist/bridgeTypes.js" + }, + "./bridgeOptions": { + "types": "./dist/bridgeOptions.d.ts", + "import": "./dist/bridgeOptions.js" + }, + "./bridge": { + "types": "./dist/bridge.d.ts", + "import": "./dist/bridge.js" + }, + "./spawnChannel": { + "types": "./dist/spawnChannel.d.ts", + "import": "./dist/spawnChannel.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -45,7 +73,8 @@ "dist" ], "dependencies": { - "@agentclientprotocol/sdk": "^0.14.1" + "@agentclientprotocol/sdk": "^0.14.1", + "@qwen-code/qwen-code-core": "file:../core" }, "devDependencies": { "typescript": "^5.3.3", diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts new file mode 100644 index 00000000000..490f2b592fe --- /dev/null +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js'; + +export class SessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string, extra?: string) { + super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : '')); + this.name = 'SessionNotFoundError'; + this.sessionId = sessionId; + } +} + +export class RestoreInProgressError extends Error { + readonly sessionId: string; + readonly activeAction: 'load' | 'resume'; + readonly requestedAction: 'load' | 'resume'; + + constructor( + sessionId: string, + activeAction: 'load' | 'resume', + requestedAction: 'load' | 'resume', + ) { + super( + `Session "${sessionId}" is already being restored via session/${activeAction}; retry session/${requestedAction} after it completes`, + ); + this.name = 'RestoreInProgressError'; + this.sessionId = sessionId; + this.activeAction = activeAction; + this.requestedAction = requestedAction; + } +} + +/** + * Thrown by `spawnOrAttach` when `req.sessionScope` is set to a value + * outside the `'single' | 'thread'` enum. The HTTP route validates the + * body field at the boundary first (so HTTP callers get a typed + * `400 invalid_session_scope` before ever reaching the bridge); this + * class exists for direct callers — tests, embeds, future entry points + * — and so the route's catch-block can translate it back to the same + * 400 shape rather than the generic 500 every other thrown `Error` + * collapses to. Distinct type so routes can branch without + * text-matching the message. + */ +export class InvalidSessionScopeError extends Error { + readonly sessionScope: unknown; + constructor(sessionScope: unknown) { + super( + `Invalid sessionScope: ${JSON.stringify(sessionScope)}. ` + + `Expected 'single' or 'thread'.`, + ); + this.name = 'InvalidSessionScopeError'; + this.sessionScope = sessionScope; + } +} + +/** + * Thrown by `spawnOrAttach` when a fresh-spawn would push `sessionCount` + * past `BridgeOptions.maxSessions`. The HTTP route maps this to 503 + * with a `Retry-After` hint. Attaches (same workspace under `single` + * scope) never trip this — only NEW children. Distinct error type so + * routes can branch without text-matching. + */ +export class SessionLimitExceededError extends Error { + readonly limit: number; + constructor(limit: number) { + super(`Session limit reached (${limit})`); + this.name = 'SessionLimitExceededError'; + this.limit = limit; + } +} + +/** + * Thrown by `spawnOrAttach` when the requested `workspaceCwd` doesn't + * canonicalize to the daemon's bound workspace. Per #3803 §02 every + * bridge instance is bound to exactly one workspace; cross-workspace + * requests are rejected at the daemon boundary. The server route + * translates this to a 400 response with `code: 'workspace_mismatch'` + * and both paths in the body so clients can fall through to spawning + * their own daemon / routing to a different one via an orchestrator. + */ +export class WorkspaceMismatchError extends Error { + readonly bound: string; + readonly requested: string; + constructor(bound: string, requested: string) { + // Truncate `requested` to PATH_MAX so a malicious or buggy client + // can't amplify a multi-MB `cwd` body through this error. + const safeRequested = + requested.length > MAX_WORKSPACE_PATH_LENGTH + ? `${requested.slice(0, MAX_WORKSPACE_PATH_LENGTH)}…[truncated]` + : requested; + super( + `Workspace mismatch: daemon is bound to "${bound}" but ` + + `request asked for "${safeRequested}". Each \`qwen serve\` ` + + `daemon binds to exactly one workspace; start a separate ` + + `daemon for "${safeRequested}" (or route the request to one ` + + `via an orchestrator).`, + ); + this.name = 'WorkspaceMismatchError'; + this.bound = bound; + this.requested = safeRequested; + } +} + +/** + * Thrown when an HTTP caller echoes a client id that this daemon did not + * issue for the addressed live session. Create/attach calls may receive a + * fresh id instead; state-changing session routes reject unknown ids so + * originator metadata stays daemon-stamped rather than caller-asserted. + */ +export class InvalidClientIdError extends Error { + readonly sessionId: string; + readonly clientId: string; + constructor(sessionId: string, clientId: string) { + super(`Client id "${clientId}" is not registered for session ${sessionId}`); + this.name = 'InvalidClientIdError'; + this.sessionId = sessionId; + this.clientId = clientId; + } +} + +/** + * Thrown by `bridge.respondToPermission` when the voter's + * `optionId` isn't in the set of options the agent originally + * offered. Server route catches this and returns 400 (distinct from + * 404 unknown-requestId). + */ +export class InvalidPermissionOptionError extends Error { + readonly requestId: string; + readonly optionId: string; + constructor(requestId: string, optionId: string) { + super( + `Permission ${requestId}: optionId "${optionId}" is not in the ` + + `set of options the agent offered.`, + ); + this.name = 'InvalidPermissionOptionError'; + this.requestId = requestId; + this.optionId = optionId; + } +} + +export class InvalidSessionMetadataError extends Error { + readonly field: string; + constructor(field: string, reason: string) { + super(`Invalid session metadata: ${field} ${reason}`); + this.name = 'InvalidSessionMetadataError'; + this.field = field; + } +} + +/** + * #4175 Wave 4 PR 17. Thrown by `initWorkspace` when the target file + * already exists with non-whitespace content and the caller did not + * pass `force: true`. Translated to HTTP 409 by the route. The + * `path` and `existingSize` fields let SDK clients render a clear + * "file already exists; pass `force: true` to overwrite" prompt + * without re-stat'ing the workspace. + */ +export class WorkspaceInitConflictError extends Error { + readonly path: string; + readonly existingSize: number; + constructor(path: string, existingSize: number) { + super( + `Workspace file ${path} already exists ` + + `(${existingSize} bytes); pass {force: true} to overwrite.`, + ); + this.name = 'WorkspaceInitConflictError'; + this.path = path; + this.existingSize = existingSize; + } +} + +/** + * #4282 fold-in 1 (gpt-5.5 C5). Thrown by `restartMcpServer` when the + * caller asks for a server name that isn't in the daemon's + * `McpServers` config. Translated to HTTP 404 + structured body by + * the route — distinguishable from a generic 500 so a bad server + * name doesn't look like an internal daemon failure. + */ +export class McpServerNotFoundError extends Error { + readonly serverName: string; + constructor(serverName: string) { + super(`MCP server not configured: ${JSON.stringify(serverName)}`); + this.name = 'McpServerNotFoundError'; + this.serverName = serverName; + } +} + +/** + * #4282 fold-in 1 (gpt-5.5 C4). Thrown by `restartMcpServer` when + * `discoverMcpToolsForServer` resolves but the MCP client fails to + * end up `CONNECTED` post-discover. The manager catches reconnect + * errors and returns void, so without an explicit post-check the + * route would report `restarted: true` while the server stays + * disconnected. Translated to HTTP 502 + `errorKind: + * 'protocol_error'` by the route. + */ +export class McpServerRestartFailedError extends Error { + readonly serverName: string; + readonly mcpStatus: string; + constructor(serverName: string, mcpStatus: string) { + super( + `MCP server ${JSON.stringify(serverName)} did not reach a connected ` + + `state after restart (status: ${mcpStatus}).`, + ); + this.name = 'McpServerRestartFailedError'; + this.serverName = serverName; + this.mcpStatus = mcpStatus; + } +} diff --git a/packages/cli/src/serve/status.test.ts b/packages/acp-bridge/src/status.test.ts similarity index 100% rename from packages/cli/src/serve/status.test.ts rename to packages/acp-bridge/src/status.test.ts diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts new file mode 100644 index 00000000000..b9e0f022904 --- /dev/null +++ b/packages/acp-bridge/src/status.ts @@ -0,0 +1,600 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AvailableCommand } from '@agentclientprotocol/sdk'; +import { SkillError } from '@qwen-code/qwen-code-core'; + +export const STATUS_SCHEMA_VERSION = 1 as const; + +/** + * Closed enumeration of structured error categories surfaced on diagnostic + * status cells. Cells produced by `/workspace/preflight`, `/workspace/env`, + * and (eventually) the MCP guardrails route share this taxonomy so SDK + * consumers can branch on a known set rather than parsing free-form strings. + */ +export const SERVE_ERROR_KINDS = [ + 'missing_binary', + 'blocked_egress', + 'auth_env_error', + 'init_timeout', + 'protocol_error', + 'missing_file', + 'parse_error', + 'stat_failed', + // Issue #4175 PR 14: budget refusal under `--mcp-budget-mode=enforce`. + // Surfaced on per-server `mcp_server` cells (refused at discovery) + // and on the workspace-level `mcp_budget` cell (any refusal this pass). + 'budget_exhausted', +] as const; + +export type ServeErrorKind = (typeof SERVE_ERROR_KINDS)[number]; + +/** + * Typed timeout raised by `withTimeout` in the bridge. Lets the diagnostic + * mapping helper recognize init/heartbeat/extMethod timeouts via `instanceof` + * instead of regex-matching message strings. + */ +export class BridgeTimeoutError extends Error { + readonly label: string; + readonly timeoutMs: number; + constructor(label: string, timeoutMs: number) { + super(`HttpAcpBridge ${label} timed out after ${timeoutMs}ms`); + this.name = 'BridgeTimeoutError'; + this.label = label; + this.timeoutMs = timeoutMs; + } +} + +export const SERVE_STATUS_EXT_METHODS = { + workspaceMcp: 'qwen/status/workspace/mcp', + workspaceSkills: 'qwen/status/workspace/skills', + workspaceProviders: 'qwen/status/workspace/providers', + workspaceMemory: 'qwen/status/workspace/memory', + workspaceAgents: 'qwen/status/workspace/agents', + workspacePreflight: 'qwen/status/workspace/preflight', + sessionContext: 'qwen/status/session/context', + sessionSupportedCommands: 'qwen/status/session/supported_commands', +} as const; + +/** + * Control-plane (mutation) ACP extMethods introduced in #4175 Wave 4 PR 17. + * Distinct from `SERVE_STATUS_EXT_METHODS` so reviewers can grep mutation + * surface independently from read-only diagnostics. Each route in + * `server.ts` forwards through the matching extMethod into `acpAgent.ts` + * which then mutates Config / ToolRegistry / McpClientManager state. + */ +export const SERVE_CONTROL_EXT_METHODS = { + sessionApprovalMode: 'qwen/control/session/approval_mode', + workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', +} as const; + +export type ServeStatus = + | 'ok' + | 'warning' + | 'error' + | 'disabled' + | 'not_started' + | 'unknown'; + +export interface ServeStatusCell { + kind: string; + status: ServeStatus; + error?: string; + errorKind?: ServeErrorKind; + hint?: string; +} + +export type ServeMcpDiscoveryState = + | 'not_started' + | 'in_progress' + | 'completed'; + +export type ServeMcpServerRuntimeStatus = + | 'connected' + | 'connecting' + | 'disconnected'; + +export type ServeMcpTransport = + | 'stdio' + | 'sse' + | 'http' + | 'websocket' + | 'sdk' + | 'unknown'; + +export interface ServeWorkspaceMcpServerStatus extends ServeStatusCell { + kind: 'mcp_server'; + name: string; + mcpStatus?: ServeMcpServerRuntimeStatus; + transport: ServeMcpTransport; + disabled: boolean; + description?: string; + extensionName?: string; + /** + * Why this server is not live, when known. Distinguishes + * operator-disabled (`disabled: true` from `disabledMcpServers` + * config) from PR 14 budget-refused (`status: 'error', errorKind: + * 'budget_exhausted'`). Operators dashboarding the workspace + * shouldn't have to cross-reference the `errors[]` or `budgets[]` + * arrays to render a per-server row correctly. + */ + disabledReason?: 'config' | 'budget'; +} + +/** Budget mode for the MCP client guardrails (issue #4175 PR 14). */ +export type ServeMcpBudgetMode = 'enforce' | 'warn' | 'off'; + +/** + * Workspace-level budget status cell. Surfaced as one entry in + * `ServeWorkspaceMcpStatus.budgets[]`. The list shape (vs a single + * `budget?` field) is forward-compat for Wave 5 PR 23, which will + * add a `scope: 'pool'` cell alongside without a schema bump. + * + * Consumers MUST tolerate additional entries with unrecognized + * `scope` values — drop them rather than failing. + */ +export interface ServeMcpBudgetStatusCell extends ServeStatusCell { + kind: 'mcp_budget'; + /** + * Identifies which accounting scope this cell describes. + * + * **PR 14 v1 emits `'session'`** because each ACP session creates + * its own `Config`/`McpClientManager` via `acpAgent.newSessionConfig()` + * — so the budget caps live MCP clients **per session**, not + * per-workspace. The snapshot reflects the bootstrap session's + * view; concurrent sessions each enforce their own copy of the + * cap independently. See `qwen-serve-protocol.md` "PR 14 v1 + * scope: per-session" for the operator-facing rationale. + * + * Future PRs: + * - Wave 5 PR 23 (shared MCP pool) introduces a workspace-scoped + * manager and will emit `'workspace'` (or `'pool'`) cells. + * - The `string & {}` widening keeps IDE autocomplete + literal + * narrowing for known scopes while allowing unknown scopes + * through without a compile-time break — the protocol contract + * is "consumers MUST tolerate additional scope values, drop + * don't fail." + */ + scope: 'session' | 'workspace' | (string & {}); + /** Live (CONNECTED) MCP client count at snapshot time. */ + liveCount: number; + /** Configured cap (positive integer). Absent only when mode is `off`. */ + budget?: number; + /** Active enforcement mode. `off` mode produces no cell — `budgets: []`. */ + mode: ServeMcpBudgetMode; + /** Servers refused during the most recent discovery pass. */ + refusedCount: number; +} + +export interface ServeWorkspaceMcpStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + discoveryState?: ServeMcpDiscoveryState; + servers: ServeWorkspaceMcpServerStatus[]; + errors?: ServeStatusCell[]; + /** PR 14: live MCP client count (sum across all transports). */ + clientCount?: number; + /** PR 14: configured budget. Absent when no cap was set. */ + clientBudget?: number; + /** PR 14: active enforcement mode. Absent on pre-PR-14 daemons. */ + budgetMode?: ServeMcpBudgetMode; + /** + * PR 14: workspace-level status cells for budget enforcement. Always + * an array (possibly empty) on post-PR-14 daemons; absent on older + * daemons. PR 23 will add a `scope: 'pool'` cell alongside. + */ + budgets?: ServeMcpBudgetStatusCell[]; +} + +export type ServeSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; + +export interface ServeWorkspaceSkillStatus extends ServeStatusCell { + kind: 'skill'; + name: string; + description: string; + level: ServeSkillLevel; + modelInvocable: boolean; + argumentHint?: string; + model?: string; + extensionName?: string; +} + +export interface ServeWorkspaceSkillsStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + skills: ServeWorkspaceSkillStatus[]; + errors?: ServeStatusCell[]; +} + +export interface ServeWorkspaceProviderCurrent { + authType?: string; + modelId?: string; +} + +export interface ServeWorkspaceProviderModel { + modelId: string; + baseModelId: string; + name: string; + description?: string | null; + contextLimit?: number; + isCurrent: boolean; + isRuntime: boolean; +} + +export interface ServeWorkspaceProviderStatus extends ServeStatusCell { + kind: 'model_provider'; + authType: string; + current: boolean; + models: ServeWorkspaceProviderModel[]; +} + +export interface ServeWorkspaceProvidersStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + current?: ServeWorkspaceProviderCurrent; + providers: ServeWorkspaceProviderStatus[]; + errors?: ServeStatusCell[]; +} + +export interface ServeSessionContextStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + state: { + models?: unknown; + modes?: unknown; + configOptions?: unknown[] | null; + [key: string]: unknown; + }; +} + +export interface ServeSessionSupportedCommandsStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + availableCommands: AvailableCommand[]; + availableSkills: string[]; +} + +/** + * Issue #4175 PR 16: workspace memory + agents read surfaces. + * + * Both shapes mirror the `kind / status / error? / errorKind? / hint?` + * cell pattern that PR 12's mcp/skills/providers status structures use, + * so the SDK reducer can render any of these with one pattern. + */ + +export type ServeContextFileScope = 'workspace' | 'global'; + +export interface ServeWorkspaceMemoryFile { + kind: 'memory_file'; + /** Absolute path to the discovered memory file. */ + path: string; + /** + * 'workspace' for files under the bound workspace tree, 'global' for + * `~/.qwen/QWEN.md` style entries. Helps adapters render scope chips. + */ + scope: ServeContextFileScope; + /** Size in bytes of the file's serialized contents on disk. */ + bytes: number; +} + +export interface ServeWorkspaceMemoryStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + initialized: boolean; + files: ServeWorkspaceMemoryFile[]; + /** Total bytes across all hierarchical files (sum of `files[].bytes`). */ + totalBytes: number; + /** + * Number of merged QWEN.md / AGENTS.md files the loader pulled in. + * Mirrors `LoadServerHierarchicalMemoryResponse.fileCount`. + */ + fileCount: number; + /** Baseline path-rule count from `.qwen/rules/`. */ + ruleCount: number; + errors?: ServeStatusCell[]; +} + +/** + * Storage level for a subagent definition surfaced through + * `GET /workspace/agents` and the per-`agentType` detail route. + * + * `project` / `user` / `builtin` are the values the daemon actually + * returns today. `extension` and `session` are forward-compat slots: + * the daemon-scoped `SubagentManager` runs against a stub `Config` + * whose `getActiveExtensions()` returns `[]`, and session-level + * subagents live in a runtime-only cache no CRUD route reads. + * Mirrors `DaemonAgentLevel` in `@qwen-code/sdk` so route + SDK + * consumers see the same forward-compat union. + */ +export type ServeAgentLevel = + | 'project' + | 'user' + | 'builtin' + | 'extension' + | 'session'; + +export interface ServeWorkspaceAgentSummary { + kind: 'agent'; + name: string; + description: string; + level: ServeAgentLevel; + isBuiltin: boolean; + /** Whether this agent restricts the tool set via `tools:` frontmatter. */ + hasTools: boolean; + model?: string; + color?: string; + background?: boolean; + approvalMode?: string; + extensionName?: string; + /** Absolute path to the file backing this agent (or sentinel for built-ins). */ + filePath?: string; +} + +export interface ServeWorkspaceAgentDetail extends ServeWorkspaceAgentSummary { + systemPrompt: string; + tools?: string[]; + disallowedTools?: string[]; + runConfig?: { max_time_minutes?: number; max_turns?: number }; +} + +export interface ServeWorkspaceAgentsStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + agents: ServeWorkspaceAgentSummary[]; + errors?: ServeStatusCell[]; +} + +export function createIdleWorkspaceMemoryStatus( + workspaceCwd: string, +): ServeWorkspaceMemoryStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + files: [], + totalBytes: 0, + fileCount: 0, + ruleCount: 0, + }; +} + +export function createIdleWorkspaceAgentsStatus( + workspaceCwd: string, +): ServeWorkspaceAgentsStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + agents: [], + }; +} + +export function createIdleWorkspaceMcpStatus( + workspaceCwd: string, +): ServeWorkspaceMcpStatus { + // PR 14: an idle workspace has zero live clients and no enforcement + // pressure. `budgetMode` is `'off'` (regardless of how the operator + // configured it) because no discovery has run, so no reservation + // could have happened. `budgets` is an empty array, not absent — + // the daemon DOES support the surface, the snapshot just has + // nothing to report yet. Older daemons omitting the array entirely + // are still spec-compliant; consumers default-coalesce to `[]`. + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + discoveryState: 'not_started', + servers: [], + clientCount: 0, + budgetMode: 'off', + budgets: [], + }; +} + +export function createIdleWorkspaceSkillsStatus( + workspaceCwd: string, +): ServeWorkspaceSkillsStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + skills: [], + }; +} + +export function createIdleWorkspaceProvidersStatus( + workspaceCwd: string, +): ServeWorkspaceProvidersStatus { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + providers: [], + }; +} + +/** + * Discriminant for diagnostic cells emitted by `/workspace/env`. + * `env_var` cells are presence-only (the daemon never echoes secret values + * even when redacted). The other kinds expose non-sensitive values like + * runtime tag, platform, redacted proxy host, and sandbox profile name. + */ +export type ServeEnvKind = + | 'runtime' + | 'platform' + | 'sandbox' + | 'proxy' + | 'env_var'; + +export interface ServeEnvCell extends ServeStatusCell { + kind: ServeEnvKind; + /** Stable identifier within the kind (e.g. env-var name, proxy var name). */ + name: string; + present?: boolean; + /** Non-sensitive value; ALWAYS omitted for kind='env_var'. */ + value?: string; +} + +export interface ServeWorkspaceEnvStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + /** Always true — the daemon answers env without consulting ACP. */ + initialized: true; + /** Whether an ACP channel is currently live; informational only. */ + acpChannelLive: boolean; + cells: ServeEnvCell[]; + errors?: ServeStatusCell[]; +} + +/** + * Discriminant for diagnostic cells emitted by `/workspace/preflight`. Cells + * with `locality: 'daemon'` are answered by the bridge process directly and + * are always populated. Cells with `locality: 'acp'` require a live ACP child + * — when the daemon is idle they are emitted with `status: 'not_started'`. + */ +export type ServePreflightKind = + | 'node_version' + | 'cli_entry' + | 'workspace_dir' + | 'ripgrep' + | 'git' + | 'npm' + | 'auth' + | 'mcp_discovery' + | 'skills' + | 'providers' + | 'tool_registry' + | 'egress'; + +export interface ServePreflightCell extends ServeStatusCell { + kind: ServePreflightKind; + locality: 'daemon' | 'acp'; + /** Free-form structured detail (versions, counts, etc.). Never carries secret values. */ + detail?: Record; +} + +export interface ServeWorkspacePreflightStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + /** Always true — daemon-level cells are populated regardless of ACP state. */ + initialized: true; + acpChannelLive: boolean; + cells: ServePreflightCell[]; + errors?: ServeStatusCell[]; +} + +/** + * The six preflight kinds that require a live ACP child to populate. Shared + * between `createIdleAcpPreflightCells` (idle placeholder) and the + * ACP-side `buildAcpPreflightCells` builder so the two sides cannot drift + * — a future contributor adding a new ACP kind in one place sees the + * other surface immediately. + */ +export const ACP_PREFLIGHT_KINDS = [ + 'auth', + 'mcp_discovery', + 'skills', + 'providers', + 'tool_registry', + 'egress', +] as const satisfies readonly ServePreflightKind[]; + +/** + * The narrow union of ACP-locality preflight kinds. Useful for callers + * that need to dispatch on every ACP kind exhaustively (e.g. the + * `Record` builder map in `acpAgent.ts`). + */ +export type AcpPreflightKind = (typeof ACP_PREFLIGHT_KINDS)[number]; + +/** + * Idle ACP cells: emitted when the daemon has no live ACP child. The bridge + * stitches these in alongside its daemon-level cells so `/workspace/preflight` + * always returns a complete cell set without spawning a child. + */ +export function createIdleAcpPreflightCells(): ServePreflightCell[] { + return ACP_PREFLIGHT_KINDS.map((kind) => ({ + kind, + status: 'not_started' as const, + locality: 'acp' as const, + hint: 'spawn a session to populate', + })); +} + +const SKILL_PARSE_CODES: ReadonlySet = new Set([ + 'PARSE_ERROR', + 'INVALID_CONFIG', + 'INVALID_NAME', +]); + +const SKILL_FILE_CODES: ReadonlySet = new Set([ + 'FILE_ERROR', + 'NOT_FOUND', +]); + +const FS_MISSING_CODES: ReadonlySet = new Set([ + 'ENOENT', + 'EACCES', + 'EPERM', +]); + +// `ModelConfigError` subclasses live inside core's models module and are not +// re-exported on the public package surface. We classify them by the `name` +// field that each subclass sets via `this.name = new.target.name`. +const MODEL_CONFIG_ERROR_NAMES: ReadonlySet = new Set([ + 'StrictMissingCredentialsError', + 'StrictMissingModelIdError', + 'MissingApiKeyError', + 'MissingModelError', + 'MissingBaseUrlError', + 'MissingAnthropicBaseUrlEnvError', +]); + +/** + * Map a thrown domain error onto one of the closed `ServeErrorKind` literals + * so diagnostic cells can render structured remediation. Recognition is + * `instanceof`-first; message-string heuristics are a last-resort fallback for + * legacy throw sites that have not yet been retyped. + * + * Returns `undefined` when no rule matches; callers should leave `errorKind` + * unset rather than coercing an unrelated error into a misleading category. + */ +export function mapDomainErrorToErrorKind( + err: unknown, +): ServeErrorKind | undefined { + if (err instanceof BridgeTimeoutError) return 'init_timeout'; + if (err instanceof SkillError) { + if (SKILL_PARSE_CODES.has(err.code)) return 'parse_error'; + if (SKILL_FILE_CODES.has(err.code)) return 'missing_file'; + return undefined; + } + if (err instanceof SyntaxError) return 'parse_error'; + if (!(err instanceof Error)) return undefined; + // `TrustGateError` is defined in `@qwen-code/qwen-code-core/config`; we + // match by `.name` rather than `instanceof` because cross-package bundling + // can produce duplicate class instances where `instanceof` returns false. + if (err.name === 'TrustGateError') return 'auth_env_error'; + if (MODEL_CONFIG_ERROR_NAMES.has(err.name)) return 'auth_env_error'; + const code = (err as { code?: unknown }).code; + if (typeof code === 'string' && FS_MISSING_CODES.has(code)) { + return 'missing_file'; + } + // TODO(follow-up): convert the two throw sites that produce these + // messages (`getChannelClosedReject` in `httpAcpBridge.ts` and the + // `defaultSpawnChannelFactory` "Cannot determine CLI entry path" Error) + // to typed classes (`BridgeChannelClosedError`, `MissingCliEntryError`) + // and replace the regex match with `instanceof`. Until then a foreign + // error message that happens to contain either phrase will misclassify; + // the false-positive surface is small (the phrases are bridge-specific) + // but the cleaner fix belongs in the same wave as PR 22's bridge + // extraction. + const msg = err.message; + if (/agent channel closed/i.test(msg)) return 'protocol_error'; + if (/Cannot determine CLI entry path/i.test(msg)) return 'missing_binary'; + return undefined; +} diff --git a/packages/acp-bridge/src/workspacePaths.ts b/packages/acp-bridge/src/workspacePaths.ts new file mode 100644 index 00000000000..adf25025394 --- /dev/null +++ b/packages/acp-bridge/src/workspacePaths.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { realpathSync } from 'node:fs'; +import * as path from 'node:path'; + +/** + * Canonicalize a workspace path so the boot-time bound path and every + * request's `workspaceCwd` collapse to the same key. `path.resolve` + * alone normalizes `..` and `.` segments and absolutizes, but on + * case-insensitive filesystems (macOS APFS, Windows NTFS) `/Work/A` + * and `/work/a` are the same directory yet `resolve` returns them + * verbatim — without normalization the `boundWorkspace` check would + * reject every request that spelled the path with different casing + * and `sessionScope: 'single'` re-attach would silently degrade to + * "one per spelling". + * + * `realpathSync.native` (when the path exists) walks symlinks and returns + * the on-disk casing; this matches what `config.ts` / `settings.ts` / + * `sandbox.ts` use for their own workspace resolution. When the path + * doesn't exist (test fixtures, ahead-of-mkdir flows) we fall back to + * the resolved-but-uncanonicalized form rather than throwing — the + * downstream `spawn({cwd})` will fail with a useful ENOENT if the + * workspace truly doesn't exist. + * + * NOTE: This is a **cross-module contract** — `config.ts`, + * `settings.ts`, `sandbox.ts`, and the bridge layer all need to + * canonicalize the same way for the bound-workspace check + + * `sessionScope: 'single'` re-attach to work correctly across paths. + * The contract: use `realpathSync.native` on the resolved absolute + * path; fall back to `path.resolve` only when the path doesn't exist + * yet. + * + * Lifted to `@qwen-code/acp-bridge` in #4175 PR 22b so the bridge + * package owns the cross-module primitive directly. + * `cli/src/serve/fs/paths.ts` re-exports for callers still pointing + * at the original location. + */ +export function canonicalizeWorkspace(p: string): string { + const resolved = path.resolve(p); + try { + // FIXME(stage-2): switch to `fs.promises.realpath` once the + // bridge call sites become async-friendly. This sync syscall + // runs on the hot `spawnOrAttach` path and blocks the event + // loop for one filesystem stat per call. Single-user loopback + // (Stage 1's design target) doesn't notice; high-concurrency + // deployments will. Stage 2 in-process refactor removes the + // entire bridge-side path resolution anyway, but if Stage 2 + // ever lands without that change, switch to the async version. + return realpathSync.native(resolved); + } catch (err) { + // Only fall back to path.resolve for ENOENT (path doesn't exist + // yet). Other filesystem errors (EACCES, EIO, ELOOP) should + // propagate — swallowing them would hide transient I/O failures + // behind misleading workspace_mismatch rejections. + if ( + err && + typeof err === 'object' && + (err as { code?: unknown }).code === 'ENOENT' + ) { + return resolved; + } + throw err; + } +} + +/** + * PATH_MAX on Linux is 4096; macOS / BSD is 1024. We use the Linux + * value as a generous ceiling — anything bigger is either a + * malformed client request (memory amplification attack against the + * 400 / stderr / error-message echo paths) or a synthetic test + * input. The HTTP route's POST /session pre-check rejects bodies past + * this; `WorkspaceMismatchError` truncates for any caller that + * skips the pre-check. + */ +export const MAX_WORKSPACE_PATH_LENGTH = 4096; diff --git a/packages/acp-bridge/tsconfig.json b/packages/acp-bridge/tsconfig.json index 6b9d76f9e15..4e821948804 100644 --- a/packages/acp-bridge/tsconfig.json +++ b/packages/acp-bridge/tsconfig.json @@ -3,10 +3,16 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", + "baseUrl": ".", "lib": ["ES2023"], "composite": true, - "types": ["node", "vitest/globals"] + "types": ["node", "vitest/globals"], + "paths": { + "@qwen-code/qwen-code-core": ["../core/src/index.ts"], + "@qwen-code/qwen-code-core/*": ["../core/src/*"] + } }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }] } diff --git a/packages/cli/src/serve/fs/paths.ts b/packages/cli/src/serve/fs/paths.ts index 479e856854a..12cf3771430 100644 --- a/packages/cli/src/serve/fs/paths.ts +++ b/packages/cli/src/serve/fs/paths.ts @@ -4,97 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { promises as fsp, realpathSync } from 'node:fs'; +import { promises as fsp } from 'node:fs'; import * as path from 'node:path'; import { isWithinRoot } from '@qwen-code/qwen-code-core'; import { FsError, type FsErrorKind } from './errors.js'; -/** - * Canonicalize a workspace path so the boot-time bound path and every - * request's `workspaceCwd` collapse to the same key. `path.resolve` - * alone normalizes `..` and `.` segments and absolutizes, but on - * case-insensitive filesystems (macOS APFS, Windows NTFS) `/Work/A` - * and `/work/a` are the same directory yet `resolve` returns them - * verbatim — without normalization the `boundWorkspace` check would - * reject every request that spelled the path with different casing - * and `sessionScope: 'single'` re-attach would silently degrade to - * "one per spelling". - * - * `realpathSync.native` (when the path exists) walks symlinks and returns - * the on-disk casing; this matches what `config.ts` / `settings.ts` / - * `sandbox.ts` use for their own workspace resolution. When the path - * doesn't exist (test fixtures, ahead-of-mkdir flows) we fall back to - * the resolved-but-uncanonicalized form rather than throwing — the - * downstream `spawn({cwd})` will fail with a useful ENOENT if the - * workspace truly doesn't exist. - * - * NOTE: This is a **cross-module contract** (BX9_q) — `config.ts`, - * `settings.ts`, `sandbox.ts`, and `httpAcpBridge.ts` all need to - * canonicalize the same way for the bound-workspace check + - * `sessionScope: 'single'` re-attach to work correctly across paths. - * The contract: use `realpathSync.native` on the resolved absolute - * path; fall back to `path.resolve` only when the path doesn't exist - * yet. If a future change breaks this alignment (e.g. one module - * starts lowercasing on Windows but this one doesn't), the - * canonicalized request path won't match the canonicalized bound - * path → every request returns `workspace_mismatch` even though the - * human-readable paths look equivalent. There's no test that pins - * the alignment; the integration suite would catch a divergence only - * if it tested the specific casing / symlink path the affected - * module changed. - * - * Stage 2 in-process (#3803 §10) collapses the bridge into core, - * removing the bridge-side path resolution entirely. Stage 1.5 - * `@qwen-code/acp-bridge` lift (chiga0 finding 1) is the natural - * place to extract a shared `canonicalizeWorkspace` primitive that - * all four modules consume — the lowest-common-denominator - * extraction is fine THERE because the package boundary forces the - * call sites to converge. Until then, *any* change to how those - * modules resolve workspace paths needs a matching change here. - * - * #4175 PR 18 extraction: this file is the new home of the primitive - * for the serve layer. The bridge re-exports it so existing callers - * continue to import from `httpAcpBridge.js`. The forthcoming - * `WorkspaceFileSystem` boundary (PR 18 commits 3+) builds on top of - * this single resolver. - */ -export function canonicalizeWorkspace(p: string): string { - const resolved = path.resolve(p); - try { - // FIXME(stage-2): switch to `fs.promises.realpath` once the - // bridge call sites become async-friendly. This sync syscall - // runs on the hot `spawnOrAttach` path and blocks the event - // loop for one filesystem stat per call. Single-user loopback - // (Stage 1's design target) doesn't notice; high-concurrency - // deployments will. Stage 2 in-process refactor removes the - // entire bridge-side path resolution anyway, but if Stage 2 - // ever lands without that change, switch to the async version. - // - // Note for the resolveWithinWorkspace fast-path: - // `CANONICAL_BOUND_CACHE` (defined later in this file) memoizes - // the canonical mapping per `boundWorkspace` string. Under the - // `1 daemon = 1 workspace` model the cache is hit 100% of the - // time after the factory's boot-time canonicalization, so the - // sync syscall in steady state is **zero per request** — - // event-loop blocking only happens at boot or whenever a fresh - // `boundWorkspace` value first appears (e.g. tests sharing a - // module instance across `mkdtemp` workspaces). - return realpathSync.native(resolved); - } catch (err) { - // Only fall back to path.resolve for ENOENT (path doesn't exist - // yet). Other filesystem errors (EACCES, EIO, ELOOP) should - // propagate — swallowing them would hide transient I/O failures - // behind misleading workspace_mismatch rejections. - if ( - err && - typeof err === 'object' && - (err as { code?: unknown }).code === 'ENOENT' - ) { - return resolved; - } - throw err; - } -} +// `canonicalizeWorkspace` and `MAX_WORKSPACE_PATH_LENGTH` lifted to +// `@qwen-code/acp-bridge` in #4175 PR 22b — the bridge package owns the +// cross-module workspace-canonicalization contract directly. Imported +// here for the local `canonicalizeBoundWorkspaceCached` fast-path AND +// re-exported so callers like `config.ts` / `settings.ts` / +// `sandbox.ts` and the file-system service keep importing from +// `./serve/fs/paths.js` without churn. +import { + canonicalizeWorkspace, + MAX_WORKSPACE_PATH_LENGTH, +} from '@qwen-code/acp-bridge/workspacePaths'; +export { canonicalizeWorkspace, MAX_WORKSPACE_PATH_LENGTH }; /** * Branded absolute path that has passed the workspace boundary check. diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts index c659c7174a5..5cdd5a28c7b 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -623,143 +623,40 @@ export interface HttpAcpBridge { * Routes catch this to map to HTTP 404. Distinct from generic Error so the * route layer doesn't have to brittle-match on message text. */ -export class SessionNotFoundError extends Error { - readonly sessionId: string; - constructor(sessionId: string, extra?: string) { - super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : '')); - this.name = 'SessionNotFoundError'; - this.sessionId = sessionId; - } -} - -export class RestoreInProgressError extends Error { - readonly sessionId: string; - readonly activeAction: 'load' | 'resume'; - readonly requestedAction: 'load' | 'resume'; - - constructor( - sessionId: string, - activeAction: 'load' | 'resume', - requestedAction: 'load' | 'resume', - ) { - super( - `Session "${sessionId}" is already being restored via session/${activeAction}; retry session/${requestedAction} after it completes`, - ); - this.name = 'RestoreInProgressError'; - this.sessionId = sessionId; - this.activeAction = activeAction; - this.requestedAction = requestedAction; - } -} - -/** - * Thrown by `spawnOrAttach` when `req.sessionScope` is set to a value - * outside the `'single' | 'thread'` enum. The HTTP route validates the - * body field at the boundary first (so HTTP callers get a typed - * `400 invalid_session_scope` before ever reaching the bridge); this - * class exists for direct callers — tests, embeds, future entry points - * — and so the route's catch-block can translate it back to the same - * 400 shape rather than the generic 500 every other thrown `Error` - * collapses to. Distinct type so routes can branch without - * text-matching the message. - */ -export class InvalidSessionScopeError extends Error { - readonly sessionScope: unknown; - constructor(sessionScope: unknown) { - super( - `Invalid sessionScope: ${JSON.stringify(sessionScope)}. ` + - `Expected 'single' or 'thread'.`, - ); - this.name = 'InvalidSessionScopeError'; - this.sessionScope = sessionScope; - } -} - -/** - * Thrown by `spawnOrAttach` when a fresh-spawn would push `sessionCount` - * past `BridgeOptions.maxSessions`. The HTTP route maps this to 503 - * with a `Retry-After` hint. Attaches (same workspace under `single` - * scope) never trip this — only NEW children. Distinct error type so - * routes can branch without text-matching. - */ -export class SessionLimitExceededError extends Error { - readonly limit: number; - constructor(limit: number) { - super(`Session limit reached (${limit})`); - this.name = 'SessionLimitExceededError'; - this.limit = limit; - } -} - -/** - * Thrown by `spawnOrAttach` when the requested `workspaceCwd` doesn't - * canonicalize to the daemon's bound workspace. Per #3803 §02 every - * bridge instance is bound to exactly one workspace; cross-workspace - * requests are rejected at the daemon boundary. The server route - * translates this to a 400 response with `code: 'workspace_mismatch'` - * and both paths in the body so clients can fall through to spawning - * their own daemon / routing to a different one via an orchestrator. - */ -export class WorkspaceMismatchError extends Error { - readonly bound: string; - readonly requested: string; - constructor(bound: string, requested: string) { - // Truncate `requested` to PATH_MAX so a malicious or buggy client - // can't amplify a multi-MB `cwd` body through this error. The - // constructor interpolates `requested` into `.message` TWICE, the - // route's `sendBridgeError` echoes it in stderr (now JSON.stringify - // -wrapped per the log-injection fix), and `res.json` echoes it in - // the 400 body — without truncation a ~10 MB cwd (right under the - // `express.json({limit: '10mb'})` cap) becomes ~20 MB message + - // ~10 MB stderr + ~30 MB JSON response per request, × - // `maxConnections` (default 256). The route also caps `cwd.length` - // at this same limit upstream (POST /session); this is - // defense-in-depth for non-HTTP callers (tests, embeds, future - // entry points that throw the error directly). - const safeRequested = - requested.length > MAX_WORKSPACE_PATH_LENGTH - ? `${requested.slice(0, MAX_WORKSPACE_PATH_LENGTH)}…[truncated]` - : requested; - super( - `Workspace mismatch: daemon is bound to "${bound}" but ` + - `request asked for "${safeRequested}". Each \`qwen serve\` ` + - `daemon binds to exactly one workspace; start a separate ` + - `daemon for "${safeRequested}" (or route the request to one ` + - `via an orchestrator).`, - ); - this.name = 'WorkspaceMismatchError'; - this.bound = bound; - this.requested = safeRequested; - } -} - -/** - * Thrown when an HTTP caller echoes a client id that this daemon did not - * issue for the addressed live session. Create/attach calls may receive a - * fresh id instead; state-changing session routes reject unknown ids so - * originator metadata stays daemon-stamped rather than caller-asserted. - */ -export class InvalidClientIdError extends Error { - readonly sessionId: string; - readonly clientId: string; - constructor(sessionId: string, clientId: string) { - super(`Client id "${clientId}" is not registered for session ${sessionId}`); - this.name = 'InvalidClientIdError'; - this.sessionId = sessionId; - this.clientId = clientId; - } -} - -/** - * PATH_MAX on Linux is 4096; macOS / BSD is 1024. We use the Linux - * value as a generous ceiling — anything bigger is either a - * malformed client request (memory amplification attack against the - * 400 / stderr / error-message echo paths) or a synthetic test - * input. The route's POST /session pre-check rejects bodies past - * this; `WorkspaceMismatchError` truncates for any caller that - * skips the pre-check. - */ -export const MAX_WORKSPACE_PATH_LENGTH = 4096; +// Bridge errors lifted to `@qwen-code/acp-bridge/bridgeErrors` in +// #4175 PR 22b. `MAX_WORKSPACE_PATH_LENGTH` lifted to +// `@qwen-code/acp-bridge/workspacePaths` in the same slice. +// Imported AND re-exported so existing relative callers (server.ts:31, +// workspaceAgents.ts, workspaceMemory.ts) keep resolving and the local +// factory code below can still construct + throw these errors. +import { + SessionNotFoundError, + RestoreInProgressError, + InvalidSessionScopeError, + SessionLimitExceededError, + WorkspaceMismatchError, + InvalidClientIdError, + InvalidPermissionOptionError, + InvalidSessionMetadataError, + WorkspaceInitConflictError, + McpServerNotFoundError, + McpServerRestartFailedError, +} from '@qwen-code/acp-bridge/bridgeErrors'; +import { MAX_WORKSPACE_PATH_LENGTH } from '@qwen-code/acp-bridge/workspacePaths'; +export { + SessionNotFoundError, + RestoreInProgressError, + InvalidSessionScopeError, + SessionLimitExceededError, + WorkspaceMismatchError, + InvalidClientIdError, + InvalidPermissionOptionError, + InvalidSessionMetadataError, + WorkspaceInitConflictError, + McpServerNotFoundError, + McpServerRestartFailedError, + MAX_WORKSPACE_PATH_LENGTH, +}; // `AcpChannel` / `AcpChannelExitInfo` / `ChannelFactory` were lifted to // `@qwen-code/acp-bridge` in #4175 PR 22a so `channels/base/AcpBridge.ts` @@ -1152,25 +1049,9 @@ function writeServeDebugLine(message: string): void { writeStderrLine(`qwen serve debug: ${message}`); } -/** - * BkwQI: thrown by `bridge.respondToPermission` when the voter's - * `optionId` isn't in the set of options the agent originally - * offered. Server route catches this and returns 400 (distinct from - * 404 unknown-requestId). - */ -export class InvalidPermissionOptionError extends Error { - readonly requestId: string; - readonly optionId: string; - constructor(requestId: string, optionId: string) { - super( - `Permission ${requestId}: optionId "${optionId}" is not in the ` + - `set of options the agent offered.`, - ); - this.name = 'InvalidPermissionOptionError'; - this.requestId = requestId; - this.optionId = optionId; - } -} +// `InvalidPermissionOptionError` lifted to +// `@qwen-code/acp-bridge/bridgeErrors` in #4175 PR 22b — see +// the consolidated re-export block earlier in this file. const MAX_DISPLAY_NAME_LENGTH = 256; @@ -1206,75 +1087,10 @@ function hasControlCharacter(value: string): boolean { return false; } -export class InvalidSessionMetadataError extends Error { - readonly field: string; - constructor(field: string, reason: string) { - super(`Invalid session metadata: ${field} ${reason}`); - this.name = 'InvalidSessionMetadataError'; - this.field = field; - } -} - -/** - * #4175 Wave 4 PR 17. Thrown by `initWorkspace` when the target file - * already exists with non-whitespace content and the caller did not - * pass `force: true`. Translated to HTTP 409 by the route. The - * `path` and `existingSize` fields let SDK clients render a clear - * "file already exists; pass `force: true` to overwrite" prompt - * without re-stat'ing the workspace. - */ -export class WorkspaceInitConflictError extends Error { - readonly path: string; - readonly existingSize: number; - constructor(path: string, existingSize: number) { - super( - `Workspace file ${path} already exists ` + - `(${existingSize} bytes); pass {force: true} to overwrite.`, - ); - this.name = 'WorkspaceInitConflictError'; - this.path = path; - this.existingSize = existingSize; - } -} - -/** - * #4282 fold-in 1 (gpt-5.5 C5). Thrown by `restartMcpServer` when the - * caller asks for a server name that isn't in the daemon's - * `McpServers` config. Translated to HTTP 404 + structured body by - * the route — distinguishable from a generic 500 so a bad server - * name doesn't look like an internal daemon failure. - */ -export class McpServerNotFoundError extends Error { - readonly serverName: string; - constructor(serverName: string) { - super(`MCP server not configured: ${JSON.stringify(serverName)}`); - this.name = 'McpServerNotFoundError'; - this.serverName = serverName; - } -} - -/** - * #4282 fold-in 1 (gpt-5.5 C4). Thrown by `restartMcpServer` when - * `discoverMcpToolsForServer` resolves but the MCP client fails to - * end up `CONNECTED` post-discover. The manager catches reconnect - * errors and returns void, so without an explicit post-check the - * route would report `restarted: true` while the server stays - * disconnected. Translated to HTTP 502 + `errorKind: - * 'protocol_error'` by the route. - */ -export class McpServerRestartFailedError extends Error { - readonly serverName: string; - readonly mcpStatus: string; - constructor(serverName: string, mcpStatus: string) { - super( - `MCP server ${JSON.stringify(serverName)} did not reach a connected ` + - `state after restart (status: ${mcpStatus}).`, - ); - this.name = 'McpServerRestartFailedError'; - this.serverName = serverName; - this.mcpStatus = mcpStatus; - } -} +// `InvalidSessionMetadataError`, `WorkspaceInitConflictError`, +// `McpServerNotFoundError`, `McpServerRestartFailedError` lifted to +// `@qwen-code/acp-bridge/bridgeErrors` in #4175 PR 22b — see the +// consolidated re-export block earlier in this file. /** * Bridge `Client` implementation — the daemon's response surface for things diff --git a/packages/cli/src/serve/status.ts b/packages/cli/src/serve/status.ts index b9e0f022904..0c57bd5660d 100644 --- a/packages/cli/src/serve/status.ts +++ b/packages/cli/src/serve/status.ts @@ -4,597 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AvailableCommand } from '@agentclientprotocol/sdk'; -import { SkillError } from '@qwen-code/qwen-code-core'; - -export const STATUS_SCHEMA_VERSION = 1 as const; - -/** - * Closed enumeration of structured error categories surfaced on diagnostic - * status cells. Cells produced by `/workspace/preflight`, `/workspace/env`, - * and (eventually) the MCP guardrails route share this taxonomy so SDK - * consumers can branch on a known set rather than parsing free-form strings. - */ -export const SERVE_ERROR_KINDS = [ - 'missing_binary', - 'blocked_egress', - 'auth_env_error', - 'init_timeout', - 'protocol_error', - 'missing_file', - 'parse_error', - 'stat_failed', - // Issue #4175 PR 14: budget refusal under `--mcp-budget-mode=enforce`. - // Surfaced on per-server `mcp_server` cells (refused at discovery) - // and on the workspace-level `mcp_budget` cell (any refusal this pass). - 'budget_exhausted', -] as const; - -export type ServeErrorKind = (typeof SERVE_ERROR_KINDS)[number]; - -/** - * Typed timeout raised by `withTimeout` in the bridge. Lets the diagnostic - * mapping helper recognize init/heartbeat/extMethod timeouts via `instanceof` - * instead of regex-matching message strings. - */ -export class BridgeTimeoutError extends Error { - readonly label: string; - readonly timeoutMs: number; - constructor(label: string, timeoutMs: number) { - super(`HttpAcpBridge ${label} timed out after ${timeoutMs}ms`); - this.name = 'BridgeTimeoutError'; - this.label = label; - this.timeoutMs = timeoutMs; - } -} - -export const SERVE_STATUS_EXT_METHODS = { - workspaceMcp: 'qwen/status/workspace/mcp', - workspaceSkills: 'qwen/status/workspace/skills', - workspaceProviders: 'qwen/status/workspace/providers', - workspaceMemory: 'qwen/status/workspace/memory', - workspaceAgents: 'qwen/status/workspace/agents', - workspacePreflight: 'qwen/status/workspace/preflight', - sessionContext: 'qwen/status/session/context', - sessionSupportedCommands: 'qwen/status/session/supported_commands', -} as const; - -/** - * Control-plane (mutation) ACP extMethods introduced in #4175 Wave 4 PR 17. - * Distinct from `SERVE_STATUS_EXT_METHODS` so reviewers can grep mutation - * surface independently from read-only diagnostics. Each route in - * `server.ts` forwards through the matching extMethod into `acpAgent.ts` - * which then mutates Config / ToolRegistry / McpClientManager state. - */ -export const SERVE_CONTROL_EXT_METHODS = { - sessionApprovalMode: 'qwen/control/session/approval_mode', - workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', -} as const; - -export type ServeStatus = - | 'ok' - | 'warning' - | 'error' - | 'disabled' - | 'not_started' - | 'unknown'; - -export interface ServeStatusCell { - kind: string; - status: ServeStatus; - error?: string; - errorKind?: ServeErrorKind; - hint?: string; -} - -export type ServeMcpDiscoveryState = - | 'not_started' - | 'in_progress' - | 'completed'; - -export type ServeMcpServerRuntimeStatus = - | 'connected' - | 'connecting' - | 'disconnected'; - -export type ServeMcpTransport = - | 'stdio' - | 'sse' - | 'http' - | 'websocket' - | 'sdk' - | 'unknown'; - -export interface ServeWorkspaceMcpServerStatus extends ServeStatusCell { - kind: 'mcp_server'; - name: string; - mcpStatus?: ServeMcpServerRuntimeStatus; - transport: ServeMcpTransport; - disabled: boolean; - description?: string; - extensionName?: string; - /** - * Why this server is not live, when known. Distinguishes - * operator-disabled (`disabled: true` from `disabledMcpServers` - * config) from PR 14 budget-refused (`status: 'error', errorKind: - * 'budget_exhausted'`). Operators dashboarding the workspace - * shouldn't have to cross-reference the `errors[]` or `budgets[]` - * arrays to render a per-server row correctly. - */ - disabledReason?: 'config' | 'budget'; -} - -/** Budget mode for the MCP client guardrails (issue #4175 PR 14). */ -export type ServeMcpBudgetMode = 'enforce' | 'warn' | 'off'; - -/** - * Workspace-level budget status cell. Surfaced as one entry in - * `ServeWorkspaceMcpStatus.budgets[]`. The list shape (vs a single - * `budget?` field) is forward-compat for Wave 5 PR 23, which will - * add a `scope: 'pool'` cell alongside without a schema bump. - * - * Consumers MUST tolerate additional entries with unrecognized - * `scope` values — drop them rather than failing. - */ -export interface ServeMcpBudgetStatusCell extends ServeStatusCell { - kind: 'mcp_budget'; - /** - * Identifies which accounting scope this cell describes. - * - * **PR 14 v1 emits `'session'`** because each ACP session creates - * its own `Config`/`McpClientManager` via `acpAgent.newSessionConfig()` - * — so the budget caps live MCP clients **per session**, not - * per-workspace. The snapshot reflects the bootstrap session's - * view; concurrent sessions each enforce their own copy of the - * cap independently. See `qwen-serve-protocol.md` "PR 14 v1 - * scope: per-session" for the operator-facing rationale. - * - * Future PRs: - * - Wave 5 PR 23 (shared MCP pool) introduces a workspace-scoped - * manager and will emit `'workspace'` (or `'pool'`) cells. - * - The `string & {}` widening keeps IDE autocomplete + literal - * narrowing for known scopes while allowing unknown scopes - * through without a compile-time break — the protocol contract - * is "consumers MUST tolerate additional scope values, drop - * don't fail." - */ - scope: 'session' | 'workspace' | (string & {}); - /** Live (CONNECTED) MCP client count at snapshot time. */ - liveCount: number; - /** Configured cap (positive integer). Absent only when mode is `off`. */ - budget?: number; - /** Active enforcement mode. `off` mode produces no cell — `budgets: []`. */ - mode: ServeMcpBudgetMode; - /** Servers refused during the most recent discovery pass. */ - refusedCount: number; -} - -export interface ServeWorkspaceMcpStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - initialized: boolean; - discoveryState?: ServeMcpDiscoveryState; - servers: ServeWorkspaceMcpServerStatus[]; - errors?: ServeStatusCell[]; - /** PR 14: live MCP client count (sum across all transports). */ - clientCount?: number; - /** PR 14: configured budget. Absent when no cap was set. */ - clientBudget?: number; - /** PR 14: active enforcement mode. Absent on pre-PR-14 daemons. */ - budgetMode?: ServeMcpBudgetMode; - /** - * PR 14: workspace-level status cells for budget enforcement. Always - * an array (possibly empty) on post-PR-14 daemons; absent on older - * daemons. PR 23 will add a `scope: 'pool'` cell alongside. - */ - budgets?: ServeMcpBudgetStatusCell[]; -} - -export type ServeSkillLevel = 'project' | 'user' | 'extension' | 'bundled'; - -export interface ServeWorkspaceSkillStatus extends ServeStatusCell { - kind: 'skill'; - name: string; - description: string; - level: ServeSkillLevel; - modelInvocable: boolean; - argumentHint?: string; - model?: string; - extensionName?: string; -} - -export interface ServeWorkspaceSkillsStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - initialized: boolean; - skills: ServeWorkspaceSkillStatus[]; - errors?: ServeStatusCell[]; -} - -export interface ServeWorkspaceProviderCurrent { - authType?: string; - modelId?: string; -} - -export interface ServeWorkspaceProviderModel { - modelId: string; - baseModelId: string; - name: string; - description?: string | null; - contextLimit?: number; - isCurrent: boolean; - isRuntime: boolean; -} - -export interface ServeWorkspaceProviderStatus extends ServeStatusCell { - kind: 'model_provider'; - authType: string; - current: boolean; - models: ServeWorkspaceProviderModel[]; -} - -export interface ServeWorkspaceProvidersStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - initialized: boolean; - current?: ServeWorkspaceProviderCurrent; - providers: ServeWorkspaceProviderStatus[]; - errors?: ServeStatusCell[]; -} - -export interface ServeSessionContextStatus { - v: typeof STATUS_SCHEMA_VERSION; - sessionId: string; - workspaceCwd: string; - state: { - models?: unknown; - modes?: unknown; - configOptions?: unknown[] | null; - [key: string]: unknown; - }; -} - -export interface ServeSessionSupportedCommandsStatus { - v: typeof STATUS_SCHEMA_VERSION; - sessionId: string; - availableCommands: AvailableCommand[]; - availableSkills: string[]; -} - -/** - * Issue #4175 PR 16: workspace memory + agents read surfaces. - * - * Both shapes mirror the `kind / status / error? / errorKind? / hint?` - * cell pattern that PR 12's mcp/skills/providers status structures use, - * so the SDK reducer can render any of these with one pattern. - */ - -export type ServeContextFileScope = 'workspace' | 'global'; - -export interface ServeWorkspaceMemoryFile { - kind: 'memory_file'; - /** Absolute path to the discovered memory file. */ - path: string; - /** - * 'workspace' for files under the bound workspace tree, 'global' for - * `~/.qwen/QWEN.md` style entries. Helps adapters render scope chips. - */ - scope: ServeContextFileScope; - /** Size in bytes of the file's serialized contents on disk. */ - bytes: number; -} - -export interface ServeWorkspaceMemoryStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - initialized: boolean; - files: ServeWorkspaceMemoryFile[]; - /** Total bytes across all hierarchical files (sum of `files[].bytes`). */ - totalBytes: number; - /** - * Number of merged QWEN.md / AGENTS.md files the loader pulled in. - * Mirrors `LoadServerHierarchicalMemoryResponse.fileCount`. - */ - fileCount: number; - /** Baseline path-rule count from `.qwen/rules/`. */ - ruleCount: number; - errors?: ServeStatusCell[]; -} - -/** - * Storage level for a subagent definition surfaced through - * `GET /workspace/agents` and the per-`agentType` detail route. - * - * `project` / `user` / `builtin` are the values the daemon actually - * returns today. `extension` and `session` are forward-compat slots: - * the daemon-scoped `SubagentManager` runs against a stub `Config` - * whose `getActiveExtensions()` returns `[]`, and session-level - * subagents live in a runtime-only cache no CRUD route reads. - * Mirrors `DaemonAgentLevel` in `@qwen-code/sdk` so route + SDK - * consumers see the same forward-compat union. - */ -export type ServeAgentLevel = - | 'project' - | 'user' - | 'builtin' - | 'extension' - | 'session'; - -export interface ServeWorkspaceAgentSummary { - kind: 'agent'; - name: string; - description: string; - level: ServeAgentLevel; - isBuiltin: boolean; - /** Whether this agent restricts the tool set via `tools:` frontmatter. */ - hasTools: boolean; - model?: string; - color?: string; - background?: boolean; - approvalMode?: string; - extensionName?: string; - /** Absolute path to the file backing this agent (or sentinel for built-ins). */ - filePath?: string; -} - -export interface ServeWorkspaceAgentDetail extends ServeWorkspaceAgentSummary { - systemPrompt: string; - tools?: string[]; - disallowedTools?: string[]; - runConfig?: { max_time_minutes?: number; max_turns?: number }; -} - -export interface ServeWorkspaceAgentsStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - agents: ServeWorkspaceAgentSummary[]; - errors?: ServeStatusCell[]; -} - -export function createIdleWorkspaceMemoryStatus( - workspaceCwd: string, -): ServeWorkspaceMemoryStatus { - return { - v: STATUS_SCHEMA_VERSION, - workspaceCwd, - initialized: false, - files: [], - totalBytes: 0, - fileCount: 0, - ruleCount: 0, - }; -} - -export function createIdleWorkspaceAgentsStatus( - workspaceCwd: string, -): ServeWorkspaceAgentsStatus { - return { - v: STATUS_SCHEMA_VERSION, - workspaceCwd, - agents: [], - }; -} - -export function createIdleWorkspaceMcpStatus( - workspaceCwd: string, -): ServeWorkspaceMcpStatus { - // PR 14: an idle workspace has zero live clients and no enforcement - // pressure. `budgetMode` is `'off'` (regardless of how the operator - // configured it) because no discovery has run, so no reservation - // could have happened. `budgets` is an empty array, not absent — - // the daemon DOES support the surface, the snapshot just has - // nothing to report yet. Older daemons omitting the array entirely - // are still spec-compliant; consumers default-coalesce to `[]`. - return { - v: STATUS_SCHEMA_VERSION, - workspaceCwd, - initialized: false, - discoveryState: 'not_started', - servers: [], - clientCount: 0, - budgetMode: 'off', - budgets: [], - }; -} - -export function createIdleWorkspaceSkillsStatus( - workspaceCwd: string, -): ServeWorkspaceSkillsStatus { - return { - v: STATUS_SCHEMA_VERSION, - workspaceCwd, - initialized: false, - skills: [], - }; -} - -export function createIdleWorkspaceProvidersStatus( - workspaceCwd: string, -): ServeWorkspaceProvidersStatus { - return { - v: STATUS_SCHEMA_VERSION, - workspaceCwd, - initialized: false, - providers: [], - }; -} - -/** - * Discriminant for diagnostic cells emitted by `/workspace/env`. - * `env_var` cells are presence-only (the daemon never echoes secret values - * even when redacted). The other kinds expose non-sensitive values like - * runtime tag, platform, redacted proxy host, and sandbox profile name. - */ -export type ServeEnvKind = - | 'runtime' - | 'platform' - | 'sandbox' - | 'proxy' - | 'env_var'; - -export interface ServeEnvCell extends ServeStatusCell { - kind: ServeEnvKind; - /** Stable identifier within the kind (e.g. env-var name, proxy var name). */ - name: string; - present?: boolean; - /** Non-sensitive value; ALWAYS omitted for kind='env_var'. */ - value?: string; -} - -export interface ServeWorkspaceEnvStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - /** Always true — the daemon answers env without consulting ACP. */ - initialized: true; - /** Whether an ACP channel is currently live; informational only. */ - acpChannelLive: boolean; - cells: ServeEnvCell[]; - errors?: ServeStatusCell[]; -} - -/** - * Discriminant for diagnostic cells emitted by `/workspace/preflight`. Cells - * with `locality: 'daemon'` are answered by the bridge process directly and - * are always populated. Cells with `locality: 'acp'` require a live ACP child - * — when the daemon is idle they are emitted with `status: 'not_started'`. - */ -export type ServePreflightKind = - | 'node_version' - | 'cli_entry' - | 'workspace_dir' - | 'ripgrep' - | 'git' - | 'npm' - | 'auth' - | 'mcp_discovery' - | 'skills' - | 'providers' - | 'tool_registry' - | 'egress'; - -export interface ServePreflightCell extends ServeStatusCell { - kind: ServePreflightKind; - locality: 'daemon' | 'acp'; - /** Free-form structured detail (versions, counts, etc.). Never carries secret values. */ - detail?: Record; -} - -export interface ServeWorkspacePreflightStatus { - v: typeof STATUS_SCHEMA_VERSION; - workspaceCwd: string; - /** Always true — daemon-level cells are populated regardless of ACP state. */ - initialized: true; - acpChannelLive: boolean; - cells: ServePreflightCell[]; - errors?: ServeStatusCell[]; -} - -/** - * The six preflight kinds that require a live ACP child to populate. Shared - * between `createIdleAcpPreflightCells` (idle placeholder) and the - * ACP-side `buildAcpPreflightCells` builder so the two sides cannot drift - * — a future contributor adding a new ACP kind in one place sees the - * other surface immediately. - */ -export const ACP_PREFLIGHT_KINDS = [ - 'auth', - 'mcp_discovery', - 'skills', - 'providers', - 'tool_registry', - 'egress', -] as const satisfies readonly ServePreflightKind[]; - -/** - * The narrow union of ACP-locality preflight kinds. Useful for callers - * that need to dispatch on every ACP kind exhaustively (e.g. the - * `Record` builder map in `acpAgent.ts`). - */ -export type AcpPreflightKind = (typeof ACP_PREFLIGHT_KINDS)[number]; - -/** - * Idle ACP cells: emitted when the daemon has no live ACP child. The bridge - * stitches these in alongside its daemon-level cells so `/workspace/preflight` - * always returns a complete cell set without spawning a child. - */ -export function createIdleAcpPreflightCells(): ServePreflightCell[] { - return ACP_PREFLIGHT_KINDS.map((kind) => ({ - kind, - status: 'not_started' as const, - locality: 'acp' as const, - hint: 'spawn a session to populate', - })); -} - -const SKILL_PARSE_CODES: ReadonlySet = new Set([ - 'PARSE_ERROR', - 'INVALID_CONFIG', - 'INVALID_NAME', -]); - -const SKILL_FILE_CODES: ReadonlySet = new Set([ - 'FILE_ERROR', - 'NOT_FOUND', -]); - -const FS_MISSING_CODES: ReadonlySet = new Set([ - 'ENOENT', - 'EACCES', - 'EPERM', -]); - -// `ModelConfigError` subclasses live inside core's models module and are not -// re-exported on the public package surface. We classify them by the `name` -// field that each subclass sets via `this.name = new.target.name`. -const MODEL_CONFIG_ERROR_NAMES: ReadonlySet = new Set([ - 'StrictMissingCredentialsError', - 'StrictMissingModelIdError', - 'MissingApiKeyError', - 'MissingModelError', - 'MissingBaseUrlError', - 'MissingAnthropicBaseUrlEnvError', -]); - -/** - * Map a thrown domain error onto one of the closed `ServeErrorKind` literals - * so diagnostic cells can render structured remediation. Recognition is - * `instanceof`-first; message-string heuristics are a last-resort fallback for - * legacy throw sites that have not yet been retyped. - * - * Returns `undefined` when no rule matches; callers should leave `errorKind` - * unset rather than coercing an unrelated error into a misleading category. - */ -export function mapDomainErrorToErrorKind( - err: unknown, -): ServeErrorKind | undefined { - if (err instanceof BridgeTimeoutError) return 'init_timeout'; - if (err instanceof SkillError) { - if (SKILL_PARSE_CODES.has(err.code)) return 'parse_error'; - if (SKILL_FILE_CODES.has(err.code)) return 'missing_file'; - return undefined; - } - if (err instanceof SyntaxError) return 'parse_error'; - if (!(err instanceof Error)) return undefined; - // `TrustGateError` is defined in `@qwen-code/qwen-code-core/config`; we - // match by `.name` rather than `instanceof` because cross-package bundling - // can produce duplicate class instances where `instanceof` returns false. - if (err.name === 'TrustGateError') return 'auth_env_error'; - if (MODEL_CONFIG_ERROR_NAMES.has(err.name)) return 'auth_env_error'; - const code = (err as { code?: unknown }).code; - if (typeof code === 'string' && FS_MISSING_CODES.has(code)) { - return 'missing_file'; - } - // TODO(follow-up): convert the two throw sites that produce these - // messages (`getChannelClosedReject` in `httpAcpBridge.ts` and the - // `defaultSpawnChannelFactory` "Cannot determine CLI entry path" Error) - // to typed classes (`BridgeChannelClosedError`, `MissingCliEntryError`) - // and replace the regex match with `instanceof`. Until then a foreign - // error message that happens to contain either phrase will misclassify; - // the false-positive surface is small (the phrases are bridge-specific) - // but the cleaner fix belongs in the same wave as PR 22's bridge - // extraction. - const msg = err.message; - if (/agent channel closed/i.test(msg)) return 'protocol_error'; - if (/Cannot determine CLI entry path/i.test(msg)) return 'missing_binary'; - return undefined; -} +// Re-export wrapper. The implementation lives in `@qwen-code/acp-bridge` +// (lifted in #4175 PR 22b). The 25-symbol import block in +// `acp-integration/acpAgent.ts:85-109` and every other internal caller +// keep resolving without churn. +// +// @see ../../../acp-bridge/src/status.ts for the implementation. +export * from '@qwen-code/acp-bridge/status'; From 97ba88da5b3a28ecb93e3c3ee4316e5deed48fdb Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 19 May 2026 02:03:40 +0800 Subject: [PATCH 2/4] refactor(acp-bridge): lift bridge types (#4175 PR 22b checkpoint 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure type lift of the bridge's public TypeScript surface — no behavior or runtime change. What moves - 11 type declarations (~520 LOC) from `httpAcpBridge.ts:101-620` → `packages/acp-bridge/src/bridgeTypes.ts`: - `BridgeSpawnRequest`, `BridgeSession`, `BridgeRestoreSessionRequest` - `BridgeSessionState` (alias of ACP `LoadSessionResponse | ResumeSessionResponse`) - `BridgeRestoredSession`, `BridgeSessionSummary`, `SessionMetadataUpdate` - `BridgeClientRequestContext`, `BridgeHeartbeatResult`, `BridgeHeartbeatState` - `HttpAcpBridge` interface (the bridge's public facade — ~30 methods + 2 getter properties) `bridgeTypes.ts` imports - `ApprovalMode` from `@qwen-code/qwen-code-core` (status types reference it) - ACP wire types (`CancelNotification`, `LoadSessionResponse`, `PromptRequest`, `PromptResponse`, `RequestPermissionResponse`, `ResumeSessionResponse`, `SetSessionModelRequest`, `SetSessionModelResponse`) from `@agentclientprotocol/sdk` - `BridgeEvent` + `SubscribeOptions` from local `./eventBus.js` - All `Serve*Status` types from local `./status.js` (also part of acp-bridge after checkpoint 1) Wrappers - `httpAcpBridge.ts` lines 101-620 replaced with a single `import type` + `export type` re-export block; the local `BridgeClient` class + `createHttpAcpBridge` factory continue to reference these names through the import binding - Cleaned up imports from `@agentclientprotocol/sdk` and `./status.js` — `LoadSessionResponse`, `PromptResponse`, `ResumeSessionResponse`, `SubscribeOptions`, and 5 `ServeWorkspace*Status` types are no longer used inline in `httpAcpBridge.ts` (all consumed via lifted types) - Added `./bridgeTypes` subpath export to `acp-bridge/package.json` Backward compatibility - All existing imports of `from './httpAcpBridge.js'` keep resolving (server.ts:31, runQwenServe.ts:16, workspaceAgents.ts:21, workspaceMemory.ts:19, index.ts:90-97) - Zero `/capabilities`, route, SDK, or behavior changes Verification - `cd packages/core && npx tsc --build` clean - `cd packages/acp-bridge && npx tsc --build` clean - `cd packages/cli && npx tsc --noEmit` clean Remaining PR 22b checkpoints - 22b/3: BridgeOptions + DaemonStatusProvider injection seam - 22b/4: BridgeClient class - 22b/5: defaultSpawnChannelFactory - 22b/6: createHttpAcpBridge factory (~2240 LOC of moves) — the bulk - 22b/7: serve/daemonStatusProvider.ts + runQwenServe wire - 22b/8: move bridge tests (5064 lines) - 22b/9: shrink httpAcpBridge.ts to final re-export shim --- packages/acp-bridge/src/bridgeTypes.ts | 392 ++++++++++++++++ packages/cli/src/serve/httpAcpBridge.ts | 574 ++---------------------- 2 files changed, 426 insertions(+), 540 deletions(-) create mode 100644 packages/acp-bridge/src/bridgeTypes.ts diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts new file mode 100644 index 00000000000..925c3957a66 --- /dev/null +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { + CancelNotification, + LoadSessionResponse, + PromptRequest, + PromptResponse, + RequestPermissionResponse, + ResumeSessionResponse, + SetSessionModelRequest, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import type { BridgeEvent, SubscribeOptions } from './eventBus.js'; +import type { + ServeSessionContextStatus, + ServeSessionSupportedCommandsStatus, + ServeWorkspaceEnvStatus, + ServeWorkspaceMcpStatus, + ServeWorkspacePreflightStatus, + ServeWorkspaceProvidersStatus, + ServeWorkspaceSkillsStatus, +} from './status.js'; + +export interface BridgeSpawnRequest { + /** Absolute path to the workspace root the child inherits as cwd. */ + workspaceCwd: string; + /** Optional explicit model service id; falls back to settings default. */ + modelServiceId?: string; + /** + * Optional echo of a daemon-issued client id from a previous attach to the + * same live session. Unknown ids are ignored on create/attach and replaced + * with a freshly stamped id. + */ + clientId?: string; + /** + * Per-request override for `sessionScope`. When set, takes precedence + * over the bridge-wide default (`BridgeOptions.sessionScope`). When + * omitted, the bridge-wide default applies. + */ + sessionScope?: 'single' | 'thread'; +} + +export interface BridgeSession { + sessionId: string; + workspaceCwd: string; + /** True if this attach reused an existing session under `sessionScope: 'single'`. */ + attached: boolean; + /** + * Opaque daemon-issued id for the attaching HTTP client. Subsequent + * session-scoped requests may echo it so daemon events can identify the + * initiating client without trusting request bodies. + */ + clientId?: string; + /** ISO 8601 timestamp of when the session was created. */ + createdAt?: string; +} + +export interface BridgeRestoreSessionRequest { + /** Session id to restore through ACP `session/load` or `session/resume`. */ + sessionId: string; + /** Absolute path to the workspace root the child inherits as cwd. */ + workspaceCwd: string; + /** Optional echo of a daemon-issued client id for this session. */ + clientId?: string; +} + +export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse; + +export interface BridgeRestoredSession extends BridgeSession { + /** ACP state returned by `session/load` / `session/resume`. */ + state: BridgeSessionState; +} + +/** Sparse summary used by `GET /workspace/:id/sessions`. */ +export interface BridgeSessionSummary { + sessionId: string; + workspaceCwd: string; + createdAt: string; + displayName?: string; + clientCount: number; + hasActivePrompt: boolean; +} + +export interface SessionMetadataUpdate { + displayName?: string; +} + +export interface BridgeClientRequestContext { + /** Daemon-issued client id echoed through the HTTP transport header. */ + clientId?: string; +} + +/** + * Returned from `recordHeartbeat`. `lastSeenAt` is the server-side + * `Date.now()` epoch (ms) the bridge stored for this session/client + * pair. `clientId` is echoed only when the caller provided a trusted + * one through `X-Qwen-Client-Id`; anonymous heartbeats omit it but + * still bump the per-session timestamp. + */ +export interface BridgeHeartbeatResult { + sessionId: string; + clientId?: string; + lastSeenAt: number; +} + +/** + * Read-only snapshot of last-seen timestamps the bridge has recorded for + * a session. `sessionLastSeenAt` is the most recent heartbeat across any + * client (anonymous or identified). `clientLastSeenAt` maps each + * registered `clientId` to its own last heartbeat. Returned by + * `getHeartbeatState` for in-process diagnostics. + */ +export interface BridgeHeartbeatState { + sessionLastSeenAt?: number; + clientLastSeenAt: ReadonlyMap; +} + +export interface HttpAcpBridge { + /** + * Create a new session, or — under `sessionScope: 'single'` — attach to an + * existing session for the same workspace. + */ + spawnOrAttach(req: BridgeSpawnRequest): Promise; + + /** + * Load an existing persisted session and replay its history through + * session_update notifications. Returns `attached: true` when the requested + * session is already live in this daemon. + */ + loadSession(req: BridgeRestoreSessionRequest): Promise; + + /** + * Resume an existing persisted session without requesting history replay. + * Returns `attached: true` when the requested session is already live in + * this daemon. + */ + resumeSession( + req: BridgeRestoreSessionRequest, + ): Promise; + + /** + * Forward a prompt to the agent. Concurrent prompts against the same + * session FIFO-serialize through a per-session queue. Throws + * `SessionNotFoundError` when the id is unknown. + */ + sendPrompt( + sessionId: string, + req: PromptRequest, + signal?: AbortSignal, + context?: BridgeClientRequestContext, + ): Promise; + + /** + * Cancel the in-flight prompt on the session. Throws + * `SessionNotFoundError` when the id is unknown. + */ + cancelSession( + sessionId: string, + req?: CancelNotification, + context?: BridgeClientRequestContext, + ): Promise; + + /** + * Subscribe to the session's event stream. Throws + * `SessionNotFoundError` when the id is unknown. + */ + subscribeEvents( + sessionId: string, + opts?: SubscribeOptions, + ): AsyncIterable; + + /** + * Explicitly close a live session. Force-closes even when other clients + * are attached. Throws `SessionNotFoundError` for unknown ids. + */ + closeSession( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; + + /** + * Update mutable session metadata. Currently supports `displayName` only. + * Throws `SessionNotFoundError` for unknown ids. + */ + updateSessionMetadata( + sessionId: string, + metadata: SessionMetadataUpdate, + context?: BridgeClientRequestContext, + ): SessionMetadataUpdate; + + /** + * Cast a vote on a pending `permission_request` (first-responder wins). + */ + respondToPermission( + requestId: string, + response: RequestPermissionResponse, + context?: BridgeClientRequestContext, + ): boolean; + + /** + * Cast a vote scoped to an explicit session route. + */ + respondToSessionPermission( + sessionId: string, + requestId: string, + response: RequestPermissionResponse, + context?: BridgeClientRequestContext, + ): boolean; + + /** + * List all live sessions whose canonical workspace path matches the + * supplied cwd. Empty array (not throw) when no sessions exist. + */ + listWorkspaceSessions(workspaceCwd: string): BridgeSessionSummary[]; + + /** + * Record a client heartbeat for the session. Throws + * `SessionNotFoundError` for unknown ids and `InvalidClientIdError` + * when the supplied `clientId` is not registered for this session. + */ + recordHeartbeat( + sessionId: string, + context?: BridgeClientRequestContext, + ): BridgeHeartbeatResult; + + /** + * Read the bridge's recorded last-seen timestamps for a session. + * Returns `undefined` for unknown sessions. + */ + getHeartbeatState(sessionId: string): BridgeHeartbeatState | undefined; + + /** + * Workspace-level event fan-out for mutations that change daemon-wide state. + * Best-effort per session; closed buses silently skipped. + */ + publishWorkspaceEvent(event: Omit): void; + + /** + * Union of every live session's `clientIds`. Used by workspace-level + * mutation routes to validate the optional `X-Qwen-Client-Id` header. + * Returns a snapshot — callers must not mutate. + */ + knownClientIds(): ReadonlySet; + + /** + * Read daemon-runtime MCP status for the bound workspace. Does not spawn + * an ACP child when the daemon is idle. + */ + getWorkspaceMcpStatus(): Promise; + + /** + * Read daemon-runtime skill status for the bound workspace. + */ + getWorkspaceSkillsStatus(): Promise; + + /** + * Read daemon-runtime model-provider status for the bound workspace. + */ + getWorkspaceProvidersStatus(): Promise; + + /** + * Read the daemon-process environment snapshot for the bound workspace. + * Answered entirely from `process.*` state — does not consult ACP. + */ + getWorkspaceEnvStatus(): Promise; + + /** + * Read daemon-runtime preflight diagnostics. Daemon-level cells are + * always populated; ACP-level cells require a live ACP child — when + * the daemon is idle they are emitted with `status: 'not_started'`. + */ + getWorkspacePreflightStatus(): Promise; + + /** Read the current ACP context/config state for a live session. */ + getSessionContextStatus( + sessionId: string, + ): Promise; + + /** Read slash-command/skill command availability for a live session. */ + getSessionSupportedCommandsStatus( + sessionId: string, + ): Promise; + + /** + * Switch the active model service for a session. Throws + * `SessionNotFoundError` for unknown ids. + */ + setSessionModel( + sessionId: string, + req: SetSessionModelRequest, + context?: BridgeClientRequestContext, + ): Promise; + + /** + * Change the approval mode of a live session and broadcast an + * `approval_mode_changed` event. `opts.persist === true` also writes + * `tools.approvalMode` to workspace settings. + */ + setSessionApprovalMode( + sessionId: string, + mode: ApprovalMode, + opts: { persist: boolean }, + context?: BridgeClientRequestContext, + ): Promise<{ + sessionId: string; + mode: ApprovalMode; + previous: ApprovalMode; + persisted: boolean; + }>; + + /** + * Add or remove a tool name from the workspace's `tools.disabled` + * settings list and fan-out a `tool_toggled` event to every live + * session SSE bus. + */ + setWorkspaceToolEnabled( + toolName: string, + enabled: boolean, + originatorClientId: string | undefined, + ): Promise<{ toolName: string; enabled: boolean }>; + + /** + * Scaffold an empty `QWEN.md` (or whatever + * `getCurrentGeminiMdFilename()` returns) at the bound workspace + * root. Default refuses to overwrite via + * `WorkspaceInitConflictError`; `opts.force === true` overwrites. + */ + initWorkspace( + opts: { force?: boolean }, + originatorClientId: string | undefined, + ): Promise<{ + path: string; + action: 'created' | 'overwrote' | 'noop'; + }>; + + /** + * Restart a configured MCP server through the ACP child's + * `McpClientManager`. Pre-checks the live budget snapshot and + * returns a structured "skipped" response (200 OK) for soft refusals. + */ + restartMcpServer( + serverName: string, + originatorClientId: string | undefined, + ): Promise< + | { serverName: string; restarted: true; durationMs: number } + | { + serverName: string; + restarted: false; + skipped: true; + reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; + } + >; + + /** + * Tear down a session — kill the child, drop from maps, publish + * `session_died`. Idempotent on already-dead sessions. + * + * `requireZeroAttaches: true` makes the call a no-op when at + * least one other client has called `spawnOrAttach` for this + * entry and got `attached: true`. + */ + killSession( + sessionId: string, + opts?: { requireZeroAttaches?: boolean }, + ): Promise; + + /** + * Roll back a prior attach: decrement `attachCount` and reap if the + * session has no other live attaches/subscribers. + */ + detachClient(sessionId: string, clientId?: string): Promise; + + /** Test/inspection hook: number of live sessions. */ + readonly sessionCount: number; + + /** Test/inspection hook: number of permission requests awaiting a vote. */ + readonly pendingPermissionCount: number; + + /** + * Synchronous force-kill of every live channel. Called by signal + * handlers when the operator double-taps Ctrl+C. + */ + killAllSync(): void; + + /** Close all live child processes; called on daemon shutdown. */ + shutdown(): Promise; +} diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts index 5cdd5a28c7b..33f174be95f 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -16,12 +16,7 @@ import { } from '@agentclientprotocol/sdk'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { canonicalizeWorkspace } from './fs/paths.js'; -import { - EventBus, - DEFAULT_RING_SIZE, - type BridgeEvent, - type SubscribeOptions, -} from './eventBus.js'; +import { EventBus, DEFAULT_RING_SIZE, type BridgeEvent } from './eventBus.js'; import { BridgeTimeoutError, SERVE_CONTROL_EXT_METHODS, @@ -34,14 +29,7 @@ import { mapDomainErrorToErrorKind, type ServePreflightCell, type ServePreflightKind, - type ServeSessionContextStatus, - type ServeSessionSupportedCommandsStatus, type ServeStatusCell, - type ServeWorkspaceEnvStatus, - type ServeWorkspaceMcpStatus, - type ServeWorkspacePreflightStatus, - type ServeWorkspaceProvidersStatus, - type ServeWorkspaceSkillsStatus, } from './status.js'; import { buildEnvStatusFromProcess } from './envSnapshot.js'; import type { ApprovalMode } from '@qwen-code/qwen-code-core'; @@ -54,14 +42,11 @@ import { getGitVersion, getNpmVersion } from '../utils/systemInfo.js'; import type { CancelNotification, Client, - LoadSessionResponse, PromptRequest, - PromptResponse, ReadTextFileRequest, ReadTextFileResponse, RequestPermissionRequest, RequestPermissionResponse, - ResumeSessionResponse, SessionNotification, SetSessionModelRequest, SetSessionModelResponse, @@ -98,531 +83,40 @@ import type { * route handlers don't need to change. */ -export interface BridgeSpawnRequest { - /** Absolute path to the workspace root the child inherits as cwd. */ - workspaceCwd: string; - /** Optional explicit model service id; falls back to settings default. */ - modelServiceId?: string; - /** - * Optional echo of a daemon-issued client id from a previous attach to the - * same live session. Unknown ids are ignored on create/attach and replaced - * with a freshly stamped id. - */ - clientId?: string; - /** - * Per-request override for `sessionScope`. When set, takes precedence - * over the bridge-wide default (`BridgeOptions.sessionScope`, which - * direct embeds may set at construction time; the production daemon - * has no CLI flag for it today and currently always uses `'single'`). - * When omitted, the bridge-wide default applies — preserving exact - * pre-#4175-PR-5 behavior for any caller that doesn't set the field. - * - * Resolves the FIXME at `BridgeOptions.sessionScope` (#3803 — VSCode - * needing per-window isolation against a daemon defaulting to - * `'single'`) and unblocks the baseline harness from honestly - * measuring per-session cost (the harness in - * `qwen-serve-baseline.test.ts` notes it cannot surface the P1 MCP - * N×M amplification under the shared default). - */ - sessionScope?: 'single' | 'thread'; -} - -export interface BridgeSession { - sessionId: string; - workspaceCwd: string; - /** True if this attach reused an existing session under `sessionScope: 'single'`. */ - attached: boolean; - /** - * Opaque daemon-issued id for the attaching HTTP client. Subsequent - * session-scoped requests may echo it so daemon events can identify the - * initiating client without trusting request bodies. - */ - clientId?: string; - /** ISO 8601 timestamp of when the session was created. */ - createdAt?: string; -} - -export interface BridgeRestoreSessionRequest { - /** Session id to restore through ACP `session/load` or `session/resume`. */ - sessionId: string; - /** Absolute path to the workspace root the child inherits as cwd. */ - workspaceCwd: string; - /** Optional echo of a daemon-issued client id for this session. */ - clientId?: string; -} - -export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse; - -export interface BridgeRestoredSession extends BridgeSession { - /** ACP state returned by `session/load` / `session/resume`. */ - state: BridgeSessionState; -} - -/** Sparse summary used by `GET /workspace/:id/sessions`. */ -export interface BridgeSessionSummary { - sessionId: string; - workspaceCwd: string; - createdAt: string; - displayName?: string; - clientCount: number; - hasActivePrompt: boolean; -} - -export interface SessionMetadataUpdate { - displayName?: string; -} - -export interface BridgeClientRequestContext { - /** Daemon-issued client id echoed through the HTTP transport header. */ - clientId?: string; -} - -/** - * Returned from `recordHeartbeat`. `lastSeenAt` is the server-side - * `Date.now()` epoch (ms) the bridge stored for this session/client - * pair — the same value future diagnostics and revocation policy - * (Wave 5 PR 24) will read. `clientId` is echoed only when the caller - * provided a trusted one through `X-Qwen-Client-Id`; anonymous - * heartbeats omit it but still bump the per-session timestamp. - */ -export interface BridgeHeartbeatResult { - sessionId: string; - clientId?: string; - lastSeenAt: number; -} - -/** - * Read-only snapshot of last-seen timestamps the bridge has recorded for - * a session. `sessionLastSeenAt` is the most recent heartbeat across any - * client (anonymous or identified). `clientLastSeenAt` maps each - * registered `clientId` to its own last heartbeat. Returned by - * `getHeartbeatState` for in-process diagnostics; the eventual read-only - * `GET /session/:id/heartbeat-state` route (Wave 3 PR 12) will surface - * the same shape over HTTP. - */ -export interface BridgeHeartbeatState { - sessionLastSeenAt?: number; - clientLastSeenAt: ReadonlyMap; -} - -export interface HttpAcpBridge { - /** - * Create a new session, or — under `sessionScope: 'single'` — attach to an - * existing session for the same workspace. - */ - spawnOrAttach(req: BridgeSpawnRequest): Promise; - - /** - * Load an existing persisted session and replay its history through - * session_update notifications. Returns `attached: true` when the requested - * session is already live in this daemon. - */ - loadSession(req: BridgeRestoreSessionRequest): Promise; - - /** - * Resume an existing persisted session without requesting history replay. - * Returns `attached: true` when the requested session is already live in - * this daemon. - */ - resumeSession( - req: BridgeRestoreSessionRequest, - ): Promise; - - /** - * Forward a prompt to the agent. Concurrent prompts against the same - * session FIFO-serialize through a per-session queue (ACP guarantees - * "one active prompt per session"). Throws `SessionNotFoundError` when - * the id is unknown. - * - * Optional `signal` — abort cancels the in-flight prompt by sending an - * ACP `cancel` notification to the agent (which causes the agent to - * resolve its `prompt()` with `stopReason: 'cancelled'`). Used by the - * SSE route to propagate `req.on('close')` so a disconnected HTTP - * client unblocks the per-session FIFO instead of poisoning it. - */ - sendPrompt( - sessionId: string, - req: PromptRequest, - signal?: AbortSignal, - context?: BridgeClientRequestContext, - ): Promise; - - /** - * Cancel the in-flight prompt on the session. ACP-side this is a - * notification, not a request — the agent acknowledges by resolving the - * active `prompt()` with a `cancelled` stop reason. Throws - * `SessionNotFoundError` when the id is unknown. - */ - cancelSession( - sessionId: string, - req?: CancelNotification, - context?: BridgeClientRequestContext, - ): Promise; - - /** - * Subscribe to the session's event stream. Returns an AsyncIterable that - * yields published events; supports `Last-Event-ID` reconnect through - * `opts.lastEventId`. Throws `SessionNotFoundError` when the id is - * unknown. - */ - subscribeEvents( - sessionId: string, - opts?: SubscribeOptions, - ): AsyncIterable; - - /** - * Explicitly close a live session. Force-closes even when other clients - * are attached — cancels any active prompt, resolves pending permissions - * as cancelled, publishes `session_closed`, closes the EventBus, and - * removes the session from daemon maps. Throws `SessionNotFoundError` - * for unknown ids (the SDK absorbs 404 to provide client-side - * idempotency). On-disk persisted sessions are NOT deleted — they can - * still be reloaded via `POST /session/:id/load`. - */ - closeSession( - sessionId: string, - context?: BridgeClientRequestContext, - ): Promise; - - /** - * Update mutable session metadata. Currently supports `displayName` only. - * Publishes a `session_metadata_updated` event when fields change. - * Returns the effective stored metadata. Throws `SessionNotFoundError` - * for unknown ids. - */ - updateSessionMetadata( - sessionId: string, - metadata: SessionMetadataUpdate, - context?: BridgeClientRequestContext, - ): SessionMetadataUpdate; - - /** - * Cast a vote on a pending `permission_request` (first-responder wins). - * Returns true when the vote was accepted, false when the requestId is - * unknown — either never existed or already resolved by another client. - */ - respondToPermission( - requestId: string, - response: RequestPermissionResponse, - context?: BridgeClientRequestContext, - ): boolean; - - /** - * Cast a vote scoped to an explicit session route. This keeps the legacy - * first-responder behavior but lets clients avoid accidentally voting on a - * request id that belongs to another live session. - */ - respondToSessionPermission( - sessionId: string, - requestId: string, - response: RequestPermissionResponse, - context?: BridgeClientRequestContext, - ): boolean; - - /** - * List all live sessions whose canonical workspace path matches the - * supplied cwd. Empty array (not throw) when no sessions exist — - * a session-picker UI shouldn't 404 just because the workspace is idle. - */ - listWorkspaceSessions(workspaceCwd: string): BridgeSessionSummary[]; - - /** - * Record a client heartbeat for the session. Bumps the per-session - * `sessionLastSeenAt` and, when a trusted `clientId` is supplied, - * the per-client entry in `clientLastSeenAt`. Throws - * `SessionNotFoundError` when the id is unknown and - * `InvalidClientIdError` when the supplied `clientId` is not - * registered for this session — the same shape `sendPrompt` / - * `setSessionModel` use, so HTTP routes can map it to `400 - * invalid_client_id` consistently. - * - * The recorded timestamps are exposed via `getHeartbeatState`; this - * PR keeps them in-process only (future diagnostics route in PR 12, - * future revocation policy in PR 24). - */ - recordHeartbeat( - sessionId: string, - context?: BridgeClientRequestContext, - ): BridgeHeartbeatResult; - - /** - * Read the bridge's recorded last-seen timestamps for a session. - * Returns `undefined` for unknown sessions. The map is a snapshot — - * callers must not mutate it. Stage 1 surfaces this only to in- - * process callers (tests, future read-only diagnostics routes). - */ - getHeartbeatState(sessionId: string): BridgeHeartbeatState | undefined; - - /** - * Issue #4175 PR 16: workspace-level event fan-out for mutations that - * change daemon-wide state (memory writes, agent CRUD). Publishes the - * event onto every live session's `EventBus` so SSE subscribers - * observe it through the existing per-session stream rather than a - * new workspace-level channel. Best-effort per session — a closed - * bus is silently skipped (mirrors the `permission_resolved` - * try/catch). When zero sessions are active the event drops on the - * floor; the route's read-after-write contract remains correct. - */ - publishWorkspaceEvent(event: Omit): void; - - /** - * Issue #4175 PR 16: union of every live session's `clientIds`. Used - * by workspace-level mutation routes (memory write, agent CRUD) to - * validate the optional `X-Qwen-Client-Id` header without requiring - * a session id. Returns a snapshot — callers must not mutate. - * Wave 5 PR 24 will replace this with a workspace-scoped client - * registry decoupled from sessions. - */ - knownClientIds(): ReadonlySet; - - /** - * Read daemon-runtime MCP status for the bound workspace. Does not spawn an - * ACP child when the daemon is idle; idle daemons return initialized:false. - */ - getWorkspaceMcpStatus(): Promise; - - /** - * Read daemon-runtime skill status for the bound workspace. Does not spawn an - * ACP child when the daemon is idle; idle daemons return initialized:false. - */ - getWorkspaceSkillsStatus(): Promise; - - /** - * Read daemon-runtime model-provider status for the bound workspace. Does - * not spawn an ACP child when the daemon is idle. - */ - getWorkspaceProvidersStatus(): Promise; - - /** - * Read the daemon-process environment snapshot for the bound workspace. - * Answered entirely from `process.*` state — does not consult ACP. Always - * returns `initialized: true`; `acpChannelLive` reports whether a child is - * currently up. - */ - getWorkspaceEnvStatus(): Promise; - - /** - * Read daemon-runtime preflight diagnostics. Daemon-level cells (Node - * version, CLI entry, workspace dir, ripgrep, git, npm) are always - * populated. ACP-level cells (auth, mcp_discovery, skills, providers, - * tool_registry, egress) require a live ACP child — when the daemon is - * idle they are emitted with `status: 'not_started'`. - */ - getWorkspacePreflightStatus(): Promise; - - /** Read the current ACP context/config state for a live session. */ - getSessionContextStatus( - sessionId: string, - ): Promise; - - /** Read slash-command/skill command availability for a live session. */ - getSessionSupportedCommandsStatus( - sessionId: string, - ): Promise; - - /** - * Switch the active model service for a session. Forwards through ACP's - * (currently unstable) `unstable_setSessionModel` and broadcasts a - * `model_switched` event so cross-client UIs reflect the change. - * Throws `SessionNotFoundError` for unknown ids. - */ - setSessionModel( - sessionId: string, - req: SetSessionModelRequest, - context?: BridgeClientRequestContext, - ): Promise; - - /** - * Change the approval mode of a live session and broadcast an - * `approval_mode_changed` event. Forwards through the - * `qwen/control/session/approval_mode` ACP extMethod so the change - * lands inside the ACP child's own `Config` instance. - * - * `opts.persist === true` also writes `tools.approvalMode` to the - * workspace settings file so a future ACP child or a future daemon - * restart picks up the new default. Default is ephemeral - * (`persist: false`) — the remote caller does not pollute the - * user's on-disk settings unless they ask for it. - * - * Throws `SessionNotFoundError` for unknown sessions. The ACP-side - * trust-folder rejection surfaces as a `TrustGateError` from core - * which the route maps via `mapDomainErrorToErrorKind` to - * `auth_env_error`. - */ - setSessionApprovalMode( - sessionId: string, - mode: ApprovalMode, - opts: { persist: boolean }, - context?: BridgeClientRequestContext, - ): Promise<{ - sessionId: string; - mode: ApprovalMode; - previous: ApprovalMode; - persisted: boolean; - }>; - - /** - * Add or remove a tool name from the workspace's `tools.disabled` - * settings list and fan-out a `tool_toggled` event to every live - * session SSE bus. Does NOT consult the ACP child — settings are - * file IO the daemon owns directly. Already-registered tools in - * active sessions stay registered until the next ACP child spawn - * (`tools.disabled` is consulted at `Config` construction time, so - * the toggle takes effect on the next workspace-wide refresh). - * - * Unknown tool names are accepted: the daemon has no authoritative - * tool registry to validate against (built-ins live inside the ACP - * child, MCP tools are discovered post-spawn). Pre-disabling a - * not-yet-installed MCP tool is a legitimate use case. - * - * Throws when the daemon was constructed without a - * `persistDisabledTools` callback — direct embeds / tests must opt - * in to write semantics. - */ - setWorkspaceToolEnabled( - toolName: string, - enabled: boolean, - originatorClientId: string | undefined, - ): Promise<{ toolName: string; enabled: boolean }>; - - /** - * Scaffold an empty `QWEN.md` (or whatever - * `getCurrentGeminiMdFilename()` returns) at the bound workspace - * root. Mechanical only — does NOT invoke the LLM. The caller is - * expected to follow up with `POST /session/:id/prompt` if it wants - * AI-driven content fill. - * - * Default refuses to overwrite: when the target file already exists - * with non-whitespace content, throws `WorkspaceInitConflictError` - * (translated to HTTP 409 by the route). `opts.force === true` - * overwrites unconditionally. - * - * Fan-outs a `workspace_initialized` event with `{path, action: - * 'created' | 'overwrote'}` to every live session SSE bus. - */ - initWorkspace( - opts: { force?: boolean }, - originatorClientId: string | undefined, - ): Promise<{ - path: string; - action: 'created' | 'overwrote' | 'noop'; - }>; - - /** - * Restart a configured MCP server through the ACP child's - * `McpClientManager`. Pre-checks the live budget snapshot from PR 14 - * v1 and returns a structured "skipped" response (200 OK) for soft - * refusals (in-flight discovery, server disabled, restart would - * push live count over budget under `enforce` mode). Hard errors - * (server not configured at all, `McpClientManager` unavailable) - * propagate as ACP errors → mapped to HTTP 4xx/5xx by the route. - * - * On success, fan-outs `mcp_server_restarted` to every live session - * SSE bus with `{serverName, durationMs}`. On soft skip, fan-outs - * `mcp_server_restart_refused` with `{serverName, reason}`. - * - * Throws `SessionNotFoundError`-like (via `SERVE_CONTROL_EXT_METHODS` - * routing) when no ACP channel is alive — restart requires a live - * `McpClientManager` instance, which only exists inside a spawned - * ACP child. - */ - restartMcpServer( - serverName: string, - originatorClientId: string | undefined, - ): Promise< - | { serverName: string; restarted: true; durationMs: number } - | { - serverName: string; - restarted: false; - skipped: true; - reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; - } - >; - - /** - * Kill the agent process for the session and remove it from the maps. - * Used by the HTTP route layer to reap orphans created when a client - * disconnects mid-spawn (the server-side child kept being created - * even though no caller will ever know the sessionId). Idempotent — - * unknown / already-dead sessions are no-ops. - */ - /** - * Tear down a session — kill the child, drop from maps, publish - * `session_died`. Idempotent on already-dead sessions. - * - * `requireZeroAttaches: true` makes the call a no-op when at - * least one other client has called `spawnOrAttach` for this - * entry and got `attached: true`. Used by the disconnect-reaper - * in `server.ts` so a fast reattach by client B doesn't lose its - * session to client A's "I disconnected mid-spawn" cleanup. - */ - killSession( - sessionId: string, - opts?: { requireZeroAttaches?: boolean }, - ): Promise; - - /** - * Roll back a prior attach: decrement `attachCount` and, if the - * session now has neither attaching clients (`attachCount === 0`) - * nor live SSE subscribers, reap it. - * - * Called from the server's `POST /session` route handler when the - * attaching client disconnected before the response could be - * written (`!res.writable && session.attached === true`). Without - * this, the BQ9tV `attachCount`-based race guard would persist - * monotonically: once any attach bumped the counter, the - * spawn-owner's disconnect-reaper would never run again — even if - * the attacher themselves disconnected (tanzhenxin issue 2). This - * is the symmetric "I bumped, but my socket died so the bump is - * fictitious" cleanup. When `clientId` is provided, the daemon-issued - * identity reference acquired by that failed attach is released too; - * echoed ids are ref-counted so a failed reconnect does not revoke an - * older live owner of the same id. - */ - detachClient(sessionId: string, clientId?: string): Promise; - - /** Test/inspection hook: number of live sessions. */ - readonly sessionCount: number; - - /** Test/inspection hook: number of permission requests awaiting a vote. */ - readonly pendingPermissionCount: number; - - /** - * Bd1y6: synchronous force-kill of every live channel. Called by - * the runQwenServe SIGINT/SIGTERM handler when the operator - * double-taps — the second signal can't afford the async - * `shutdown()` Promise that the first signal is still in the - * middle of. Without this, `process.exit(1)` would leave agent - * children running after the daemon vanishes. - */ - killAllSync(): void; - - /** Close all live child processes; called on daemon shutdown. */ - shutdown(): Promise; - - /** - * Issue #4175 PR 21 — best-effort fan-out of a workspace-scoped event - * (no `sessionId`) to every live session bus. Used by routes that - * make workspace-level state changes — e.g. device-flow auth — so SSE - * subscribers attached to any session learn about the change. - * - * **Best-effort semantics:** swallowed bus failures (closed bus, - * subscriber overflow) do NOT throw. Workspace events are - * authoritative via the GET routes; SSE is the convenience path. - * - * Removed in PR #4255 fold-in 9: PR 16 (#4249) landed - * `publishWorkspaceEvent` with identical fan-out semantics; the - * closed-bus + all-failed-stderr operator-visibility features - * that PR 21 added here have been folded INTO - * `publishWorkspaceEvent`. Use that helper for all workspace- - * scoped fan-outs (memory, agents, auth device-flow, future). - */ -} +// Bridge types (BridgeSpawnRequest / BridgeSession / BridgeRestoreSessionRequest / +// BridgeSessionState / BridgeRestoredSession / BridgeSessionSummary / +// SessionMetadataUpdate / BridgeClientRequestContext / BridgeHeartbeatResult / +// BridgeHeartbeatState / HttpAcpBridge interface) lifted to +// `@qwen-code/acp-bridge/bridgeTypes` in #4175 PR 22b. Imported AND +// re-exported so existing relative callers keep resolving and the local +// factory + BridgeClient code below can still reference the types. +import type { + BridgeSpawnRequest, + BridgeSession, + BridgeRestoreSessionRequest, + BridgeSessionState, + BridgeRestoredSession, + BridgeSessionSummary, + SessionMetadataUpdate, + BridgeClientRequestContext, + BridgeHeartbeatResult, + BridgeHeartbeatState, + HttpAcpBridge, +} from '@qwen-code/acp-bridge/bridgeTypes'; +export type { + BridgeSpawnRequest, + BridgeSession, + BridgeRestoreSessionRequest, + BridgeSessionState, + BridgeRestoredSession, + BridgeSessionSummary, + SessionMetadataUpdate, + BridgeClientRequestContext, + BridgeHeartbeatResult, + BridgeHeartbeatState, + HttpAcpBridge, +}; -/** - * Routes catch this to map to HTTP 404. Distinct from generic Error so the - * route layer doesn't have to brittle-match on message text. - */ // Bridge errors lifted to `@qwen-code/acp-bridge/bridgeErrors` in // #4175 PR 22b. `MAX_WORKSPACE_PATH_LENGTH` lifted to // `@qwen-code/acp-bridge/workspacePaths` in the same slice. From 33c83033def9cea6d91177ba0ffefc040fd23333 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 19 May 2026 02:31:19 +0800 Subject: [PATCH 3/4] fix(acp-bridge): drop premature subpath exports + add bridgeErrors module JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-driven fixes for PR 22b/1: 1. **Codex P2 + Copilot inline (real bug)**: removed the three subpath exports `./bridgeOptions`, `./bridge`, `./spawnChannel` from `packages/acp-bridge/package.json`. They pointed at `dist/*.js` files that this PR never builds (no corresponding `src/*.ts` exists yet — those modules ship in PR 22b/2). A consumer following any of those advertised subpaths would have hit `ERR_MODULE_NOT_FOUND` at runtime. The exports come back when 22b/2 adds the source files. 2. **github-actions Low #6 (polish)**: added a module-level JSDoc header to `packages/acp-bridge/src/bridgeErrors.ts` explaining the centralized error taxonomy + how `instanceof`-branching maps to HTTP status codes + how the structured fields drive SDK consumers' typed prompts. Also notes the lift origin (#4175 PR 22b/1) and the re-export shim that keeps server.ts / workspaceAgents.ts / workspaceMemory.ts callers green. Verification - `cd packages/core && npx tsc --build` clean - `cd packages/acp-bridge && npx tsc --noEmit` clean - `cd packages/acp-bridge && npx vitest run` — 40/40 pass (status 12 + eventBus 20 + inMemoryChannel 8) Other review feedback handled separately (replies on PR; follow-up issue for `mapDomainErrorToErrorKind` regex tech debt). --- packages/acp-bridge/package.json | 12 ------------ packages/acp-bridge/src/bridgeErrors.ts | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 19f076f13bf..9ae206506d8 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -47,18 +47,6 @@ "types": "./dist/bridgeTypes.d.ts", "import": "./dist/bridgeTypes.js" }, - "./bridgeOptions": { - "types": "./dist/bridgeOptions.d.ts", - "import": "./dist/bridgeOptions.js" - }, - "./bridge": { - "types": "./dist/bridge.d.ts", - "import": "./dist/bridge.js" - }, - "./spawnChannel": { - "types": "./dist/spawnChannel.d.ts", - "import": "./dist/spawnChannel.js" - }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 490f2b592fe..55eedaed1ac 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -4,6 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Centralized error taxonomy for ACP bridge operations. + * + * Each class is a structurally-distinct subclass of `Error` that the + * HTTP route layer (and embedded callers) can `instanceof`-branch on + * to map to a specific status code without text-matching the message. + * The fields on each class (`sessionId`, `bound`/`requested`, `limit`, + * etc.) are the structured payload that `sendBridgeError` surfaces in + * the JSON body, so SDK consumers can render typed prompts (e.g. + * "session limit reached, retry after N seconds") without parsing + * free-form text. + * + * Lifted from `packages/cli/src/serve/httpAcpBridge.ts` in #4175 PR + * 22b/1 so the bridge package owns the error contract directly. The + * 7 error classes server.ts imports + 1 each from workspaceAgents.ts + * and workspaceMemory.ts continue to resolve through the + * httpAcpBridge.ts re-export shim. + */ + import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js'; export class SessionNotFoundError extends Error { From 703168d4642de6b4ecfe0007df9ef2801288fff3 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 19 May 2026 02:44:41 +0800 Subject: [PATCH 4/4] fix(acp-bridge): include lifted modules in barrel re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review caught: `acp-bridge/src/index.ts` only re-exported the four PR 22a primitives (`eventBus`, `inMemoryChannel`, `channel`, `permission`) but missed the four modules added in PR 22b/1 (`status`, `workspacePaths`, `bridgeErrors`, `bridgeTypes`). The README I wrote in PR 22a explicitly promised "root for application/test code that uses several primitives at once" — leaving these out broke the contract: anyone using `import { ServeStatusCell } from '@qwen-code/acp-bridge'` would get `Module ... has no exported member`. Subpath imports (`@qwen-code/acp-bridge/status` etc.) already worked, so this is a documentation-vs-code drift rather than a runtime regression in tracked consumers — but it would have surprised the first downstream consumer that followed the README's guidance. Verification - `cd packages/acp-bridge && npx tsc --build` clean - `cd packages/acp-bridge && npx vitest run` — 40/40 pass - `cd packages/cli && npx tsc --noEmit` clean - `cd packages/cli && npx vitest run src/serve/` — 680/680 pass Found during self-review pass requested after the Codex/Copilot/github- actions review feedback was addressed in `33c83033d`. --- packages/acp-bridge/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/acp-bridge/src/index.ts b/packages/acp-bridge/src/index.ts index f03e3020009..3de8304efac 100644 --- a/packages/acp-bridge/src/index.ts +++ b/packages/acp-bridge/src/index.ts @@ -8,3 +8,7 @@ export * from './eventBus.js'; export * from './inMemoryChannel.js'; export * from './channel.js'; export * from './permission.js'; +export * from './workspacePaths.js'; +export * from './status.js'; +export * from './bridgeErrors.js'; +export * from './bridgeTypes.js';