Skip to content

Commit 18b08b9

Browse files
committed
feat(serve): add workspace init route (#4175 Wave 4 PR 17)
Adds POST /workspace/init — strict-gated mutation route that scaffolds an empty `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns under `--memory-file-name` overrides) at the daemon's bound workspace root. Mechanical only — does NOT invoke the LLM. Clients that want AI-driven content fill should follow up with POST /session/:id/prompt. Behavior: - Default refuses to overwrite when the target file exists with non- whitespace content; the bridge throws `WorkspaceInitConflictError` which the route translates to HTTP 409 `workspace_init_conflict` with the resolved path + size in the body - `body: {force: true}` overwrites unconditionally; response carries `action: 'overwrote'` vs `'created'` so SDK consumers can render the difference - Whitespace-only existing content is treated as absent (no 409), matching the local `/init` slash command's behavior so a half- broken init left with an empty file doesn't trap the user - Pure file IO + workspace-scoped event fan-out — no ACP roundtrip; works regardless of whether an ACP child is alive - Fan-outs `workspace_initialized` event with `{path, action}` to every live session SSE bus via the `broadcastWorkspaceEvent` helper introduced in commit 4 SDK additions: - `DaemonClient.initWorkspace(opts?, clientId?)` with conditional body emission (omits `force` unless explicitly true so older daemons that reject unknown body fields stay compatible) - `DaemonInitWorkspaceResult` + `DaemonWorkspaceInitializedEvent` typed event with runtime guard (`isWorkspaceInitializedData`), reducer integration on `DaemonSessionViewState` (`workspaceInitCount` / `lastWorkspaceInit`) New typed error class `WorkspaceInitConflictError` exported from `packages/cli/src/serve/index.ts` so direct embeds can match it via `instanceof`. New capability tag `workspace_init` (always-on, since v1). 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
1 parent b7fd920 commit 18b08b9

13 files changed

Lines changed: 513 additions & 3 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ describe('qwen serve — capabilities envelope', () => {
226226
// #4175 Wave 4 PR 17.
227227
'session_approval_mode_control',
228228
'workspace_tool_toggle',
229+
'workspace_init',
229230
]);
230231
});
231232
});

packages/cli/src/serve/capabilities.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export const SERVE_CAPABILITY_REGISTRY = {
123123
// unregistered — the toggle takes effect on the next ACP child spawn
124124
// (`tools.disabled` is consulted at `Config` construction time).
125125
workspace_tool_toggle: { since: 'v1' },
126+
// #4175 Wave 4 PR 17. `POST /workspace/init` scaffolds an empty
127+
// `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns) at
128+
// the bound workspace root. Body: `{force?: boolean}`. Default
129+
// refuses with 409 when the file already exists; `force: true`
130+
// overwrites. Mechanical only — does NOT call the LLM. To AI-fill
131+
// the file, the caller should follow up with
132+
// `POST /session/:id/prompt`.
133+
workspace_init: { since: 'v1' },
126134
// Issue #4175 PR 15. Daemon was booted with `--require-auth` (or
127135
// `requireAuth: true`), so even loopback callers must carry a bearer
128136
// token. Advertised CONDITIONALLY — only when the flag is on — so

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

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect } from 'vitest';
7+
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
88
import { randomBytes } from 'node:crypto';
99
import { promises as fsp } from 'node:fs';
1010
import * as os from 'node:os';
@@ -44,6 +44,7 @@ import {
4444
MAX_WORKSPACE_PATH_LENGTH,
4545
RestoreInProgressError,
4646
SessionNotFoundError,
47+
WorkspaceInitConflictError,
4748
WorkspaceMismatchError,
4849
type AcpChannel,
4950
type BridgeOptions,
@@ -4399,6 +4400,113 @@ describe('createHttpAcpBridge', () => {
43994400
});
44004401
});
44014402

4403+
describe('initWorkspace (#4175 Wave 4 PR 17)', () => {
4404+
/**
4405+
* Per-test workspace temp dir so the bridge's writeFile lands on a
4406+
* real path the tests can stat. Cleaned up by `afterEach`.
4407+
*/
4408+
let tmpWs: string;
4409+
4410+
beforeEach(async () => {
4411+
tmpWs = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-init-workspace-'));
4412+
});
4413+
4414+
afterEach(async () => {
4415+
await fsp.rm(tmpWs, { recursive: true, force: true });
4416+
});
4417+
4418+
it('creates an empty QWEN.md on a fresh workspace', async () => {
4419+
const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs });
4420+
const res = await bridge.initWorkspace({}, undefined);
4421+
expect(res.action).toBe('created');
4422+
expect(res.path).toBe(path.join(tmpWs, 'QWEN.md'));
4423+
const written = await fsp.readFile(res.path, 'utf8');
4424+
expect(written).toBe('');
4425+
});
4426+
4427+
it('treats whitespace-only file as absent (no 409)', async () => {
4428+
const target = path.join(tmpWs, 'QWEN.md');
4429+
await fsp.writeFile(target, ' \n\t\n', 'utf8');
4430+
const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs });
4431+
const res = await bridge.initWorkspace({}, undefined);
4432+
expect(res.action).toBe('created');
4433+
const written = await fsp.readFile(target, 'utf8');
4434+
expect(written).toBe('');
4435+
});
4436+
4437+
it('throws WorkspaceInitConflictError when content exists and force is omitted', async () => {
4438+
const target = path.join(tmpWs, 'QWEN.md');
4439+
const original = '# Project notes\n\nimportant stuff';
4440+
await fsp.writeFile(target, original, 'utf8');
4441+
const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs });
4442+
const err = await bridge.initWorkspace({}, undefined).catch((e) => e);
4443+
expect(err).toBeInstanceOf(WorkspaceInitConflictError);
4444+
expect((err as WorkspaceInitConflictError).path).toBe(target);
4445+
expect((err as WorkspaceInitConflictError).existingSize).toBe(
4446+
Buffer.byteLength(original, 'utf8'),
4447+
);
4448+
// Original content must be preserved on conflict.
4449+
expect(await fsp.readFile(target, 'utf8')).toBe(original);
4450+
});
4451+
4452+
it('overwrites with action:overwrote when force is true', async () => {
4453+
const target = path.join(tmpWs, 'QWEN.md');
4454+
await fsp.writeFile(target, '# Old', 'utf8');
4455+
const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs });
4456+
const res = await bridge.initWorkspace({ force: true }, undefined);
4457+
expect(res.action).toBe('overwrote');
4458+
expect(await fsp.readFile(target, 'utf8')).toBe('');
4459+
});
4460+
4461+
it('does NOT spawn an ACP child', async () => {
4462+
let factoryCalls = 0;
4463+
const bridge = createHttpAcpBridge({
4464+
boundWorkspace: tmpWs,
4465+
channelFactory: async () => {
4466+
factoryCalls += 1;
4467+
throw new Error('channel factory should not be invoked');
4468+
},
4469+
});
4470+
await bridge.initWorkspace({}, undefined);
4471+
expect(factoryCalls).toBe(0);
4472+
});
4473+
4474+
it('fan-outs workspace_initialized to live session buses', async () => {
4475+
const factory: ChannelFactory = async () => {
4476+
const { clientStream, agentStream } = createInMemoryChannel();
4477+
new AgentSideConnection(() => new FakeAgent() as Agent, agentStream);
4478+
return {
4479+
stream: clientStream,
4480+
exited: new Promise<
4481+
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
4482+
| undefined
4483+
>(() => {}),
4484+
kill: async () => {},
4485+
killSync: () => {},
4486+
};
4487+
};
4488+
const bridge = createHttpAcpBridge({
4489+
boundWorkspace: tmpWs,
4490+
channelFactory: factory,
4491+
});
4492+
const session = await bridge.spawnOrAttach({ workspaceCwd: tmpWs });
4493+
const abort = new AbortController();
4494+
const it = bridge
4495+
.subscribeEvents(session.sessionId, { signal: abort.signal })
4496+
[Symbol.asyncIterator]();
4497+
const res = await bridge.initWorkspace({}, session.clientId);
4498+
const next = await it.next();
4499+
expect(next.value?.type).toBe('workspace_initialized');
4500+
expect(next.value?.data).toEqual({
4501+
path: res.path,
4502+
action: 'created',
4503+
});
4504+
expect(next.value?.originatorClientId).toBe(session.clientId);
4505+
abort.abort();
4506+
await bridge.shutdown();
4507+
});
4508+
});
4509+
44024510
describe('subscribeEvents', () => {
44034511
it('throws SessionNotFoundError for unknown session ids', () => {
44044512
const bridge = makeBridge({

packages/cli/src/serve/httpAcpBridge.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ import {
4545
} from './status.js';
4646
import { buildEnvStatusFromProcess } from './envSnapshot.js';
4747
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
48-
import { TrustGateError, canUseRipgrep } from '@qwen-code/qwen-code-core';
48+
import {
49+
TrustGateError,
50+
canUseRipgrep,
51+
getCurrentGeminiMdFilename,
52+
} from '@qwen-code/qwen-code-core';
4953
import { getGitVersion, getNpmVersion } from '../utils/systemInfo.js';
5054
import type {
5155
CancelNotification,
@@ -482,6 +486,29 @@ export interface HttpAcpBridge {
482486
originatorClientId: string | undefined,
483487
): Promise<{ toolName: string; enabled: boolean }>;
484488

489+
/**
490+
* Scaffold an empty `QWEN.md` (or whatever
491+
* `getCurrentGeminiMdFilename()` returns) at the bound workspace
492+
* root. Mechanical only — does NOT invoke the LLM. The caller is
493+
* expected to follow up with `POST /session/:id/prompt` if it wants
494+
* AI-driven content fill.
495+
*
496+
* Default refuses to overwrite: when the target file already exists
497+
* with non-whitespace content, throws `WorkspaceInitConflictError`
498+
* (translated to HTTP 409 by the route). `opts.force === true`
499+
* overwrites unconditionally.
500+
*
501+
* Fan-outs a `workspace_initialized` event with `{path, action:
502+
* 'created' | 'overwrote'}` to every live session SSE bus.
503+
*/
504+
initWorkspace(
505+
opts: { force?: boolean },
506+
originatorClientId: string | undefined,
507+
): Promise<{
508+
path: string;
509+
action: 'created' | 'overwrote';
510+
}>;
511+
485512
/**
486513
* Kill the agent process for the session and remove it from the maps.
487514
* Used by the HTTP route layer to reap orphans created when a client
@@ -1147,6 +1174,28 @@ export class InvalidSessionMetadataError extends Error {
11471174
}
11481175
}
11491176

1177+
/**
1178+
* #4175 Wave 4 PR 17. Thrown by `initWorkspace` when the target file
1179+
* already exists with non-whitespace content and the caller did not
1180+
* pass `force: true`. Translated to HTTP 409 by the route. The
1181+
* `path` and `existingSize` fields let SDK clients render a clear
1182+
* "file already exists; pass `force: true` to overwrite" prompt
1183+
* without re-stat'ing the workspace.
1184+
*/
1185+
export class WorkspaceInitConflictError extends Error {
1186+
readonly path: string;
1187+
readonly existingSize: number;
1188+
constructor(path: string, existingSize: number) {
1189+
super(
1190+
`Workspace file ${path} already exists ` +
1191+
`(${existingSize} bytes); pass {force: true} to overwrite.`,
1192+
);
1193+
this.name = 'WorkspaceInitConflictError';
1194+
this.path = path;
1195+
this.existingSize = existingSize;
1196+
}
1197+
}
1198+
11501199
/**
11511200
* Bridge `Client` implementation — the daemon's response surface for things
11521201
* the agent asks the client (file reads/writes, permission prompts).
@@ -3715,6 +3764,44 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
37153764
return { toolName, enabled };
37163765
},
37173766

3767+
async initWorkspace(initOpts, originatorClientId) {
3768+
// #4175 Wave 4 PR 17. Mechanical scaffold of an empty `QWEN.md`
3769+
// (or whatever `getCurrentGeminiMdFilename()` returns under
3770+
// `--memory-file-name` overrides). No ACP roundtrip, no LLM
3771+
// call — clients that want AI-fill follow up with
3772+
// `POST /session/:id/prompt`.
3773+
const filename = getCurrentGeminiMdFilename();
3774+
const target = path.join(boundWorkspace, filename);
3775+
let existingSize: number | undefined;
3776+
let action: 'created' | 'overwrote' = 'created';
3777+
try {
3778+
const existing = await fs.readFile(target, 'utf8');
3779+
// Whitespace-only is treated as absent — matches the behavior
3780+
// of the local `/init` slash command (initCommand.ts) so a
3781+
// half-broken init left an empty file behind doesn't trigger
3782+
// a confusing 409.
3783+
if (existing.trim().length > 0) {
3784+
existingSize = Buffer.byteLength(existing, 'utf8');
3785+
if (initOpts.force !== true) {
3786+
throw new WorkspaceInitConflictError(target, existingSize);
3787+
}
3788+
action = 'overwrote';
3789+
}
3790+
} catch (err) {
3791+
if (err instanceof WorkspaceInitConflictError) throw err;
3792+
const code = (err as { code?: unknown } | null | undefined)?.code;
3793+
if (code !== 'ENOENT') throw err;
3794+
// ENOENT — fall through to create.
3795+
}
3796+
await fs.writeFile(target, '', 'utf8');
3797+
broadcastWorkspaceEvent({
3798+
type: 'workspace_initialized',
3799+
data: { path: target, action },
3800+
...(originatorClientId ? { originatorClientId } : {}),
3801+
});
3802+
return { path: target, action };
3803+
},
3804+
37183805
async killSession(sessionId, opts) {
37193806
const entry = byId.get(sessionId);
37203807
if (!entry) return;

packages/cli/src/serve/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export {
8989
createHttpAcpBridge,
9090
defaultSpawnChannelFactory,
9191
SessionNotFoundError,
92+
WorkspaceInitConflictError,
9293
type AcpChannel,
9394
type BridgeOptions,
9495
type BridgeSession,

0 commit comments

Comments
 (0)