Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ export const SERVE_CAPABILITY_REGISTRY = {
workspace_mcp: { since: 'v1' },
workspace_skills: { since: 'v1' },
workspace_providers: { since: 'v1' },
// Issue #4175 PR 16: workspace memory CRUD (`GET/POST /workspace/memory`).
// Daemon exposes hierarchical QWEN.md state and accepts append/replace
// writes scoped to either the bound workspace or the global ~/.qwen
// directory. Mutation path is gated by the centralized mutation gate.
workspace_memory: { since: 'v1' },
// Issue #4175 PR 16: workspace agents CRUD (`GET/POST /workspace/agents`
// + `GET/POST/DELETE /workspace/agents/:agentType`). Wraps
// `SubagentManager` over HTTP so remote clients can list / read /
// create / update / delete project- and user-level subagent
// definitions. Built-in / extension agents stay read-only.
workspace_agents: { since: 'v1' },
session_context: { since: 'v1' },
session_supported_commands: { since: 'v1' },
session_close: { since: 'v1' },
Expand Down
70 changes: 70 additions & 0 deletions packages/cli/src/serve/httpAcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,28 @@ export interface HttpAcpBridge {
*/
getHeartbeatState(sessionId: string): BridgeHeartbeatState | undefined;

/**
* Issue #4175 PR 16: workspace-level event fan-out for mutations that
* change daemon-wide state (memory writes, agent CRUD). Publishes the
* event onto every live session's `EventBus` so SSE subscribers
* observe it through the existing per-session stream rather than a
* new workspace-level channel. Best-effort per session — a closed
* bus is silently skipped (mirrors the `permission_resolved`
* try/catch). When zero sessions are active the event drops on the
* floor; the route's read-after-write contract remains correct.
*/
publishWorkspaceEvent(event: Omit<BridgeEvent, 'id' | 'v'>): void;

/**
* Issue #4175 PR 16: union of every live session's `clientIds`. Used
* by workspace-level mutation routes (memory write, agent CRUD) to
* validate the optional `X-Qwen-Client-Id` header without requiring
* a session id. Returns a snapshot — callers must not mutate.
* Wave 5 PR 24 will replace this with a workspace-scoped client
* registry decoupled from sessions.
*/
knownClientIds(): ReadonlySet<string>;

/**
* Read daemon-runtime MCP status for the bound workspace. Does not spawn an
* ACP child when the daemon is idle; idle daemons return initialized:false.
Expand Down Expand Up @@ -3167,6 +3189,54 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
};
},

publishWorkspaceEvent(event) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] publishWorkspaceEvent 缺少 workspace discriminator 参数 — 未来多 workspace 支持的信息泄露隐患

该方法遍历所有活跃 session 并向每个 bus 发布,无按 session 的 workspace 范围检查。当前 daemon 绑定到单一 workspace(boundWorkspace),因此风险为零。但如果未来添加多 workspace 支持而未改造此 fan-out 路径,workspace A 的 memory 写入会将 memory_changed 事件泄露给 workspace B 的 SSE 订阅者。

Suggested change
publishWorkspaceEvent(event) {
// 添加 workspaceCwd 参数并在 fan-out 循环中过滤:
// if (entry.workspaceCwd !== workspaceCwd) continue;

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Issue #4175 PR 16. Workspace-level mutations (memory writes /
// agent CRUD) need a fan-out path that doesn't require a session
// id. Iterate every live session's bus best-effort — a closed bus
// (mid-shutdown, or evicted under load) is silently skipped, same
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
// posture as `permission_resolved` at line 1717.
//
// We deliberately do NOT track delivery success per session here:
// the route handler's contract is "read-after-write" and any SSE
// subscriber that misses the event can re-fetch via the route's
// GET sibling. Stage 5 PR 24 PermissionMediator can layer a
// proper workspace event bus on top if adapters need stricter
// delivery semantics.
//
// Per-entry exceptions go to the debug log so a wholesale fan-out
// regression isn't invisible — `EventBus.publish` is documented
// never to throw (BX9_p contract at eventBus.ts:186), so anything
// landing here is by definition unexpected. Gated behind
// `QWEN_SERVE_DEBUG` to keep production stderr quiet during the
// common shutdown-race case.
for (const entry of byId.values()) {
try {
entry.events.publish(event);
} catch (err) {
writeServeDebugLine(
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
`publishWorkspaceEvent: bus publish failed for session ` +
`${JSON.stringify(entry.sessionId)} (type=${event.type}): ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
}
},

knownClientIds() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] knownClientIds() 为单次 Set.has() 检查构建并填充完整 Set,每次 mutation 请求都会触发

resolveWorkspaceClientId(memory route)和 resolveOriginatorClientId(agents route)在每次 POST/DELETE 时都会调用该方法。仅需一次成员检查,却遍历所有 session 及其全部 clientIds。

Suggested change
knownClientIds() {
// 添加直接成员检查方法:
// isKnownClientId(clientId: string): boolean
// 在迭代 session 时,首次匹配即返回,避免完整的 Set 分配。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Snapshot the union of every live session's stamped client ids.
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
// Returned as a fresh Set so callers can mutate-safely (the live
// per-session maps stay private). Workspace-level mutation routes
// use this to validate `X-Qwen-Client-Id` without owning a
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
// session id; PR 24 will replace it with a workspace-scoped
// registry that doesn't conflate session-attach with workspace-
// attach.
const out = new Set<string>();
for (const entry of byId.values()) {
for (const id of entry.clientIds.keys()) out.add(id);
}
return out;
},

async getWorkspaceMcpStatus() {
return requestWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () =>
createIdleWorkspaceMcpStatus(boundWorkspace),
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ const EXPECTED_STAGE1_FEATURES = [
'workspace_mcp',
'workspace_skills',
'workspace_providers',
'workspace_memory',
'workspace_agents',
'session_context',
'session_supported_commands',
'session_close',
Expand Down Expand Up @@ -501,6 +503,18 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
heartbeatStateCalls.push(sessionId);
return heartbeatStateImpl(sessionId);
},
publishWorkspaceEvent(_event) {
// Issue #4175 PR 16 — fakeBridge default is a no-op. Tests that
// assert on workspace fan-out override this through the dedicated
// route-level test files (workspaceMemory.test.ts /
// workspaceAgents.test.ts) where the real fan-out behavior is
// exercised against a live bridge.
},
knownClientIds() {
// Default empty set — workspace mutation tests opt in by
// overriding the bridge in their suite.
return new Set<string>();
},
async killSession(sessionId, opts) {
killCalls.push({ sessionId, opts });
},
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
type CapabilitiesEnvelope,
type ServeOptions,
} from './types.js';
import { mountWorkspaceMemoryRoutes } from './workspaceMemory.js';
import { mountWorkspaceAgentsRoutes } from './workspaceAgents.js';

export interface ServeAppDeps {
/** Bridge instance; tests inject a fake. Defaults to a fresh real one. */
Expand Down Expand Up @@ -288,6 +290,28 @@ export function createServeApp(
}
});

// Issue #4175 PR 16: workspace memory + agents CRUD. Routes mounted
// through factories so server.ts stays the composition root while
// the feature modules own their own validation, error mapping, and
// event fan-out. Both factories receive the shared `mutate` gate
// and the request-helpers `parseClientIdHeader` / `safeBody` so
// strict mutation gating and pollution-key scrubbing match the
// existing routes bit-for-bit.
mountWorkspaceMemoryRoutes(app, {
bridge,
boundWorkspace,
mutate,
parseClientId: parseClientIdHeader,
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
safeBody,
});
mountWorkspaceAgentsRoutes(app, {
bridge,
boundWorkspace,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});

app.post('/session', mutate(), async (req, res) => {
const body = safeBody(req);
// #3803 §02: 1 daemon = 1 workspace. Three input shapes:
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/src/serve/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const SERVE_STATUS_EXT_METHODS = {
workspaceMcp: 'qwen/status/workspace/mcp',
workspaceSkills: 'qwen/status/workspace/skills',
workspaceProviders: 'qwen/status/workspace/providers',
workspaceMemory: 'qwen/status/workspace/memory',
workspaceAgents: 'qwen/status/workspace/agents',
sessionContext: 'qwen/status/session/context',
sessionSupportedCommands: 'qwen/status/session/supported_commands',
} as const;
Expand Down Expand Up @@ -140,6 +142,108 @@ export interface ServeSessionSupportedCommandsStatus {
availableSkills: string[];
}

/**
* Issue #4175 PR 16: workspace memory + agents read surfaces.
*
* Both shapes mirror the `kind / status / error? / errorKind? / hint?`
* cell pattern that PR 12's mcp/skills/providers status structures use,
* so the SDK reducer can render any of these with one pattern.
*/

export type ServeContextFileScope = 'workspace' | 'global';

export interface ServeWorkspaceMemoryFile {
kind: 'memory_file';
/** Absolute path to the discovered memory file. */
path: string;
/**
* 'workspace' for files under the bound workspace tree, 'global' for
* `~/.qwen/QWEN.md` style entries. Helps adapters render scope chips.
*/
scope: ServeContextFileScope;
/** Size in bytes of the file's serialized contents on disk. */
bytes: number;
}

export interface ServeWorkspaceMemoryStatus {
v: typeof STATUS_SCHEMA_VERSION;
workspaceCwd: string;
initialized: boolean;
files: ServeWorkspaceMemoryFile[];
/** Total bytes across all hierarchical files (sum of `files[].bytes`). */
totalBytes: number;
/**
* Number of merged QWEN.md / AGENTS.md files the loader pulled in.
* Mirrors `LoadServerHierarchicalMemoryResponse.fileCount`.
*/
fileCount: number;
/** Baseline path-rule count from `.qwen/rules/`. */
ruleCount: number;
errors?: ServeStatusCell[];
}

export type ServeAgentLevel =
| 'project'
| 'user'
| 'builtin'
| 'extension'
| 'session';

export interface ServeWorkspaceAgentSummary {
kind: 'agent';
name: string;
description: string;
level: ServeAgentLevel;
isBuiltin: boolean;
/** Whether this agent restricts the tool set via `tools:` frontmatter. */
hasTools: boolean;
model?: string;
color?: string;
background?: boolean;
approvalMode?: string;
extensionName?: string;
/** Absolute path to the file backing this agent (or sentinel for built-ins). */
filePath?: string;
}

export interface ServeWorkspaceAgentDetail extends ServeWorkspaceAgentSummary {
systemPrompt: string;
tools?: string[];
disallowedTools?: string[];
runConfig?: { max_time_minutes?: number; max_turns?: number };
}

export interface ServeWorkspaceAgentsStatus {
v: typeof STATUS_SCHEMA_VERSION;
workspaceCwd: string;
agents: ServeWorkspaceAgentSummary[];
errors?: ServeStatusCell[];
}

export function createIdleWorkspaceMemoryStatus(
workspaceCwd: string,
): ServeWorkspaceMemoryStatus {
return {
v: STATUS_SCHEMA_VERSION,
workspaceCwd,
initialized: false,
files: [],
totalBytes: 0,
fileCount: 0,
ruleCount: 0,
};
}

export function createIdleWorkspaceAgentsStatus(
workspaceCwd: string,
): ServeWorkspaceAgentsStatus {
return {
v: STATUS_SCHEMA_VERSION,
workspaceCwd,
agents: [],
};
}

export function createIdleWorkspaceMcpStatus(
workspaceCwd: string,
): ServeWorkspaceMcpStatus {
Expand Down
Loading
Loading