Skip to content

Commit 5441387

Browse files
committed
feat(serve): add /workspace/preflight daemon-cells path (#4175 Wave 3 PR 13)
Wire the preflight route. Daemon-level cells are populated unconditionally from `process.*` and `node:fs`; ACP-level cells fall back to `not_started` placeholders when no child is alive so a poll never spawns one. - `workspace_preflight` capability tag. - `ServePreflightKind` discriminant (12 values: node_version, cli_entry, workspace_dir, ripgrep, git, npm — daemon-level — plus auth, mcp_discovery, skills, providers, tool_registry, egress — ACP-level). - `ServePreflightCell extends ServeStatusCell` with `locality: 'daemon' | 'acp'` + free-form `detail`. `ServeWorkspacePreflightStatus` envelope. - `createIdleAcpPreflightCells()` factory: emits the six ACP-level cells with `status: 'not_started'` + a uniform `hint` so the bridge can stitch them in alongside daemon cells without ever calling ACP. - `bridge.getWorkspacePreflightStatus()`: - Daemon cells via `buildDaemonPreflightCells` (Promise.all over Node-version, CLI-entry resolution mirroring `defaultSpawnChannelFactory`, `fs.stat` on `boundWorkspace` with ENOENT/EACCES/EPERM mapped to `missing_file`, best-effort `canUseRipgrep` / `getGitVersion` / `getNpmVersion` warnings). - ACP cells via `requestWorkspaceStatus` — idle factory returns the `not_started` placeholders; live path delegates to ACP via the `qwen/status/workspace/preflight` ext method (handler lands in next commit). Bridge-side timeout / channel-close while consulting ACP folds into envelope `errors[]` with `mapDomainErrorToErrorKind` classification; daemon cells still render. - `app.get('/workspace/preflight', ...)` route + JSDoc bullet. - SDK: `DaemonPreflightKind` / `DaemonPreflightCell` / `DaemonWorkspacePreflightStatus` mirrors; `DaemonClient.workspacePreflight()`. Tests: server-level (route returns the bridge payload), bridge-level (idle returns 6 daemon + 6 ACP `not_started` cells without spawning a channel), SDK-level (`workspacePreflight()` round-trip). Capability test updated.
1 parent 8d03e57 commit 5441387

16 files changed

Lines changed: 788 additions & 101 deletions

File tree

docs/users/qwen-serve.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,37 @@ The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight
4040

4141
The daemon also exposes read-only runtime snapshots for client UIs:
4242
`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`,
43-
`GET /workspace/env`, `GET /session/:id/context`, and
44-
`GET /session/:id/supported-commands`. The workspace routes report the live
45-
daemon runtime and do not start the ACP child when idle; an idle daemon
46-
returns `initialized: false` with an empty snapshot.
43+
`GET /workspace/env`, `GET /workspace/preflight`,
44+
`GET /session/:id/context`, and `GET /session/:id/supported-commands`.
45+
46+
`GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers`
47+
report the live ACP runtime and do not start the ACP child when idle; an
48+
idle daemon returns `initialized: false` with an empty snapshot. Once a
49+
session is alive they switch to `initialized: true` and surface the real
50+
state.
51+
52+
`GET /workspace/env` and `GET /workspace/preflight` always answer with
53+
`initialized: true` regardless of ACP state. `env` never consults ACP
54+
(daemon-process info only); `preflight` answers daemon-level cells from
55+
`process.*` and emits `status: 'not_started'` placeholders for ACP-level
56+
cells when the child is idle.
4757

4858
`GET /workspace/env` reports the daemon process's runtime, platform, sandbox,
4959
proxy, and the **presence** (never the value) of whitelisted secret env vars
5060
such as `OPENAI_API_KEY`. Proxy URLs are stripped of credentials and reduced
5161
to `host:port` before they hit the wire. The route always answers from the
5262
daemon process directly and never spawns an ACP child.
5363

64+
`GET /workspace/preflight` returns a list of readiness checks. **Daemon-level
65+
cells** (Node version, CLI entry, workspace directory, ripgrep, git, npm)
66+
always render. **ACP-level cells** (auth, MCP discovery, skills, providers,
67+
tool registry, egress) require a live ACP child — when the daemon is idle
68+
they emit `status: 'not_started'` placeholders rather than spawning ACP just
69+
to populate them. Failures map to a closed `errorKind` enum (`missing_binary`,
70+
`auth_env_error`, `init_timeout`, `protocol_error`, `missing_file`,
71+
`parse_error`, `blocked_egress`) so client UIs can render structured
72+
remediation.
73+
5474
### 3. Open a session
5575

5676
```bash

integration-tests/cli/qwen-serve-routes.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ describe('qwen serve — capabilities envelope', () => {
208208
'workspace_mcp',
209209
'workspace_skills',
210210
'workspace_providers',
211+
'workspace_env',
212+
'workspace_preflight',
211213
'session_context',
212214
'session_supported_commands',
213215
'session_close',

packages/cli/src/serve/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
5555
workspace_skills: { since: 'v1' },
5656
workspace_providers: { since: 'v1' },
5757
workspace_env: { since: 'v1' },
58+
workspace_preflight: { since: 'v1' },
5859
session_context: { since: 'v1' },
5960
session_supported_commands: { since: 'v1' },
6061
session_close: { since: 'v1' },

packages/cli/src/serve/httpAcpBridge.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,158 @@ describe('createHttpAcpBridge', () => {
493493

494494
await bridge.shutdown();
495495
});
496+
497+
it('returns daemon preflight cells with not_started ACP cells when idle', async () => {
498+
const handles: ChannelHandle[] = [];
499+
const bridge = makeBridge({
500+
channelFactory: async () => {
501+
const h = makeChannel();
502+
handles.push(h);
503+
return h.channel;
504+
},
505+
});
506+
507+
const status = await bridge.getWorkspacePreflightStatus();
508+
expect(status).toMatchObject({
509+
v: 1,
510+
workspaceCwd: WS_A,
511+
initialized: true,
512+
acpChannelLive: false,
513+
});
514+
515+
// Daemon-level cells are always populated.
516+
const daemonKinds = status.cells
517+
.filter((c) => c.locality === 'daemon')
518+
.map((c) => c.kind);
519+
expect(daemonKinds).toEqual(
520+
expect.arrayContaining([
521+
'node_version',
522+
'cli_entry',
523+
'workspace_dir',
524+
'ripgrep',
525+
'git',
526+
'npm',
527+
]),
528+
);
529+
530+
// ACP cells fall back to `not_started` placeholders without spawning.
531+
const acpCells = status.cells.filter((c) => c.locality === 'acp');
532+
expect(acpCells.map((c) => c.kind)).toEqual([
533+
'auth',
534+
'mcp_discovery',
535+
'skills',
536+
'providers',
537+
'tool_registry',
538+
'egress',
539+
]);
540+
for (const cell of acpCells) {
541+
expect(cell.status).toBe('not_started');
542+
}
543+
544+
expect(handles).toHaveLength(0);
545+
});
546+
547+
it('merges daemon cells with live ACP-side preflight cells when a channel is up', async () => {
548+
const handles: ChannelHandle[] = [];
549+
const acpCells = [
550+
{ kind: 'auth', status: 'ok', locality: 'acp' },
551+
{ kind: 'mcp_discovery', status: 'ok', locality: 'acp' },
552+
{ kind: 'skills', status: 'ok', locality: 'acp' },
553+
{ kind: 'providers', status: 'ok', locality: 'acp' },
554+
{ kind: 'tool_registry', status: 'ok', locality: 'acp' },
555+
{ kind: 'egress', status: 'not_started', locality: 'acp' },
556+
];
557+
const bridge = makeBridge({
558+
channelFactory: async () => {
559+
const h = makeChannel({
560+
extMethodImpl: (method) => {
561+
if (method === 'qwen/status/workspace/preflight') {
562+
return { cells: acpCells };
563+
}
564+
return { cells: [] };
565+
},
566+
});
567+
handles.push(h);
568+
return h.channel;
569+
},
570+
});
571+
572+
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
573+
const status = await bridge.getWorkspacePreflightStatus();
574+
expect(status.acpChannelLive).toBe(true);
575+
// Daemon cells precede ACP cells in the merged response.
576+
const daemonKinds = status.cells
577+
.filter((c) => c.locality === 'daemon')
578+
.map((c) => c.kind);
579+
expect(daemonKinds).toEqual(
580+
expect.arrayContaining([
581+
'node_version',
582+
'cli_entry',
583+
'workspace_dir',
584+
'ripgrep',
585+
'git',
586+
'npm',
587+
]),
588+
);
589+
const liveAcpCells = status.cells.filter((c) => c.locality === 'acp');
590+
expect(liveAcpCells.map((c) => [c.kind, c.status])).toEqual([
591+
['auth', 'ok'],
592+
['mcp_discovery', 'ok'],
593+
['skills', 'ok'],
594+
['providers', 'ok'],
595+
['tool_registry', 'ok'],
596+
['egress', 'not_started'],
597+
]);
598+
expect(status.errors).toBeUndefined();
599+
600+
await bridge.shutdown();
601+
});
602+
603+
it('falls back to idle ACP cells + envelope error when extMethod throws mid-preflight', async () => {
604+
const handles: ChannelHandle[] = [];
605+
const bridge = makeBridge({
606+
channelFactory: async () => {
607+
const h = makeChannel({
608+
extMethodImpl: () => {
609+
throw new Error('agent channel closed mid-request');
610+
},
611+
});
612+
handles.push(h);
613+
return h.channel;
614+
},
615+
});
616+
617+
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
618+
const status = await bridge.getWorkspacePreflightStatus();
619+
// Daemon cells must still render — that's the route's resilience contract.
620+
const daemonKinds = status.cells
621+
.filter((c) => c.locality === 'daemon')
622+
.map((c) => c.kind);
623+
expect(daemonKinds.length).toBeGreaterThan(0);
624+
// ACP cells fall back to `not_started` placeholders since the extMethod
625+
// call rejected.
626+
const acpCells = status.cells.filter((c) => c.locality === 'acp');
627+
expect(acpCells.length).toBe(6);
628+
for (const cell of acpCells) {
629+
expect(cell.status).toBe('not_started');
630+
}
631+
// The envelope's `errors` array carries the bridge-side failure
632+
// describing which surface failed without sinking the whole route.
633+
// `errorKind` is best-effort via `mapDomainErrorToErrorKind`; here the
634+
// ACP SDK wraps the inner throw as a generic JSON-RPC "Internal
635+
// error" which doesn't match any of the helper's recognition rules
636+
// (the typed `BridgeChannelClosedError` follow-up will close that
637+
// gap), so we only assert the structural shape, not the tag.
638+
expect(status.errors).toBeDefined();
639+
expect(status.errors![0]).toMatchObject({
640+
kind: 'preflight',
641+
status: 'error',
642+
});
643+
expect(status.errors![0].error).toBeTruthy();
644+
645+
await bridge.shutdown();
646+
});
647+
496648
it('requests session status through the existing ACP channel', async () => {
497649
const handles: ChannelHandle[] = [];
498650
const bridge = makeBridge({

0 commit comments

Comments
 (0)