diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index a9f34b2a4e7..c03ad8707c8 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -173,6 +173,9 @@ The write tag means the route contract exists; it does not mean the current deployment is open for anonymous mutation. Write/edit are strict mutation routes and require a configured bearer token even on loopback. +`daemon_status` advertises `GET /daemon/status`, the consolidated read-only +operator diagnostic snapshot documented below. + **Conditional tags.** A small number of feature tags are advertised only when the matching deployment toggle is on. Tag presence = behavior is on; absence = either an older daemon predating the tag, OR a current daemon where the operator did not opt in. Currently: | Tag | Advertised when … | @@ -213,6 +216,89 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that expo **Auth:** required **only on non-loopback binds**. On loopback (`127.0.0.1`, `::1`, `[::1]`) `/health` is registered before the bearer middleware so k8s/Compose probes inside the pod don't need to carry the token. On non-loopback (`--hostname 0.0.0.0` etc.) the route is registered after the bearer middleware and returns 401 without a valid token — otherwise an unauthenticated caller could probe arbitrary addresses to confirm a `qwen serve` exists, a low-severity info leak that combines poorly with port scanning. CORS deny + Host allowlist still apply on the loopback exemption. +### `GET /daemon/status` + +Read-only operator diagnostics. Unlike `/health`, this is a normal daemon API: +it is registered after bearer auth and rate limiting, including on loopback +binds. Query parameter: + +- `detail=summary` (default) reads only in-memory daemon state. +- `detail=full` also includes live session diagnostics, ACP connection + diagnostics, auth device-flow counts, and workspace status sections. +- any other `detail` returns `400 { "code": "invalid_detail" }`. + +`summary` intentionally does not query workspace status methods, start an ACP +child, or spawn a session. `full` queries each workspace section independently; +a timeout or exception marks only that section as `unavailable` and adds a +`workspace_status_unavailable` issue. + +Response shape: + +```json +{ + "v": 1, + "detail": "summary", + "generatedAt": "2026-06-16T00:00:00.000Z", + "status": "ok", + "issues": [], + "daemon": { + "pid": 12345, + "uptimeMs": 3600000, + "mode": "http-bridge", + "workspaceCwd": "/repo", + "qwenCodeVersion": "0.18.1", + "daemonId": "serve-..." + }, + "security": { + "tokenConfigured": true, + "requireAuth": false, + "loopbackBind": true, + "allowOriginConfigured": false, + "allowOriginMode": "none", + "sessionShellCommandEnabled": false + }, + "limits": { + "maxSessions": 20, + "maxPendingPromptsPerSession": 5, + "listenerMaxConnections": 256, + "eventRingSize": 8000, + "promptDeadlineMs": null, + "writerIdleTimeoutMs": null, + "channelIdleTimeoutMs": 0, + "sessionIdleTimeoutMs": 1800000, + "acpConnectionCap": 64 + }, + "runtime": { + "sessions": { "active": 0 }, + "permissions": { "pending": 0, "policy": "first-responder" }, + "channel": { "live": false }, + "transport": { + "restSseActive": 0, + "acp": { + "enabled": true, + "connections": 0, + "connectionStreams": 0, + "sessionStreams": 0, + "sseStreams": 0, + "wsStreams": 0, + "pendingClientRequests": 0 + } + } + } +} +``` + +`status` is `error` if any issue has error severity, `warning` if any issue has +warning severity, otherwise `ok`. Issue codes are stable and include +`session_capacity_high`, `connection_capacity_high`, `pending_permissions`, +`acp_channel_down`, `preflight_error`, `mcp_budget_warning`, +`mcp_budget_exhausted`, `rate_limit_hits`, and +`workspace_status_unavailable`. + +Security: the response never includes bearer tokens, client ids, full ACP +connection ids, device-flow user codes, or verification URLs. `summary` omits +the daemon log path; `full` may include it for authenticated operators. + ### `GET /capabilities` ```json @@ -223,7 +309,7 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that expo "supported": ["v1"] }, "mode": "http-bridge", - "features": ["health", "capabilities", "..."], + "features": ["health", "daemon_status", "capabilities", "..."], "modelServices": [], "workspaceCwd": "/canonical/path/to/workspace" } diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 746ba440375..2a5f9cdf067 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -70,18 +70,29 @@ curl http://127.0.0.1:4170/health # → {"status":"ok"} curl http://127.0.0.1:4170/capabilities -# → {"v":1,"mode":"http-bridge","features":["health","capabilities","session_create",...],"workspaceCwd":"/path/to/your-project"} +# → {"v":1,"mode":"http-bridge","features":["health","daemon_status","capabilities","session_create",...],"workspaceCwd":"/path/to/your-project"} + +curl http://127.0.0.1:4170/daemon/status +# → {"v":1,"detail":"summary","status":"ok","runtime":{...}} ``` The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight check + omit `cwd` on `POST /session`. The `limits.maxPendingPromptsPerSession` field advertises the active per-session prompt admission cap; `null` means the cap is disabled. -The daemon also exposes read-only runtime snapshots for client UIs: -`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`, -`GET /workspace/env`, `GET /workspace/preflight`, +The daemon also exposes read-only runtime snapshots for client UIs and +operators: `GET /daemon/status`, `GET /workspace/mcp`, +`GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`, +`GET /workspace/preflight`, `GET /session/:id/context`, `GET /session/:id/supported-commands`, and `GET /session/:id/tasks`. +`GET /daemon/status` is the consolidated troubleshooting snapshot. The default +`detail=summary` reads only in-memory daemon state (sessions, permissions, +SSE/ACP transport counts, rate limit rejects, process memory, resolved limits) +and does not start the ACP child. Use `GET /daemon/status?detail=full` for +per-session diagnostics, ACP connection details, auth device-flow counts, and +workspace status sections when you are actively investigating a problem. + `GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers` report the live ACP runtime and do not start the ACP child when idle; an idle daemon returns `initialized: false` with an empty snapshot. Once a @@ -320,9 +331,9 @@ To handle multiple **users** (each with their own quota, audit log, sandbox) or The daemon exposes ACP's `session/load` and resume flow over HTTP via two routes: -| Route | Use when | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST /session/:id/load` | The client has **no** history rendered (cold reconnect, picker-then-open). The daemon replays every persisted turn through SSE so subscribers see the full transcript. Capability tag: `session_load`. | +| Route | Use when | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /session/:id/load` | The client has **no** history rendered (cold reconnect, picker-then-open). The daemon replays every persisted turn through SSE so subscribers see the full transcript. Capability tag: `session_load`. | | `POST /session/:id/resume` | The client already has the turns on screen and only needs the daemon-side handle back. Model context is restored on the agent side without UI replay — the SSE stream stays clean. Capability tag: `session_resume` (`unstable_session_resume` remains a deprecated alias for older clients). | The TypeScript SDK exposes both as static factories on `DaemonSessionClient`: diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 20bce325dd4..81a2da07c39 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -78,6 +78,7 @@ import type { BridgeClientRequestContext, CloseSessionOpts, AcpSessionBridge, + BridgeDaemonStatusSnapshot, } from './bridgeTypes.js'; import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js'; import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js'; @@ -2478,6 +2479,47 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { startSessionReaper(); return { + getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot { + return { + limits: { + maxSessions: maxSessions === Infinity ? null : maxSessions, + maxPendingPromptsPerSession: + maxPendingPromptsPerSession === Infinity + ? null + : maxPendingPromptsPerSession, + eventRingSize, + channelIdleTimeoutMs: resolvedChannelIdleTimeoutMs(), + sessionIdleTimeoutMs, + }, + sessionCount: byId.size, + pendingPermissionCount: permissionMediator.pendingCount, + channelLive: !!liveChannelInfo(), + permissionPolicy: permissionMediator.policy, + sessions: [...byId.values()].map((entry) => ({ + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + createdAt: entry.createdAt, + ...(entry.displayName ? { displayName: entry.displayName } : {}), + clientCount: entry.clientIds.size, + subscriberCount: entry.events.subscriberCount, + attachCount: entry.attachCount, + pendingPromptCount: entry.pendingPromptCount, + pendingPermissionCount: entry.pendingPermissionIds.size, + hasActivePrompt: entry.promptActive, + lastEventId: entry.events.lastEventId, + ...(entry.sessionLastSeenAt !== undefined + ? { lastSeenAt: entry.sessionLastSeenAt } + : {}), + ...(entry.currentModelId + ? { currentModelId: entry.currentModelId } + : {}), + ...(entry.currentApprovalMode + ? { currentApprovalMode: entry.currentApprovalMode } + : {}), + })), + }; + }, + get sessionCount() { return byId.size; }, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 43e1baddb16..d30ea62e247 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -183,7 +183,44 @@ export interface BridgeHeartbeatState { clientLastSeenAt: ReadonlyMap; } +export interface BridgeDaemonStatusLimits { + maxSessions: number | null; + maxPendingPromptsPerSession: number | null; + eventRingSize: number; + channelIdleTimeoutMs: number; + sessionIdleTimeoutMs: number; +} + +export interface BridgeDaemonSessionDiagnostic { + sessionId: string; + workspaceCwd: string; + createdAt: string; + displayName?: string; + clientCount: number; + subscriberCount: number; + attachCount: number; + pendingPromptCount: number; + pendingPermissionCount: number; + hasActivePrompt: boolean; + lastEventId: number; + lastSeenAt?: number; + currentModelId?: string; + currentApprovalMode?: string; +} + +export interface BridgeDaemonStatusSnapshot { + limits: BridgeDaemonStatusLimits; + sessionCount: number; + pendingPermissionCount: number; + channelLive: boolean; + permissionPolicy: PermissionPolicy; + sessions: BridgeDaemonSessionDiagnostic[]; +} + export interface AcpSessionBridge { + /** Read-only daemon diagnostics for status endpoints. */ + getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot; + /** * Create a new session, or — under `sessionScope: 'single'` — attach to an * existing session for the same workspace. diff --git a/packages/cli/src/serve/acpHttp/connectionRegistry.test.ts b/packages/cli/src/serve/acpHttp/connectionRegistry.test.ts new file mode 100644 index 00000000000..a00bcd55fa9 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/connectionRegistry.test.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { ConnectionRegistry } from './connectionRegistry.js'; +import type { TransportStream } from './transportStream.js'; + +class FakeStream implements TransportStream { + isClosed = false; + + constructor(readonly kind: 'sse' | 'ws') {} + + async send(_message: unknown): Promise {} + + close(): void { + this.isClosed = true; + } +} + +describe('ConnectionRegistry.getSnapshot', () => { + it('counts SSE streams and redacts full connection ids', () => { + const registry = new ConnectionRegistry(undefined, undefined, 2); + try { + const conn = registry.create(true); + expect(conn).toBeDefined(); + if (!conn) return; + + conn.attachConnStream(new FakeStream('sse')); + conn.ownSession('sess-1'); + conn.attachSessionStream( + 'sess-1', + new FakeStream('sse'), + new AbortController(), + ); + conn.pending.set('request-1', { + sessionId: 'sess-1', + bridgeRequestId: 'permission-1', + kind: 'permission', + }); + + const snapshot = registry.getSnapshot(); + + expect(snapshot).toMatchObject({ + connectionCount: 1, + connectionCap: 2, + connectionStreams: 1, + sessionStreams: 1, + sseStreams: 2, + wsStreams: 0, + pendingClientRequests: 1, + }); + expect(snapshot.connections[0]).toMatchObject({ + connectionIdPrefix: conn.connectionId.slice(0, 8), + fromLoopback: true, + ownedSessionCount: 1, + sessionBindingCount: 1, + pendingClientRequests: 1, + }); + expect(snapshot.connections[0]?.connectionIdPrefix).toHaveLength(8); + expect(JSON.stringify(snapshot)).not.toContain(conn.connectionId); + } finally { + registry.dispose(); + } + }); + + it('counts a shared WebSocket stream once while tracking session bindings', () => { + const registry = new ConnectionRegistry(); + try { + const conn = registry.create(false); + expect(conn).toBeDefined(); + if (!conn) return; + + const stream = new FakeStream('ws'); + conn.attachConnStream(stream); + conn.ownSession('sess-1'); + conn.attachSessionStream('sess-1', stream, new AbortController()); + conn.ownSession('sess-2'); + conn.attachSessionStream('sess-2', stream, new AbortController()); + + const snapshot = registry.getSnapshot(); + + expect(snapshot.connectionStreams).toBe(1); + expect(snapshot.sessionStreams).toBe(2); + expect(snapshot.wsStreams).toBe(1); + expect(snapshot.sseStreams).toBe(0); + } finally { + registry.dispose(); + } + }); +}); diff --git a/packages/cli/src/serve/acpHttp/connectionRegistry.ts b/packages/cli/src/serve/acpHttp/connectionRegistry.ts index 5313b8fe9e3..67dd4534184 100644 --- a/packages/cli/src/serve/acpHttp/connectionRegistry.ts +++ b/packages/cli/src/serve/acpHttp/connectionRegistry.ts @@ -87,6 +87,34 @@ export interface PendingClientRequest { kind: 'permission'; } +export interface AcpConnectionDiagnostic { + connectionIdPrefix: string; + fromLoopback: boolean; + destroyed: boolean; + lastActiveMs: number; + ownedSessionCount: number; + sessionBindingCount: number; + closingSessionCount: number; + pendingClientRequests: number; + connectionStreamOpen: boolean; + sessionStreams: number; + sseStreams: number; + wsStreams: number; + bufferedConnectionFrames: number; + bufferedSessionFrames: number; +} + +export interface ConnectionRegistrySnapshot { + connectionCount: number; + connectionCap: number | null; + connectionStreams: number; + sessionStreams: number; + sseStreams: number; + wsStreams: number; + pendingClientRequests: number; + connections: AcpConnectionDiagnostic[]; +} + export class AcpConnection { readonly connectionId: string; /** Connection-scoped SSE stream (the client's `GET /acp` with only the conn header). */ @@ -180,6 +208,45 @@ export class AcpConnection { return binding; } + getDiagnostic(): AcpConnectionDiagnostic { + const liveStreams = new Set(); + if (this.connStream && !this.connStream.isClosed) { + liveStreams.add(this.connStream); + } + let sessionStreams = 0; + let bufferedSessionFrames = 0; + for (const binding of this.sessions.values()) { + bufferedSessionFrames += binding.buffer.length; + if (binding.stream && !binding.stream.isClosed) { + sessionStreams += 1; + liveStreams.add(binding.stream); + } + } + let sseStreams = 0; + let wsStreams = 0; + for (const stream of liveStreams) { + if (stream.kind === 'sse') sseStreams += 1; + if (stream.kind === 'ws') wsStreams += 1; + } + return { + connectionIdPrefix: this.connectionId.slice(0, 8), + fromLoopback: this.fromLoopback, + destroyed: this.destroyed, + lastActiveMs: this.lastActiveMs, + ownedSessionCount: this.ownedSessions.size, + sessionBindingCount: this.sessions.size, + closingSessionCount: this.closingSessions.size, + pendingClientRequests: this.pending.size, + connectionStreamOpen: + this.connStream !== undefined && !this.connStream.isClosed, + sessionStreams, + sseStreams, + wsStreams, + bufferedConnectionFrames: this.connBuffer.length, + bufferedSessionFrames, + }; + } + /** Send a frame on the connection-scoped stream (buffer until it attaches). */ sendConn(frame: unknown): void { if (this.connStream && !this.connStream.isClosed) { @@ -400,6 +467,29 @@ export class ConnectionRegistry { return this.maxConnections; } + getSnapshot(): ConnectionRegistrySnapshot { + const connections = [...this.byId.values()].map((conn) => + conn.getDiagnostic(), + ); + return { + connectionCount: this.byId.size, + connectionCap: + this.maxConnections > 0 && Number.isFinite(this.maxConnections) + ? this.maxConnections + : null, + connectionStreams: connections.filter((conn) => conn.connectionStreamOpen) + .length, + sessionStreams: sumBy(connections, (conn) => conn.sessionStreams), + sseStreams: sumBy(connections, (conn) => conn.sseStreams), + wsStreams: sumBy(connections, (conn) => conn.wsStreams), + pendingClientRequests: sumBy( + connections, + (conn) => conn.pendingClientRequests, + ), + connections, + }; + } + dispose(): void { clearInterval(this.sweepTimer); for (const id of [...this.byId.keys()]) this.delete(id); @@ -422,3 +512,9 @@ export class ConnectionRegistry { } } } + +function sumBy(values: readonly T[], select: (value: T) => number): number { + let total = 0; + for (const value of values) total += select(value); + return total; +} diff --git a/packages/cli/src/serve/acpHttp/sseStream.ts b/packages/cli/src/serve/acpHttp/sseStream.ts index a0333e9b620..f6bc7f7dbda 100644 --- a/packages/cli/src/serve/acpHttp/sseStream.ts +++ b/packages/cli/src/serve/acpHttp/sseStream.ts @@ -23,6 +23,8 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js'; * resumability is RFD Phase 4, deferred per the design doc §7). */ export class SseStream { + readonly kind = 'sse' as const; + private writeChain: Promise = Promise.resolve(); private heartbeat: ReturnType | undefined; private closed = false; diff --git a/packages/cli/src/serve/acpHttp/transportStream.ts b/packages/cli/src/serve/acpHttp/transportStream.ts index 5a797718f5c..e0408f6d251 100644 --- a/packages/cli/src/serve/acpHttp/transportStream.ts +++ b/packages/cli/src/serve/acpHttp/transportStream.ts @@ -9,6 +9,7 @@ * Both `SseStream` (HTTP SSE) and `WsStream` (WebSocket) implement this. */ export interface TransportStream { + readonly kind: 'sse' | 'ws'; send(message: unknown): Promise; close(): void; readonly isClosed: boolean; diff --git a/packages/cli/src/serve/acpHttp/wsStream.ts b/packages/cli/src/serve/acpHttp/wsStream.ts index e376da7e79c..66718996410 100644 --- a/packages/cli/src/serve/acpHttp/wsStream.ts +++ b/packages/cli/src/serve/acpHttp/wsStream.ts @@ -9,6 +9,8 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js'; import type { TransportStream } from './transportStream.js'; export class WsStream implements TransportStream { + readonly kind = 'ws' as const; + private writeChain: Promise = Promise.resolve(); private _closed = false; private heartbeat: ReturnType | undefined; diff --git a/packages/cli/src/serve/acpSessionBridge.ts b/packages/cli/src/serve/acpSessionBridge.ts index b899f45d34b..b6b5ba888d0 100644 --- a/packages/cli/src/serve/acpSessionBridge.ts +++ b/packages/cli/src/serve/acpSessionBridge.ts @@ -72,6 +72,9 @@ export type { BridgeClientRequestContext, BridgeHeartbeatResult, BridgeHeartbeatState, + BridgeDaemonStatusLimits, + BridgeDaemonSessionDiagnostic, + BridgeDaemonStatusSnapshot, AcpSessionBridge, HttpAcpBridge, } from '@qwen-code/acp-bridge/bridgeTypes'; diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 31c4c29981b..8aaf85ce43f 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -31,6 +31,7 @@ export interface ServeCapabilityDescriptor { export const SERVE_CAPABILITY_REGISTRY = { health: { since: 'v1' }, + daemon_status: { since: 'v1' }, capabilities: { since: 'v1' }, session_create: { since: 'v1' }, session_scope_override: { since: 'v1' }, diff --git a/packages/cli/src/serve/daemonStatus.test.ts b/packages/cli/src/serve/daemonStatus.test.ts new file mode 100644 index 00000000000..8ea1126c4c2 --- /dev/null +++ b/packages/cli/src/serve/daemonStatus.test.ts @@ -0,0 +1,289 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RequestHandler } from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AcpHttpHandle } from './acpHttp/index.js'; +import type { + AcpSessionBridge, + BridgeDaemonStatusSnapshot, +} from './acpSessionBridge.js'; +import { DeviceFlowRegistry } from './auth/deviceFlow.js'; +import { + buildDaemonStatusResponse, + type BuildDaemonStatusOptions, +} from './daemonStatus.js'; +import type { RateLimiterInstance, RateLimitTier } from './rateLimit.js'; +import type { DaemonWorkspaceService } from './workspace-service/index.js'; + +const BASE_WORKSPACE = '/work/status'; + +const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = { + limits: { + maxSessions: 20, + maxPendingPromptsPerSession: 5, + eventRingSize: 8000, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + }, + sessionCount: 0, + pendingPermissionCount: 0, + channelLive: true, + permissionPolicy: 'first-responder', + sessions: [], +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('buildDaemonStatusResponse', () => { + it('reports every runtime issue code from daemon counters', async () => { + const response = await buildDaemonStatusResponse( + 'summary', + makeOptions({ + bridgeSnapshot: { + ...BASE_BRIDGE_SNAPSHOT, + limits: { ...BASE_BRIDGE_SNAPSHOT.limits, maxSessions: 10 }, + sessionCount: 8, + pendingPermissionCount: 2, + channelLive: false, + }, + acpSnapshot: { + connectionCount: 8, + connectionCap: 10, + connectionStreams: 1, + sessionStreams: 1, + sseStreams: 1, + wsStreams: 0, + pendingClientRequests: 0, + connections: [], + }, + rateLimitHits: { prompt: 1, mutation: 2, read: 3 }, + rateLimitEnabled: true, + }), + ); + + expect(response).toMatchObject({ + status: 'error', + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'session_capacity_high' }), + expect.objectContaining({ code: 'connection_capacity_high' }), + expect.objectContaining({ code: 'pending_permissions' }), + expect.objectContaining({ code: 'acp_channel_down' }), + expect.objectContaining({ code: 'rate_limit_hits' }), + ]), + }); + }); + + it('rolls up statuses inside tools, hooks, and extensions', async () => { + const response = await buildDaemonStatusResponse( + 'full', + makeOptions({ + toolsStatus: { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + acpChannelLive: true, + tools: [{ name: 'broken-tool', enabled: true, status: 'error' }], + }, + hooksStatus: { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + disabled: false, + hooks: [{ kind: 'hook', eventName: 'Stop', status: 'warning' }], + events: {}, + }, + extensionsStatus: { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + extensions: [{ kind: 'extension', id: 'broken', status: 'error' }], + }, + }), + ); + + expect(response).toMatchObject({ + full: { + workspace: { + tools: { status: 'error' }, + hooks: { status: 'warning' }, + extensions: { status: 'error' }, + }, + }, + }); + }); + + it('reports MCP budget warning and exhausted issue codes', async () => { + const warning = await buildDaemonStatusResponse( + 'full', + makeOptions({ + mcpStatus: { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + clientCount: 3, + clientBudget: 4, + servers: [], + }, + }), + ); + expect(warning).toMatchObject({ + status: 'warning', + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'mcp_budget_warning' }), + ]), + }); + + const exhausted = await buildDaemonStatusResponse( + 'full', + makeOptions({ + mcpStatus: { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + clientCount: 4, + clientBudget: 4, + servers: [], + }, + }), + ); + expect(exhausted).toMatchObject({ + status: 'error', + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'mcp_budget_exhausted' }), + ]), + }); + }); + + it('marks a timed-out full workspace section unavailable', async () => { + vi.useFakeTimers(); + + const pending = buildDaemonStatusResponse( + 'full', + makeOptions({ + mcpStatus: new Promise(() => {}), + }), + ); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(pending).resolves.toMatchObject({ + status: 'warning', + issues: expect.arrayContaining([ + expect.objectContaining({ + code: 'workspace_status_unavailable', + section: 'mcp', + }), + ]), + full: { + workspace: { + mcp: { + status: 'unavailable', + error: { kind: 'timeout' }, + }, + }, + }, + }); + }); +}); + +interface MakeOptionsInput { + bridgeSnapshot?: BridgeDaemonStatusSnapshot; + acpSnapshot?: ReturnType; + rateLimitHits?: Record; + rateLimitEnabled?: boolean; + mcpStatus?: unknown; + toolsStatus?: unknown; + hooksStatus?: unknown; + extensionsStatus?: unknown; +} + +function makeOptions(input: MakeOptionsInput = {}): BuildDaemonStatusOptions { + const registry = new DeviceFlowRegistry({ + events: { publish: () => {} }, + resolveProvider: () => undefined, + scheduleInterval: () => fakeInterval(), + clearScheduledInterval: () => {}, + }); + const bridge = { + getDaemonStatusSnapshot: () => + input.bridgeSnapshot ?? BASE_BRIDGE_SNAPSHOT, + getWorkspaceToolsStatus: async () => + input.toolsStatus ?? okStatus({ tools: [] }), + } as unknown as AcpSessionBridge; + const workspace = { + getWorkspaceMcpStatus: async () => + input.mcpStatus ?? okStatus({ servers: [] }), + getWorkspaceSkillsStatus: async () => okStatus({ skills: [] }), + getWorkspaceProvidersStatus: async () => okStatus({ providers: [] }), + getWorkspaceEnvStatus: async () => okStatus({ cells: [] }), + getWorkspacePreflightStatus: async () => okStatus({ cells: [] }), + getWorkspaceHooksStatus: async () => + input.hooksStatus ?? okStatus({ hooks: [], events: {} }), + getWorkspaceExtensionsStatus: async () => + input.extensionsStatus ?? okStatus({ extensions: [] }), + } as unknown as DaemonWorkspaceService; + + return { + opts: { + hostname: '127.0.0.1', + port: 4170, + mode: 'http-bridge', + rateLimit: input.rateLimitEnabled, + }, + boundWorkspace: BASE_WORKSPACE, + bridge, + workspace, + qwenCodeVersion: 'test', + ...(input.acpSnapshot + ? { + acpHandle: { + registry: { getSnapshot: () => input.acpSnapshot }, + } as unknown as AcpHttpHandle, + } + : {}), + ...(input.rateLimitHits + ? { rateLimiter: makeRateLimiter(input.rateLimitHits) } + : {}), + getRestSseActive: () => 0, + features: ['health', 'daemon_status'], + protocolVersions: { current: 'v1', supported: ['v1'] }, + supportedDeviceFlowProviders: ['qwen-oauth'], + deviceFlowRegistry: registry, + sessionShellCommandEnabled: false, + }; +} + +function okStatus(extra: Record): Record { + return { + v: 1, + workspaceCwd: BASE_WORKSPACE, + initialized: true, + ...extra, + }; +} + +function makeRateLimiter( + hits: Record, +): RateLimiterInstance { + const middleware: RequestHandler = (_req, _res, next) => next(); + return { + middleware, + checkRate: () => true, + reset: () => {}, + setDraining: () => {}, + dispose: () => {}, + getHitCounts: () => hits, + }; +} + +function fakeInterval(): ReturnType { + return { + ref: () => {}, + unref: () => {}, + } as unknown as ReturnType; +} diff --git a/packages/cli/src/serve/daemonStatus.ts b/packages/cli/src/serve/daemonStatus.ts new file mode 100644 index 00000000000..e3a004782f5 --- /dev/null +++ b/packages/cli/src/serve/daemonStatus.ts @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ServeProtocolVersions } from './capabilities.js'; +import type { AcpHttpHandle } from './acpHttp/index.js'; +import type { DeviceFlowRegistry } from './auth/deviceFlow.js'; +import type { DaemonLogger } from './daemonLogger.js'; +import type { + AcpSessionBridge, + BridgeDaemonStatusSnapshot, +} from './acpSessionBridge.js'; +import { isLoopbackBind } from './loopbackBinds.js'; +import type { RateLimiterInstance, RateLimitTier } from './rateLimit.js'; +import type { ServeOptions } from './types.js'; +import type { + DaemonWorkspaceService, + WorkspaceRequestContext, +} from './workspace-service/index.js'; + +const DEFAULT_LISTENER_MAX_CONNECTIONS = 256; +const SECTION_TIMEOUT_MS = 1_000; +const CAPACITY_WARNING_RATIO = 0.8; + +export type DaemonStatusDetail = 'summary' | 'full'; +type DaemonStatusLevel = 'ok' | 'warning' | 'error'; +type SectionStatus = DaemonStatusLevel | 'unavailable'; +type IssueSeverity = 'warning' | 'error'; +type SectionSummary = Record; +type StatusRecord = Record; + +export interface DaemonStatusIssue { + code: + | 'session_capacity_high' + | 'connection_capacity_high' + | 'pending_permissions' + | 'acp_channel_down' + | 'preflight_error' + | 'mcp_budget_warning' + | 'mcp_budget_exhausted' + | 'rate_limit_hits' + | 'workspace_status_unavailable'; + severity: IssueSeverity; + message: string; + section?: string; +} + +export interface ParseDaemonStatusDetailResult { + ok: boolean; + detail?: DaemonStatusDetail; +} + +export interface BuildDaemonStatusOptions { + opts: ServeOptions; + boundWorkspace: string; + bridge: AcpSessionBridge; + workspace: DaemonWorkspaceService; + daemonLog?: DaemonLogger; + qwenCodeVersion?: string; + acpHandle?: AcpHttpHandle; + rateLimiter?: RateLimiterInstance; + getRestSseActive: () => number; + features: readonly string[]; + protocolVersions: ServeProtocolVersions; + supportedDeviceFlowProviders: readonly string[]; + deviceFlowRegistry: DeviceFlowRegistry; + sessionShellCommandEnabled: boolean; +} + +interface DaemonStatusSection { + status: SectionStatus; + durationMs: number; + summary?: SectionSummary; + data?: T; + error?: { + kind: 'timeout' | 'error'; + message: string; + }; +} + +type WorkspaceStatusSection = DaemonStatusSection; + +interface FullDaemonStatus { + sessions: BridgeDaemonStatusSnapshot['sessions']; + acpConnections: NonNullable< + ReturnType + >['connections']; + workspace: Record; + auth: { + supportedDeviceFlowProviders: string[]; + pendingDeviceFlowCount: number; + }; +} + +class SectionTimeoutError extends Error { + constructor( + readonly section: string, + readonly timeoutMs: number, + ) { + super(`${section} status timed out after ${timeoutMs}ms`); + this.name = 'SectionTimeoutError'; + } +} + +export function parseDaemonStatusDetail( + raw: unknown, +): ParseDaemonStatusDetailResult { + if (raw === undefined) return { ok: true, detail: 'summary' }; + if (raw === 'summary' || raw === 'full') { + return { ok: true, detail: raw }; + } + return { ok: false }; +} + +export async function buildDaemonStatusResponse( + detail: DaemonStatusDetail, + input: BuildDaemonStatusOptions, +): Promise> { + const bridgeSnapshot = input.bridge.getDaemonStatusSnapshot(); + const acpSnapshot = input.acpHandle?.registry.getSnapshot(); + const rateLimitHits = input.rateLimiter?.getHitCounts() ?? zeroRateHits(); + const issues: DaemonStatusIssue[] = []; + let full: FullDaemonStatus | undefined; + + pushRuntimeIssues(issues, bridgeSnapshot, acpSnapshot, rateLimitHits, input); + + if (detail === 'full') { + full = await buildFullStatus(input, bridgeSnapshot, acpSnapshot); + pushFullIssues(issues, full); + } + + return { + v: 1, + detail, + generatedAt: new Date().toISOString(), + status: rollupStatus(issues), + issues, + daemon: { + pid: process.pid, + uptimeMs: Math.round(process.uptime() * 1000), + mode: input.opts.mode, + workspaceCwd: input.boundWorkspace, + ...(input.qwenCodeVersion + ? { qwenCodeVersion: input.qwenCodeVersion } + : {}), + ...(input.daemonLog?.getDaemonId() + ? { daemonId: input.daemonLog.getDaemonId() } + : {}), + ...(detail === 'full' && input.daemonLog?.getLogPath() + ? { logPath: input.daemonLog.getLogPath() } + : {}), + }, + security: { + tokenConfigured: Boolean(input.opts.token), + requireAuth: input.opts.requireAuth === true, + loopbackBind: isLoopbackBind(input.opts.hostname), + allowOriginConfigured: + input.opts.allowOrigins !== undefined && + input.opts.allowOrigins.length > 0, + allowOriginMode: allowOriginMode(input.opts.allowOrigins), + sessionShellCommandEnabled: input.sessionShellCommandEnabled, + }, + limits: { + maxSessions: bridgeSnapshot.limits.maxSessions, + maxPendingPromptsPerSession: + bridgeSnapshot.limits.maxPendingPromptsPerSession, + listenerMaxConnections: listenerMaxConnections(input.opts.maxConnections), + eventRingSize: bridgeSnapshot.limits.eventRingSize, + promptDeadlineMs: positiveFiniteOrNull(input.opts.promptDeadlineMs), + writerIdleTimeoutMs: positiveFiniteOrNull(input.opts.writerIdleTimeoutMs), + channelIdleTimeoutMs: bridgeSnapshot.limits.channelIdleTimeoutMs, + sessionIdleTimeoutMs: bridgeSnapshot.limits.sessionIdleTimeoutMs, + acpConnectionCap: acpSnapshot?.connectionCap ?? null, + }, + capabilities: { + protocolVersions: input.protocolVersions, + features: [...input.features], + }, + runtime: { + sessions: { active: bridgeSnapshot.sessionCount }, + permissions: { + pending: bridgeSnapshot.pendingPermissionCount, + policy: bridgeSnapshot.permissionPolicy, + }, + channel: { live: bridgeSnapshot.channelLive }, + transport: { + restSseActive: input.getRestSseActive(), + acp: { + enabled: acpSnapshot !== undefined, + connections: acpSnapshot?.connectionCount ?? 0, + connectionStreams: acpSnapshot?.connectionStreams ?? 0, + sessionStreams: acpSnapshot?.sessionStreams ?? 0, + sseStreams: acpSnapshot?.sseStreams ?? 0, + wsStreams: acpSnapshot?.wsStreams ?? 0, + pendingClientRequests: acpSnapshot?.pendingClientRequests ?? 0, + }, + }, + rateLimit: { + enabled: input.opts.rateLimit === true, + rejectedSinceStart: rateLimitHits, + }, + process: process.memoryUsage(), + }, + ...(full ? { full } : {}), + }; +} + +async function buildFullStatus( + input: BuildDaemonStatusOptions, + bridgeSnapshot: BridgeDaemonStatusSnapshot, + acpSnapshot: ReturnType | undefined, +): Promise { + const ctx: WorkspaceRequestContext = { + route: 'GET /daemon/status', + workspaceCwd: input.boundWorkspace, + }; + const [mcp, skills, tools, providers, env, preflight, hooks, extensions] = + await Promise.all([ + collectSection('workspace.mcp', () => + input.workspace.getWorkspaceMcpStatus(ctx), + ), + collectSection('workspace.skills', () => + input.workspace.getWorkspaceSkillsStatus(ctx), + ), + collectSection('workspace.tools', () => + input.bridge.getWorkspaceToolsStatus(), + ), + collectSection('workspace.providers', () => + input.workspace.getWorkspaceProvidersStatus(ctx), + ), + collectSection('workspace.env', () => + input.workspace.getWorkspaceEnvStatus(ctx), + ), + collectSection('workspace.preflight', () => + input.workspace.getWorkspacePreflightStatus(ctx), + ), + collectSection('workspace.hooks', () => + input.workspace.getWorkspaceHooksStatus(ctx), + ), + collectSection('workspace.extensions', () => + input.workspace.getWorkspaceExtensionsStatus(ctx), + ), + ]); + + return { + sessions: bridgeSnapshot.sessions, + acpConnections: acpSnapshot?.connections ?? [], + workspace: { + mcp, + skills, + tools, + providers, + env, + preflight, + hooks, + extensions, + }, + auth: { + supportedDeviceFlowProviders: [...input.supportedDeviceFlowProviders], + pendingDeviceFlowCount: input.deviceFlowRegistry.listPending().length, + }, + }; +} + +async function collectSection( + name: string, + read: () => Promise, +): Promise> { + const startMs = Date.now(); + try { + const data = await withTimeout(read(), name, SECTION_TIMEOUT_MS); + return { + status: inferSectionStatus(data), + durationMs: Date.now() - startMs, + summary: summarizeStatusData(data), + data, + }; + } catch (err) { + return { + status: 'unavailable', + durationMs: Date.now() - startMs, + error: { + kind: err instanceof SectionTimeoutError ? 'timeout' : 'error', + message: err instanceof Error ? err.message : String(err), + }, + }; + } +} + +async function withTimeout( + promise: Promise, + section: string, + timeoutMs: number, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new SectionTimeoutError(section, timeoutMs)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function pushRuntimeIssues( + issues: DaemonStatusIssue[], + bridgeSnapshot: BridgeDaemonStatusSnapshot, + acpSnapshot: ReturnType | undefined, + rateLimitHits: Record, + input: BuildDaemonStatusOptions, +): void { + if ( + bridgeSnapshot.limits.maxSessions !== null && + bridgeSnapshot.limits.maxSessions > 0 && + bridgeSnapshot.sessionCount / bridgeSnapshot.limits.maxSessions >= + CAPACITY_WARNING_RATIO + ) { + issues.push({ + code: 'session_capacity_high', + severity: 'warning', + message: `Active sessions are at ${bridgeSnapshot.sessionCount}/${bridgeSnapshot.limits.maxSessions}.`, + }); + } + + if ( + acpSnapshot !== undefined && + acpSnapshot.connectionCap !== null && + acpSnapshot.connectionCap > 0 && + acpSnapshot.connectionCount / acpSnapshot.connectionCap >= + CAPACITY_WARNING_RATIO + ) { + issues.push({ + code: 'connection_capacity_high', + severity: 'warning', + message: `ACP connections are at ${acpSnapshot.connectionCount}/${acpSnapshot.connectionCap}.`, + }); + } + + if (bridgeSnapshot.pendingPermissionCount > 0) { + issues.push({ + code: 'pending_permissions', + severity: 'warning', + message: `${bridgeSnapshot.pendingPermissionCount} permission request(s) are pending.`, + }); + } + + if (bridgeSnapshot.sessionCount > 0 && !bridgeSnapshot.channelLive) { + issues.push({ + code: 'acp_channel_down', + severity: 'error', + message: 'Active sessions exist but the ACP channel is not live.', + }); + } + + if (input.opts.rateLimit === true && sumRateHits(rateLimitHits) > 0) { + issues.push({ + code: 'rate_limit_hits', + severity: 'warning', + message: `${sumRateHits(rateLimitHits)} request(s) have been rejected by rate limiting since start.`, + }); + } +} + +function pushFullIssues( + issues: DaemonStatusIssue[], + full: FullDaemonStatus, +): void { + for (const [name, section] of Object.entries(full.workspace)) { + if (section.status === 'unavailable') { + issues.push({ + code: 'workspace_status_unavailable', + severity: 'warning', + section: name, + message: `${name} status is unavailable.`, + }); + } + } + + const preflight = full.workspace['preflight']; + if (preflight && sectionHasStatus(preflight, 'error')) { + issues.push({ + code: 'preflight_error', + severity: 'error', + section: 'preflight', + message: 'Workspace preflight reports an error.', + }); + } + + const mcp = full.workspace['mcp']; + const mcpBudget = mcp ? inspectMcpBudget(mcp) : undefined; + if (mcpBudget === 'exhausted') { + issues.push({ + code: 'mcp_budget_exhausted', + severity: 'error', + section: 'mcp', + message: 'MCP client budget is exhausted.', + }); + } else if (mcpBudget === 'warning') { + issues.push({ + code: 'mcp_budget_warning', + severity: 'warning', + section: 'mcp', + message: 'MCP client budget is near capacity.', + }); + } +} + +function inferSectionStatus(data: unknown): DaemonStatusLevel { + const statuses = collectStatuses(data); + if (statuses.includes('error')) return 'error'; + if (statuses.includes('warning')) return 'warning'; + return 'ok'; +} + +function summarizeStatusData(data: unknown): SectionSummary { + const summary: SectionSummary = {}; + if (!isRecord(data)) return summary; + + copyBoolean(data, summary, 'initialized'); + copyBoolean(data, summary, 'acpChannelLive'); + copyString(data, summary, 'discoveryState'); + copyString(data, summary, 'budgetMode'); + copyNumber(data, summary, 'clientCount'); + copyNumber(data, summary, 'clientBudget'); + + for (const key of [ + 'cells', + 'errors', + 'servers', + 'budgets', + 'skills', + 'tools', + 'providers', + 'hooks', + 'extensions', + ]) { + const value = data[key]; + if (Array.isArray(value)) { + summary[`${key}Count`] = value.length; + } + } + + return summary; +} + +function collectStatuses(data: unknown): string[] { + const statuses: string[] = []; + visitStatusContainers(data, (record) => { + const status = record['status']; + if (typeof status === 'string') statuses.push(status); + }); + return statuses; +} + +function sectionHasStatus( + section: WorkspaceStatusSection, + status: string, +): boolean { + return collectStatuses(section.data).includes(status); +} + +function inspectMcpBudget( + section: WorkspaceStatusSection, +): 'warning' | 'exhausted' | undefined { + const data = section.data; + if (!isRecord(data)) return undefined; + const budgetIssue = inspectBudgetContainers(data); + if (budgetIssue) return budgetIssue; + + const clientCount = numberValue(data['clientCount']); + const clientBudget = numberValue(data['clientBudget']); + if ( + clientCount !== undefined && + clientBudget !== undefined && + clientBudget > 0 + ) { + const ratio = clientCount / clientBudget; + if (ratio >= 1) return 'exhausted'; + if (ratio >= 0.75) return 'warning'; + } + return undefined; +} + +function inspectBudgetContainers( + data: unknown, +): 'warning' | 'exhausted' | undefined { + let result: 'warning' | 'exhausted' | undefined; + visitStatusContainers(data, (record) => { + if (result === 'exhausted') return; + const errorKind = record['errorKind']; + const disabledReason = record['disabledReason']; + const status = record['status']; + const kind = record['kind']; + const refusedCount = numberValue(record['refusedCount']); + if ( + errorKind === 'budget_exhausted' || + disabledReason === 'budget' || + (kind === 'mcp_budget' && status === 'error') || + (refusedCount !== undefined && refusedCount > 0) + ) { + result = 'exhausted'; + return; + } + if (kind === 'mcp_budget' && status === 'warning') { + result = 'warning'; + } + }); + return result; +} + +function visitStatusContainers( + data: unknown, + visit: (record: StatusRecord) => void, +): void { + if (!isRecord(data)) return; + visit(data); + for (const key of [ + 'cells', + 'errors', + 'servers', + 'budgets', + 'skills', + 'tools', + 'providers', + 'hooks', + 'extensions', + ]) { + const value = data[key]; + if (!Array.isArray(value)) continue; + for (const item of value) visitStatusContainers(item, visit); + } +} + +function rollupStatus(issues: readonly DaemonStatusIssue[]): DaemonStatusLevel { + if (issues.some((issue) => issue.severity === 'error')) return 'error'; + if (issues.length > 0) return 'warning'; + return 'ok'; +} + +function allowOriginMode( + allowOrigins: readonly string[] | undefined, +): 'none' | 'specific' | 'any' { + if (!allowOrigins || allowOrigins.length === 0) return 'none'; + return allowOrigins.includes('*') ? 'any' : 'specific'; +} + +function listenerMaxConnections(value: number | undefined): number | null { + if (value === undefined) return DEFAULT_LISTENER_MAX_CONNECTIONS; + if (value === 0 || value === Infinity) return null; + return Number.isFinite(value) && value > 0 ? value : null; +} + +function positiveFiniteOrNull(value: number | undefined): number | null { + return value !== undefined && Number.isFinite(value) && value > 0 + ? value + : null; +} + +function zeroRateHits(): Record { + return { prompt: 0, mutation: 0, read: 0 }; +} + +function sumRateHits(hits: Record): number { + return hits.prompt + hits.mutation + hits.read; +} + +function isRecord(value: unknown): value is StatusRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +function copyBoolean( + from: StatusRecord, + to: SectionSummary, + key: string, +): void { + const value = from[key]; + if (typeof value === 'boolean') to[key] = value; +} + +function copyString(from: StatusRecord, to: SectionSummary, key: string): void { + const value = from[key]; + if (typeof value === 'string') to[key] = value; +} + +function copyNumber(from: StatusRecord, to: SectionSummary, key: string): void { + const value = numberValue(from[key]); + if (value !== undefined) to[key] = value; +} diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a3c25ca00e1..65145f49605 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -61,6 +61,7 @@ import { WorkspaceMismatchError, type BridgeHeartbeatResult, type BridgeHeartbeatState, + type BridgeDaemonStatusSnapshot, type BridgeRestoredSession, type BridgeClientRequestContext, type BridgeRestoreSessionRequest, @@ -120,6 +121,7 @@ const WS_BOUND = path.resolve(path.sep, 'work', 'bound'); const WS_DIFFERENT = path.resolve(path.sep, 'work', 'different'); const EXPECTED_STAGE1_FEATURES = [ 'health', + 'daemon_status', 'capabilities', 'session_create', 'session_scope_override', @@ -235,9 +237,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'workspace_hooks' && f !== 'session_hooks' && f !== 'workspace_extensions' && - f !== 'session_branch' && - f !== 'rate_limit' && - f !== 'workspace_reload', + f !== 'session_branch', ), 'workspace_settings', 'workspace_init', @@ -436,6 +436,7 @@ interface FakeBridgeOpts { signal?: AbortSignal, context?: BridgeClientRequestContext, ) => Promise<{ exitCode: number | null; output: string; aborted: boolean }>; + daemonStatusSnapshotImpl?: () => BridgeDaemonStatusSnapshot; } interface FakeBridge extends AcpSessionBridge { @@ -876,6 +877,22 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { output: `$ ${command}`, aborted: false, })); + const daemonStatusSnapshotImpl = + opts.daemonStatusSnapshotImpl ?? + (() => ({ + limits: { + maxSessions: 20, + maxPendingPromptsPerSession: 5, + eventRingSize: 8000, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + }, + sessionCount: 0, + pendingPermissionCount: 0, + channelLive: false, + permissionPolicy: 'first-responder' as const, + sessions: [], + })); return { // F3 Commit 6 — `AcpSessionBridge.permissionPolicy` is required so // `/capabilities` can expose `policy.permission`. Tests don't @@ -947,6 +964,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { get pendingPermissionCount() { return 0; }, + getDaemonStatusSnapshot() { + return daemonStatusSnapshotImpl(); + }, async spawnOrAttach(req) { const result = await spawnImpl(req); calls.push(req); @@ -1209,39 +1229,39 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { isChannelLive() { return false; }, - async queryWorkspaceStatus(method: string, idle: () => T) { + async queryWorkspaceStatus(method: string, idle: () => T): Promise { // Dispatch based on method to mirror ACP child routing. if (method === 'qwen/status/workspace/mcp') { workspaceMcpCalls += 1; - return workspaceMcpImpl(); + return workspaceMcpImpl() as Promise; } if (method === 'qwen/status/workspace/skills') { workspaceSkillsCalls += 1; - return workspaceSkillsImpl(); + return workspaceSkillsImpl() as Promise; } if (method === 'qwen/status/workspace/providers') { workspaceProvidersCalls += 1; - return workspaceProvidersImpl(); + return workspaceProvidersImpl() as Promise; } if (method === 'qwen/status/workspace/preflight') { workspacePreflightCalls += 1; - return workspacePreflightImpl(); + return workspacePreflightImpl() as Promise; } if (method === 'qwen/status/workspace/hooks') { workspaceHooksCalls += 1; - return workspaceHooksImpl(); + return workspaceHooksImpl() as Promise; } if (method === 'qwen/status/workspace/extensions') { workspaceExtensionsCalls += 1; - return workspaceExtensionsImpl(); + return workspaceExtensionsImpl() as Promise; } return idle(); }, - async invokeWorkspaceCommand( + async invokeWorkspaceCommand( method: string, params?: Record, _opts?: { timeoutMs?: number }, - ) { + ): Promise { if (method === 'qwen/control/workspace/mcp/restart') { const serverName = (params?.['serverName'] as string) ?? ''; const entryIndex = params?.['entryIndex'] as number | undefined; @@ -1253,9 +1273,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { serverName, undefined, entryIndex !== undefined ? { entryIndex } : undefined, - ); + ) as Promise; } - return {}; + return {} as T; }, async shutdown() { shutdownCalls += 1; @@ -6234,6 +6254,211 @@ describe('createServeApp', () => { }); }); + describe('GET /daemon/status', () => { + it('requires bearer auth when a token is configured', async () => { + const app = createServeApp( + { ...baseOpts, token: 'secret' }, + undefined, + { + bridge: fakeBridge(), + }, + ); + + const noAuth = await request(app) + .get('/daemon/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(noAuth.status).toBe(401); + + const withAuth = await request(app) + .get('/daemon/status') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(withAuth.status).toBe(200); + expect(withAuth.body).toMatchObject({ + v: 1, + detail: 'summary', + }); + }); + + it('returns summary diagnostics without querying workspace status', async () => { + const bridge = fakeBridge(); + const daemonLog = fakeDaemonLog(); + const app = createServeApp(baseOpts, undefined, { + bridge, + daemonLog, + qwenCodeVersion: '1.2.3-test', + }); + + const res = await request(app) + .get('/daemon/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + detail: 'summary', + status: 'ok', + issues: [], + daemon: { + pid: process.pid, + mode: 'http-bridge', + workspaceCwd: expect.any(String), + qwenCodeVersion: '1.2.3-test', + daemonId: 'test-daemon', + }, + security: { + tokenConfigured: false, + requireAuth: false, + loopbackBind: true, + allowOriginConfigured: false, + allowOriginMode: 'none', + sessionShellCommandEnabled: false, + }, + runtime: { + sessions: { active: 0 }, + permissions: { pending: 0 }, + channel: { live: false }, + transport: { + restSseActive: 0, + acp: { + enabled: true, + connections: 0, + connectionStreams: 0, + sessionStreams: 0, + sseStreams: 0, + wsStreams: 0, + pendingClientRequests: 0, + }, + }, + }, + }); + expect(res.body.generatedAt).toEqual(expect.any(String)); + expect(res.body.daemon).not.toHaveProperty('logPath'); + expect(bridge.workspaceMcpCalls).toBe(0); + expect(bridge.workspaceSkillsCalls).toBe(0); + expect(bridge.workspaceToolsCalls).toBe(0); + expect(bridge.workspaceProvidersCalls).toBe(0); + expect(bridge.workspaceEnvCalls).toBe(0); + expect(bridge.workspacePreflightCalls).toBe(0); + expect(bridge.workspaceHooksCalls).toBe(0); + expect(bridge.workspaceExtensionsCalls).toBe(0); + }); + + it('rejects unknown detail values', async () => { + const app = createServeApp(baseOpts, undefined, { + bridge: fakeBridge(), + }); + + const res = await request(app) + .get('/daemon/status?detail=verbose') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'invalid_detail', + }); + }); + + it('returns full diagnostics with independent workspace section degradation', async () => { + const bridge = fakeBridge({ + daemonStatusSnapshotImpl: () => ({ + limits: { + maxSessions: 20, + maxPendingPromptsPerSession: 5, + eventRingSize: 8000, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + }, + sessionCount: 1, + pendingPermissionCount: 0, + channelLive: true, + permissionPolicy: 'first-responder', + sessions: [ + { + sessionId: 'session-1', + workspaceCwd: WS_BOUND, + createdAt: '2026-06-01T00:00:00.000Z', + clientCount: 2, + subscriberCount: 1, + attachCount: 1, + pendingPromptCount: 0, + pendingPermissionCount: 0, + hasActivePrompt: false, + lastEventId: 4, + }, + ], + }), + workspaceMcpImpl: async () => { + throw new Error('mcp status unavailable'); + }, + workspacePreflightImpl: async () => ({ + v: 1 as const, + workspaceCwd: WS_BOUND, + initialized: true as const, + acpChannelLive: true, + cells: [ + { + kind: 'git' as const, + locality: 'daemon' as const, + status: 'error' as const, + error: 'git missing', + }, + ], + }), + }); + const app = createServeApp(baseOpts, undefined, { + bridge, + boundWorkspace: WS_BOUND, + }); + + const res = await request(app) + .get('/daemon/status?detail=full') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + detail: 'full', + status: 'error', + full: { + sessions: [ + { + sessionId: 'session-1', + clientCount: 2, + subscriberCount: 1, + }, + ], + workspace: { + mcp: { + status: 'unavailable', + error: { kind: 'error' }, + }, + preflight: { + status: 'error', + summary: { cellsCount: expect.any(Number) }, + }, + }, + auth: { + supportedDeviceFlowProviders: ['qwen-oauth'], + pendingDeviceFlowCount: 0, + }, + }, + }); + expect(res.body.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'workspace_status_unavailable', + section: 'mcp', + }), + expect.objectContaining({ + code: 'preflight_error', + section: 'preflight', + }), + ]), + ); + expect(bridge.workspaceMcpCalls).toBe(1); + }); + }); + describe('session limit (chiga0 Rec 3 — --max-sessions)', () => { it('503 + Retry-After + structured error when bridge throws SessionLimitExceededError', async () => { const bridge = fakeBridge({ diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 4be51e46231..0dd0c1f1086 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -54,7 +54,11 @@ import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { isServeDebugMode } from './debugMode.js'; import { SUPPORTED_LANGUAGES } from '../i18n/index.js'; import { isLoopbackBind } from './loopbackBinds.js'; -import { mountAcpHttp } from './acpHttp/index.js'; +import { mountAcpHttp, type AcpHttpHandle } from './acpHttp/index.js'; +import { + buildDaemonStatusResponse, + parseDaemonStatusDetail, +} from './daemonStatus.js'; import { canonicalizeWorkspace, CancelSentinelCollisionError, @@ -674,6 +678,9 @@ function resolveDaemonTelemetryRoute( if (req.method === 'POST' && path === '/sessions/delete') { return { route: 'POST /sessions/delete' }; } + if (req.method === 'GET' && path === '/daemon/status') { + return { route: 'GET /daemon/status' }; + } const sessionAction = path.match( /^\/session\/([^/]+)\/(load|resume|prompt|cancel|recap|btw|model|shell|detach|rewind|approval-mode|language|a2ui-action)$/, ); @@ -882,6 +889,7 @@ function advertisedMaxPendingPromptsPerSession( * * Supported routes: * - `GET /health` + * - `GET /daemon/status` * - `GET /capabilities` * - `GET /workspace/mcp` * - `GET /workspace/skills` @@ -1366,6 +1374,65 @@ export function createServeApp( } const LANGUAGE_CODES = [...SUPPORTED_LANGUAGES.map((l) => l.code), 'auto']; + const currentServeFeatures = () => + getAdvertisedServeFeatures(undefined, { + requireAuth: opts.requireAuth === true, + mcpPoolActive: opts.mcpPoolActive !== false, + allowOriginActive: + opts.allowOrigins !== undefined && opts.allowOrigins.length > 0, + ...(opts.promptDeadlineMs !== undefined + ? { promptDeadlineMs: opts.promptDeadlineMs } + : {}), + ...(opts.writerIdleTimeoutMs !== undefined + ? { writerIdleTimeoutMs: opts.writerIdleTimeoutMs } + : {}), + persistSettingAvailable: deps.persistSetting !== undefined, + sessionShellCommandEnabled, + rateLimit: opts.rateLimit === true, + reloadAvailable: deps.workspace !== undefined, + }); + const acpHandleRef: { current?: AcpHttpHandle } = {}; + + app.get('/daemon/status', async (req, res) => { + const detail = parseDaemonStatusDetail(req.query['detail']); + if (!detail.ok || !detail.detail) { + res.status(400).json({ + error: 'detail must be one of: summary, full', + code: 'invalid_detail', + }); + return; + } + try { + res.status(200).json( + await buildDaemonStatusResponse(detail.detail, { + opts, + boundWorkspace, + bridge, + workspace, + daemonLog, + qwenCodeVersion: deps.qwenCodeVersion, + acpHandle: acpHandleRef.current, + rateLimiter, + getRestSseActive: getActiveSseCount, + features: currentServeFeatures(), + protocolVersions: getServeProtocolVersions(), + supportedDeviceFlowProviders: Array.from( + deviceFlowProviderMap.keys(), + ), + deviceFlowRegistry, + sessionShellCommandEnabled, + }), + ); + } catch (err) { + writeStderrLine( + `qwen serve: /daemon/status failed: ${err instanceof Error ? err.message : String(err)}`, + ); + res.status(500).json({ + error: 'Failed to build daemon status', + code: 'daemon_status_failed', + }); + } + }); app.get('/capabilities', (_req, res) => { const envelope: CapabilitiesEnvelope = { @@ -1375,22 +1442,7 @@ export function createServeApp( ? { qwenCodeVersion: deps.qwenCodeVersion } : {}), mode: opts.mode, - features: getAdvertisedServeFeatures(undefined, { - requireAuth: opts.requireAuth === true, - mcpPoolActive: opts.mcpPoolActive !== false, - allowOriginActive: - opts.allowOrigins !== undefined && opts.allowOrigins.length > 0, - ...(opts.promptDeadlineMs !== undefined - ? { promptDeadlineMs: opts.promptDeadlineMs } - : {}), - ...(opts.writerIdleTimeoutMs !== undefined - ? { writerIdleTimeoutMs: opts.writerIdleTimeoutMs } - : {}), - persistSettingAvailable: deps.persistSetting !== undefined, - sessionShellCommandEnabled, - rateLimit: opts.rateLimit === true, - reloadAvailable: deps.workspace !== undefined, - }), + features: currentServeFeatures(), modelServices: [], // Surface the bound workspace so clients can detect mismatch // pre-flight and omit `cwd` on `POST /session`. @@ -3675,7 +3727,7 @@ export function createServeApp( // decision. Mounted AFTER the REST routes (distinct path, no overlap) // and BEFORE the final error handler so malformed `/acp` bodies still // route through the JSON error contract below. - const acpHandle = mountAcpHttp(app, bridge, { + acpHandleRef.current = mountAcpHttp(app, bridge, { boundWorkspace, workspace, fsFactory, @@ -3684,8 +3736,8 @@ export function createServeApp( sessionShellCommandEnabled, checkRate: rateLimiter?.checkRate, }); - if (acpHandle) { - app.locals['acpHandle'] = acpHandle; + if (acpHandleRef.current) { + app.locals['acpHandle'] = acpHandleRef.current; } // Final error handler. `express.json()` throws `SyntaxError` (with