Skip to content

Commit c89173d

Browse files
committed
feat(serve): introduce ServeErrorKind and BridgeTimeoutError (#4175 Wave 3 PR 13)
Lay the type foundation for `/workspace/preflight` and `/workspace/env` (and the eventual MCP guardrails route) so cells emitted by all three share a closed `errorKind` taxonomy: - `SERVE_ERROR_KINDS` literal-list + `ServeErrorKind` union — the seven values from #4175 (`missing_binary`, `blocked_egress`, `auth_env_error`, `init_timeout`, `protocol_error`, `missing_file`, `parse_error`). - `BridgeTimeoutError` typed class — `withTimeout` now rejects with this rather than a plain `Error`, letting `mapDomainErrorToErrorKind` recognize init / heartbeat / extMethod timeouts via `instanceof` instead of regex-matching message strings. Message format is preserved bit-for-bit. - `mapDomainErrorToErrorKind` helper — one place to classify `BridgeTimeoutError`, `SkillError`, fs ENOENT/EACCES/EPERM, ModelConfigError subclasses (recognized by `name` field — they aren't on the public surface of `@qwen-code/qwen-code-core`), `SyntaxError`, plus message-regex fallbacks for legacy throw sites (`agent channel closed`, missing CLI entry path). - `ServeStatusCell.errorKind` tightened from open `string` to the closed `ServeErrorKind` union. Backward compatible — PR 12 never assigned the field. - SDK mirrors: `DAEMON_ERROR_KINDS` const + `DaemonErrorKind` type; `DaemonStatusCell.errorKind` tightened. Tests: 11 new unit tests in `status.test.ts` covering each mapping rule plus the BridgeTimeoutError shape. No route changes; no behavior changes for any existing path.
1 parent ad23c7a commit c89173d

7 files changed

Lines changed: 254 additions & 7 deletions

File tree

packages/cli/src/serve/httpAcpBridge.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type SubscribeOptions,
2323
} from './eventBus.js';
2424
import {
25+
BridgeTimeoutError,
2526
SERVE_STATUS_EXT_METHODS,
2627
createIdleWorkspaceMcpStatus,
2728
createIdleWorkspaceProvidersStatus,
@@ -3685,10 +3686,7 @@ async function withTimeout<T>(
36853686
): Promise<T> {
36863687
let timer: NodeJS.Timeout | undefined;
36873688
const timeoutP = new Promise<never>((_, reject) => {
3688-
timer = setTimeout(
3689-
() => reject(new Error(`HttpAcpBridge ${label} timed out after ${ms}ms`)),
3690-
ms,
3691-
);
3689+
timer = setTimeout(() => reject(new BridgeTimeoutError(label, ms)), ms);
36923690
});
36933691
try {
36943692
return await Promise.race([p, timeoutP]);

packages/cli/src/serve/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ export {
3535
type ServeProtocolVersions,
3636
} from './capabilities.js';
3737
export {
38+
BridgeTimeoutError,
39+
SERVE_ERROR_KINDS,
3840
SERVE_STATUS_EXT_METHODS,
3941
STATUS_SCHEMA_VERSION,
4042
createIdleWorkspaceMcpStatus,
4143
createIdleWorkspaceProvidersStatus,
4244
createIdleWorkspaceSkillsStatus,
45+
mapDomainErrorToErrorKind,
46+
type ServeErrorKind,
4347
type ServeMcpDiscoveryState,
4448
type ServeMcpServerRuntimeStatus,
4549
type ServeMcpTransport,
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { SkillError } from '@qwen-code/qwen-code-core';
8+
import { describe, expect, it } from 'vitest';
9+
import {
10+
BridgeTimeoutError,
11+
SERVE_ERROR_KINDS,
12+
mapDomainErrorToErrorKind,
13+
} from './status.js';
14+
15+
describe('SERVE_ERROR_KINDS', () => {
16+
it('exposes the seven roadmap-defined error kinds in stable order', () => {
17+
expect(SERVE_ERROR_KINDS).toEqual([
18+
'missing_binary',
19+
'blocked_egress',
20+
'auth_env_error',
21+
'init_timeout',
22+
'protocol_error',
23+
'missing_file',
24+
'parse_error',
25+
]);
26+
});
27+
});
28+
29+
describe('BridgeTimeoutError', () => {
30+
it('preserves the legacy message format and exposes label/timeoutMs', () => {
31+
const err = new BridgeTimeoutError('init', 250);
32+
expect(err.name).toBe('BridgeTimeoutError');
33+
expect(err.message).toBe('HttpAcpBridge init timed out after 250ms');
34+
expect(err.label).toBe('init');
35+
expect(err.timeoutMs).toBe(250);
36+
expect(err).toBeInstanceOf(Error);
37+
});
38+
});
39+
40+
describe('mapDomainErrorToErrorKind', () => {
41+
it('classifies BridgeTimeoutError as init_timeout', () => {
42+
expect(mapDomainErrorToErrorKind(new BridgeTimeoutError('init', 100))).toBe(
43+
'init_timeout',
44+
);
45+
});
46+
47+
it('classifies SkillError(PARSE_ERROR / INVALID_CONFIG / INVALID_NAME) as parse_error', () => {
48+
expect(
49+
mapDomainErrorToErrorKind(new SkillError('bad yaml', 'PARSE_ERROR')),
50+
).toBe('parse_error');
51+
expect(
52+
mapDomainErrorToErrorKind(new SkillError('bad meta', 'INVALID_CONFIG')),
53+
).toBe('parse_error');
54+
expect(
55+
mapDomainErrorToErrorKind(new SkillError('bad name', 'INVALID_NAME')),
56+
).toBe('parse_error');
57+
});
58+
59+
it('classifies SkillError(FILE_ERROR / NOT_FOUND) as missing_file', () => {
60+
expect(
61+
mapDomainErrorToErrorKind(new SkillError('cannot read', 'FILE_ERROR')),
62+
).toBe('missing_file');
63+
expect(
64+
mapDomainErrorToErrorKind(new SkillError('absent', 'NOT_FOUND')),
65+
).toBe('missing_file');
66+
});
67+
68+
it('classifies fs ENOENT/EACCES/EPERM as missing_file', () => {
69+
for (const code of ['ENOENT', 'EACCES', 'EPERM']) {
70+
const err = Object.assign(new Error('fs op failed'), { code });
71+
expect(mapDomainErrorToErrorKind(err)).toBe('missing_file');
72+
}
73+
});
74+
75+
it('classifies SyntaxError as parse_error', () => {
76+
expect(mapDomainErrorToErrorKind(new SyntaxError('bad json'))).toBe(
77+
'parse_error',
78+
);
79+
});
80+
81+
it('classifies ModelConfigError subclasses (recognized via .name) as auth_env_error', () => {
82+
for (const name of [
83+
'StrictMissingCredentialsError',
84+
'StrictMissingModelIdError',
85+
'MissingApiKeyError',
86+
'MissingModelError',
87+
'MissingBaseUrlError',
88+
'MissingAnthropicBaseUrlEnvError',
89+
]) {
90+
const err = new Error(`fake ${name} payload`);
91+
err.name = name;
92+
expect(mapDomainErrorToErrorKind(err)).toBe('auth_env_error');
93+
}
94+
});
95+
96+
it('classifies agent-channel-closed message as protocol_error', () => {
97+
expect(
98+
mapDomainErrorToErrorKind(new Error('agent channel closed mid-request')),
99+
).toBe('protocol_error');
100+
});
101+
102+
it('classifies "Cannot determine CLI entry path" message as missing_binary', () => {
103+
expect(
104+
mapDomainErrorToErrorKind(new Error('Cannot determine CLI entry path')),
105+
).toBe('missing_binary');
106+
});
107+
108+
it('returns undefined for unrelated or non-Error values', () => {
109+
expect(mapDomainErrorToErrorKind(new Error('something else'))).toBe(
110+
undefined,
111+
);
112+
expect(mapDomainErrorToErrorKind('plain string')).toBe(undefined);
113+
expect(mapDomainErrorToErrorKind(null)).toBe(undefined);
114+
expect(mapDomainErrorToErrorKind(undefined)).toBe(undefined);
115+
expect(mapDomainErrorToErrorKind({ code: 'ENOTFOUND' })).toBe(undefined);
116+
});
117+
});

packages/cli/src/serve/status.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,44 @@
55
*/
66

77
import type { AvailableCommand } from '@agentclientprotocol/sdk';
8+
import { SkillError } from '@qwen-code/qwen-code-core';
89

910
export const STATUS_SCHEMA_VERSION = 1 as const;
1011

12+
/**
13+
* Closed enumeration of structured error categories surfaced on diagnostic
14+
* status cells. Cells produced by `/workspace/preflight`, `/workspace/env`,
15+
* and (eventually) the MCP guardrails route share this taxonomy so SDK
16+
* consumers can branch on a known set rather than parsing free-form strings.
17+
*/
18+
export const SERVE_ERROR_KINDS = [
19+
'missing_binary',
20+
'blocked_egress',
21+
'auth_env_error',
22+
'init_timeout',
23+
'protocol_error',
24+
'missing_file',
25+
'parse_error',
26+
] as const;
27+
28+
export type ServeErrorKind = (typeof SERVE_ERROR_KINDS)[number];
29+
30+
/**
31+
* Typed timeout raised by `withTimeout` in the bridge. Lets the diagnostic
32+
* mapping helper recognize init/heartbeat/extMethod timeouts via `instanceof`
33+
* instead of regex-matching message strings.
34+
*/
35+
export class BridgeTimeoutError extends Error {
36+
readonly label: string;
37+
readonly timeoutMs: number;
38+
constructor(label: string, timeoutMs: number) {
39+
super(`HttpAcpBridge ${label} timed out after ${timeoutMs}ms`);
40+
this.name = 'BridgeTimeoutError';
41+
this.label = label;
42+
this.timeoutMs = timeoutMs;
43+
}
44+
}
45+
1146
export const SERVE_STATUS_EXT_METHODS = {
1247
workspaceMcp: 'qwen/status/workspace/mcp',
1348
workspaceSkills: 'qwen/status/workspace/skills',
@@ -28,7 +63,7 @@ export interface ServeStatusCell {
2863
kind: string;
2964
status: ServeStatus;
3065
error?: string;
31-
errorKind?: string;
66+
errorKind?: ServeErrorKind;
3267
hint?: string;
3368
}
3469

@@ -173,3 +208,72 @@ export function createIdleWorkspaceProvidersStatus(
173208
providers: [],
174209
};
175210
}
211+
212+
const SKILL_PARSE_CODES: ReadonlySet<string> = new Set([
213+
'PARSE_ERROR',
214+
'INVALID_CONFIG',
215+
'INVALID_NAME',
216+
]);
217+
218+
const SKILL_FILE_CODES: ReadonlySet<string> = new Set([
219+
'FILE_ERROR',
220+
'NOT_FOUND',
221+
]);
222+
223+
const FS_MISSING_CODES: ReadonlySet<string> = new Set([
224+
'ENOENT',
225+
'EACCES',
226+
'EPERM',
227+
]);
228+
229+
// `ModelConfigError` subclasses live inside core's models module and are not
230+
// re-exported on the public package surface. We classify them by the `name`
231+
// field that each subclass sets via `this.name = new.target.name`.
232+
const MODEL_CONFIG_ERROR_NAMES: ReadonlySet<string> = new Set([
233+
'StrictMissingCredentialsError',
234+
'StrictMissingModelIdError',
235+
'MissingApiKeyError',
236+
'MissingModelError',
237+
'MissingBaseUrlError',
238+
'MissingAnthropicBaseUrlEnvError',
239+
]);
240+
241+
/**
242+
* Map a thrown domain error onto one of the closed `ServeErrorKind` literals
243+
* so diagnostic cells can render structured remediation. Recognition is
244+
* `instanceof`-first; message-string heuristics are a last-resort fallback for
245+
* legacy throw sites that have not yet been retyped.
246+
*
247+
* Returns `undefined` when no rule matches; callers should leave `errorKind`
248+
* unset rather than coercing an unrelated error into a misleading category.
249+
*/
250+
export function mapDomainErrorToErrorKind(
251+
err: unknown,
252+
): ServeErrorKind | undefined {
253+
if (err instanceof BridgeTimeoutError) return 'init_timeout';
254+
if (err instanceof SkillError) {
255+
if (SKILL_PARSE_CODES.has(err.code)) return 'parse_error';
256+
if (SKILL_FILE_CODES.has(err.code)) return 'missing_file';
257+
return undefined;
258+
}
259+
if (err instanceof SyntaxError) return 'parse_error';
260+
if (!(err instanceof Error)) return undefined;
261+
if (MODEL_CONFIG_ERROR_NAMES.has(err.name)) return 'auth_env_error';
262+
const code = (err as { code?: unknown }).code;
263+
if (typeof code === 'string' && FS_MISSING_CODES.has(code)) {
264+
return 'missing_file';
265+
}
266+
// TODO(follow-up): convert the two throw sites that produce these
267+
// messages (`getChannelClosedReject` in `httpAcpBridge.ts` and the
268+
// `defaultSpawnChannelFactory` "Cannot determine CLI entry path" Error)
269+
// to typed classes (`BridgeChannelClosedError`, `MissingCliEntryError`)
270+
// and replace the regex match with `instanceof`. Until then a foreign
271+
// error message that happens to contain either phrase will misclassify;
272+
// the false-positive surface is small (the phrases are bridge-specific)
273+
// but the cleaner fix belongs in the same wave as PR 22's bridge
274+
// extraction.
275+
const msg = err.message;
276+
if (/agent channel closed/i.test(msg)) return 'protocol_error';
277+
if (/Cannot determine CLI entry path/i.test(msg)) return 'missing_binary';
278+
return undefined;
279+
}

packages/sdk-typescript/src/daemon/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ export {
2727
reduceDaemonSessionEvents,
2828
} from './events.js';
2929
export { parseSseStream, SseFramingError } from './sse.js';
30-
export { DaemonCapabilityMissingError, requireWorkspaceCwd } from './types.js';
30+
export {
31+
DAEMON_ERROR_KINDS,
32+
DaemonCapabilityMissingError,
33+
requireWorkspaceCwd,
34+
} from './types.js';
3135
export type {
3236
DaemonClientEvictedData,
3337
DaemonClientEvictedEvent,
@@ -66,6 +70,7 @@ export type {
6670
export type {
6771
DaemonAvailableCommand,
6872
DaemonCapabilities,
73+
DaemonErrorKind,
6974
DaemonEvent,
7075
DaemonMcpDiscoveryState,
7176
DaemonMcpServerRuntimeStatus,

packages/sdk-typescript/src/daemon/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,28 @@ export type DaemonStatus =
177177
| 'not_started'
178178
| 'unknown';
179179

180+
/**
181+
* Closed taxonomy of structured error categories surfaced on diagnostic
182+
* status cells (workspace preflight, env, MCP guardrails). SDK consumers
183+
* can switch on a known set rather than parsing free-form messages.
184+
*/
185+
export const DAEMON_ERROR_KINDS = [
186+
'missing_binary',
187+
'blocked_egress',
188+
'auth_env_error',
189+
'init_timeout',
190+
'protocol_error',
191+
'missing_file',
192+
'parse_error',
193+
] as const;
194+
195+
export type DaemonErrorKind = (typeof DAEMON_ERROR_KINDS)[number];
196+
180197
export interface DaemonStatusCell {
181198
kind: string;
182199
status: DaemonStatus;
183200
error?: string;
184-
errorKind?: string;
201+
errorKind?: DaemonErrorKind;
185202
hint?: string;
186203
}
187204

packages/sdk-typescript/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { SdkLogger } from './utils/logger.js';
55

66
// Daemon HTTP client (talks to `qwen serve`; see GitHub issue #3803)
77
export {
8+
DAEMON_ERROR_KINDS,
89
DaemonCapabilityMissingError,
910
DaemonClient,
1011
DaemonHttpError,
@@ -21,6 +22,7 @@ export {
2122
type CreateSessionRequest,
2223
type DaemonAvailableCommand,
2324
type DaemonCapabilities,
25+
type DaemonErrorKind,
2426
type DaemonClientEvictedData,
2527
type DaemonClientEvictedEvent,
2628
type DaemonClientOptions,

0 commit comments

Comments
 (0)