From 888f21ef54e9c4207de2b81d0fbfd818a66e6902 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 6 Jul 2026 20:14:35 +0800 Subject: [PATCH 1/2] feat(cli): add Phase 1 workspace runtime registry Introduce the internal single-workspace runtime registry for qwen serve and wire the primary runtime through the existing server assembly without changing route schemas. Also migrate daemon log and telemetry identity to daemon-scoped values, keep workspace hash as metadata, and reject repeated explicit --workspace inputs until multi-workspace serve is enabled. Co-authored-by: Qwen-Coder --- .../daemon-multi-workspace-phase1-registry.md | 73 +++++++++ packages/cli/src/commands/serve.test.ts | 14 ++ packages/cli/src/commands/serve.ts | 14 +- packages/cli/src/serve/daemon-logger.test.ts | 8 +- packages/cli/src/serve/daemon-logger.ts | 21 ++- packages/cli/src/serve/run-qwen-serve.test.ts | 98 +++++++++++- packages/cli/src/serve/run-qwen-serve.ts | 21 ++- packages/cli/src/serve/server.test.ts | 42 ++++- packages/cli/src/serve/server.ts | 148 ++++++++++-------- .../cli/src/serve/server/telemetry.test.ts | 74 +++++++-- packages/cli/src/serve/server/telemetry.ts | 4 +- .../cli/src/serve/workspace-registry.test.ts | 35 +++++ packages/cli/src/serve/workspace-registry.ts | 36 +++++ 13 files changed, 488 insertions(+), 100 deletions(-) create mode 100644 .qwen/design/daemon-multi-workspace-phase1-registry.md create mode 100644 packages/cli/src/serve/workspace-registry.test.ts create mode 100644 packages/cli/src/serve/workspace-registry.ts diff --git a/.qwen/design/daemon-multi-workspace-phase1-registry.md b/.qwen/design/daemon-multi-workspace-phase1-registry.md new file mode 100644 index 00000000000..ddd9b046c18 --- /dev/null +++ b/.qwen/design/daemon-multi-workspace-phase1-registry.md @@ -0,0 +1,73 @@ +# Daemon Multi-Workspace Phase 1 Registry + +## Summary + +Phase 1 introduces the internal single-runtime registry for `qwen serve` plus +the two guardrails now called out in issue #6378: daemon-scoped identity and +repeatable `--workspace` input handling. The daemon still serves exactly one +primary workspace. Route/API behavior remains unchanged except that multiple +explicit `--workspace` values now fail loudly instead of falling into the old +single-workspace path. Daemon log filename and telemetry service instance id +also intentionally change from workspace-scoped to daemon-scoped identity; the +PR release notes should call out that migration. + +The registry is the future internal boundary for issue #6378's multi-workspace +rollout, but this step intentionally avoids protocol/schema expansion and does +not enable multi-workspace CLI behavior. + +## Design + +- `WorkspaceRuntime` wraps the current single-workspace serve objects: + `workspaceCwd`, `AcpSessionBridge`, `DaemonWorkspaceService`, the REST route + filesystem factory, and the current client-MCP sender registry. +- `WorkspaceRegistry` exposes only `primary`, `list()`, and exact + `getByWorkspaceCwd()` lookup. +- `createServeApp` constructs the existing bridge/service/fsFactory stack first, + then wraps it as the primary runtime. +- Existing `app.locals.fsFactory` and `app.locals.boundWorkspace` remain in + place for current file routes. `app.locals.workspaceRegistry` is additive. +- Route modules keep their current signatures. The server assembly layer now + passes values from `workspaceRegistry.primary`. +- Daemon log file names and telemetry service instance ids are daemon-scoped + (`serve-.log`, `daemon:`). Workspace hash remains an attribute on + log/telemetry records instead of being part of daemon identity. +- `runQwenServe` accepts the possible yargs runtime shape where `workspace` is + an array. A single value still behaves like the existing single workspace; + multiple values boot-error until multi-workspace support is enabled. + +## Bounds + +- No repeatable `--workspace` support yet; repeated values are rejected. +- No `workspaces[]` in `/capabilities` or daemon status. +- No SDK type changes. +- No plural `/workspaces/:workspace/...` routes. +- No session ownership index, env overlay, `maxTotalSessions`, or + workspace-qualified ACP/voice/channel worker behavior. + +## Audit Notes + +The route filesystem factory is named `routeFileSystemFactory` because +production currently distinguishes bridge file access from REST route file +access. The registry must not collapse those boundaries. + +`ClientMcpSenderRegistry` remains the current process-scoped single-daemon map +in this phase. The runtime stores the existing instance only; workspace-scoped +client-MCP isolation is a later multi-workspace concern. + +`SessionArchiveCoordinator` and `WorkspaceRememberTaskLane` stay as current +server assembly collaborators. They are not registry core responsibilities in +Phase 1. + +The daemon telemetry middleware now resolves the workspace cwd at request time, +even though Phase 1 still always resolves to primary. This preserves current +behavior while avoiding a primary-workspace hash closure that would be wrong +once workspace-qualified routes land. + +## Verification + +Targeted tests cover exact registry lookup, `createServeApp` locals exposure, +injected route filesystem factory preservation, existing file-route locals +behavior, daemon-scoped log/telemetry identity, request-time workspace hashing, +yargs single/repeated `--workspace` shapes, the single-workspace array path, +and the repeated `--workspace` boot guard. Final verification should run the +focused serve tests plus repository build and typecheck. diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index b2dbb642418..e94aef79455 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -93,6 +93,20 @@ describe('serve command args', () => { expect(parsed['channel']).toEqual(['telegram', 'feishu']); }); + + it('parses a single --workspace value as a string', () => { + const parsed = buildParser().parseSync('--workspace /tmp/primary'); + + expect(parsed['workspace']).toBe('/tmp/primary'); + }); + + it('parses repeatable --workspace values as an array', () => { + const parsed = buildParser().parseSync( + '--workspace /tmp/primary --workspace /tmp/secondary', + ); + + expect(parsed['workspace']).toEqual(['/tmp/primary', '/tmp/secondary']); + }); }); describe('serve rate limit env parsing', () => { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4321dfed10d..458fd8f4699 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -99,7 +99,7 @@ interface ServeArgs { 'max-pending-prompts-per-session': number; 'max-connections': number; 'event-ring-size': number; - workspace?: string; + workspace?: string | string[]; 'require-auth': boolean; 'enable-session-shell': boolean; 'tls-cert'?: string; @@ -129,6 +129,12 @@ interface ServeArgs { channel?: string[]; } +function primaryWorkspaceArg( + workspace: string | string[] | undefined, +): string | undefined { + return Array.isArray(workspace) ? workspace[0] : workspace; +} + export const serveCommand: CommandModule = { command: 'serve', describe: @@ -449,7 +455,9 @@ export const serveCommand: CommandModule = { // a deployment that's wide-open at boot. Suppress with // QWEN_CODE_SUPPRESS_YOLO_WARNING=1. try { - const loaded = loadSettings(argv.workspace ?? process.cwd()); + const loaded = loadSettings( + primaryWorkspaceArg(argv.workspace) ?? process.cwd(), + ); const merged = loaded.merged; const approvalMode = merged.tools?.approvalMode; const sandbox = merged.tools?.sandbox; @@ -535,7 +543,7 @@ export const serveCommand: CommandModule = { maxPendingPromptsPerSession, maxConnections: argv['max-connections'], eventRingSize: argv['event-ring-size'], - workspace: argv.workspace, + workspace: argv.workspace as string | undefined, requireAuth: argv['require-auth'], enableSessionShell: argv['enable-session-shell'], serveWebShell: argv.web, diff --git a/packages/cli/src/serve/daemon-logger.test.ts b/packages/cli/src/serve/daemon-logger.test.ts index 8c681606757..4f55d777b8b 100644 --- a/packages/cli/src/serve/daemon-logger.test.ts +++ b/packages/cli/src/serve/daemon-logger.test.ts @@ -148,19 +148,19 @@ describe('initDaemonLogger file init', () => { } }); - it('derives daemon-id "serve--" and creates log file', () => { + it('derives daemon-scoped daemon-id and creates log file', () => { const logger = initDaemonLogger({ boundWorkspace: '/workspace/foo', pid: 1234, baseDir: tmp, }); - expect(logger.getDaemonId()).toMatch(/^serve-1234-[0-9a-f]{8}$/); + expect(logger.getDaemonId()).toBe('daemon:1234'); expect(logger.getLogPath()).toBe( - path.join(tmp, 'daemon', `${logger.getDaemonId()}.log`), + path.join(tmp, 'daemon', 'serve-1234.log'), ); expect(existsSync(logger.getLogPath())).toBe(true); expect(readFileSync(logger.getLogPath(), 'utf8')).toMatch( - /\[INFO\] \[DAEMON\] daemon started pid=1234 workspace=\/workspace\/foo/, + /\[INFO\] \[DAEMON\] workspace=\/workspace\/foo workspaceHash=[0-9a-f]{8} daemon started pid=1234/, ); }); diff --git a/packages/cli/src/serve/daemon-logger.ts b/packages/cli/src/serve/daemon-logger.ts index c4a77b955e7..d7afc4b7a31 100644 --- a/packages/cli/src/serve/daemon-logger.ts +++ b/packages/cli/src/serve/daemon-logger.ts @@ -150,13 +150,20 @@ function isOptedOut(): boolean { return ['0', 'false', 'off', 'no'].includes(raw.trim().toLowerCase()); } -function computeDaemonId(pid: number, boundWorkspace: string): string { - const hash = crypto +function computeWorkspaceHash(boundWorkspace: string): string { + return crypto .createHash('sha256') .update(boundWorkspace) .digest('hex') .slice(0, 8); - return `serve-${pid}-${hash}`; +} + +function computeDaemonId(pid: number): string { + return `daemon:${pid}`; +} + +function computeDaemonLogFileName(pid: number): string { + return `serve-${pid}.log`; } export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger { @@ -167,16 +174,18 @@ export function initDaemonLogger(opts: InitDaemonLoggerOptions): DaemonLogger { const stderr = opts.stderr ?? writeStderrLine; const baseDir = opts.baseDir ?? resolveDaemonLogBaseDir(); - const daemonId = computeDaemonId(pid, opts.boundWorkspace); + const daemonId = computeDaemonId(pid); + const workspaceHash = computeWorkspaceHash(opts.boundWorkspace); const daemonDir = nodePath.join(baseDir, 'daemon'); - const logPath = nodePath.join(daemonDir, `${daemonId}.log`); + const logPath = nodePath.join(daemonDir, computeDaemonLogFileName(pid)); try { nodeFs.mkdirSync(daemonDir, { recursive: true }); const firstLine = buildDaemonLogLine({ level: 'INFO', - message: `daemon started pid=${pid} workspace=${opts.boundWorkspace}`, + message: `daemon started pid=${pid}`, now: now(), + ctx: { workspace: opts.boundWorkspace, workspaceHash }, }); nodeFs.appendFileSync(logPath, firstLine); } catch (err) { diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index b508961d4f6..ad67c9bcb39 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -341,14 +341,14 @@ describe('runQwenServe daemon logger wiring', () => { const daemonDir = path.join(debugDir, 'daemon'); expect(fs.existsSync(daemonDir)).toBe(true); - // Find the log file (pattern: serve--.log) + // Find the daemon-scoped log file. const logFiles = fs .readdirSync(daemonDir) .filter((f) => f.endsWith('.log')); - expect(logFiles.length).toBeGreaterThanOrEqual(1); + expect(logFiles).toContain(`serve-${process.pid}.log`); const logContent = fs.readFileSync( - path.join(daemonDir, logFiles[0]!), + path.join(daemonDir, `serve-${process.pid}.log`), 'utf8', ); // Should contain the "daemon started" boot line @@ -363,7 +363,7 @@ describe('runQwenServe daemon logger wiring', () => { // The log should still be readable after shutdown const finalContent = fs.readFileSync( - path.join(daemonDir, logFiles[0]!), + path.join(daemonDir, `serve-${process.pid}.log`), 'utf8', ); expect(finalContent).toContain('daemon started'); @@ -382,6 +382,7 @@ describe('runQwenServe telemetry validation', () => { process.env['QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH']; afterEach(() => { + vi.restoreAllMocks(); if (originalSensitiveSpanAttributeMaxLengthEnv === undefined) { delete process.env['QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH']; } else { @@ -408,6 +409,95 @@ describe('runQwenServe telemetry validation', () => { await expect(run).rejects.toThrow(qwenCore.FatalConfigError); await expect(run).rejects.toThrow(/Invalid telemetry configuration:/); }); + + it('rejects multiple explicit workspace inputs before runtime boot', async () => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-'))); + const primary = path.join(tmpDir, 'primary'); + const secondary = path.join(tmpDir, 'secondary'); + fs.mkdirSync(primary); + fs.mkdirSync(secondary); + + await expect( + runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: [primary, secondary], + maxSessions: 1, + } as unknown as Parameters[0]), + ).rejects.toThrow(/Multiple --workspace values are not supported yet/); + }); + + it('accepts a single workspace array input as the primary workspace', async () => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-'))); + const primary = path.join(tmpDir, 'primary'); + fs.mkdirSync(primary); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: [primary], + maxSessions: 1, + serveWebShell: false, + } as unknown as Parameters[0], + { + bridge: makeRuntimeBridge(), + daemonLogBaseDir: path.join(tmpDir, 'debug'), + }, + ); + try { + const res = await fetch(`${handle.url}/capabilities`); + expect(res.status).toBe(200); + expect((await res.json()) as { workspaceCwd: string }).toMatchObject({ + workspaceCwd: canonicalizeWorkspace(primary), + }); + } finally { + await handle.close(); + } + }); + + it('uses a daemon-scoped telemetry service instance id', async () => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-tv-'))); + const initializeTelemetry = vi + .spyOn(qwenCore, 'initializeTelemetry') + .mockImplementation(() => {}); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { + bridge: makeRuntimeBridge(), + daemonLogBaseDir: path.join(tmpDir, 'debug'), + }, + ); + try { + const runtimeConfig = initializeTelemetry.mock.calls[0]?.[0] as { + getSessionId(): string; + getTelemetryResourceAttributes(): Record; + }; + expect(runtimeConfig.getSessionId()).toBe(`daemon:${process.pid}`); + expect(runtimeConfig.getTelemetryResourceAttributes()).toMatchObject({ + 'service.instance.id': `daemon:${process.pid}`, + }); + } finally { + await handle.close(); + } + }); }); /** diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index caa5025d0f5..a9182350700 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -216,6 +216,21 @@ function envFlagDisabled(raw: string | undefined): boolean { return normalized === '0' || normalized === 'false'; } +function resolveSingleWorkspaceInput(workspace: unknown): string { + if (Array.isArray(workspace)) { + if (workspace.length === 0) return process.cwd(); + if (workspace.length > 1) { + throw new Error( + 'Multiple --workspace values are not supported yet. ' + + 'Multi-workspace serve is not enabled; pass one --workspace.', + ); + } + return String(workspace[0]); + } + if (workspace === undefined) return process.cwd(); + return String(workspace); +} + function hasChromeExtensionOrigin(origins: readonly string[] | undefined) { return ( origins?.some((origin) => @@ -1625,7 +1640,7 @@ export async function runQwenServe( // multiple daemon processes, not intra-daemon routing. // // Boot-loud validation: absolute path, exists, is a directory. - const rawWorkspace = opts.workspace ?? process.cwd(); + const rawWorkspace = resolveSingleWorkspaceInput(opts.workspace); if (!path.isAbsolute(rawWorkspace)) { throw new Error( `Invalid --workspace "${rawWorkspace}": must be an absolute path.`, @@ -2026,7 +2041,7 @@ export async function runQwenServe( createDaemonTelemetryRuntimeConfig( daemonTelemetrySettings, resolvedCliVersion, - `daemon:${daemonWorkspaceHash}:${process.pid}`, + `daemon:${process.pid}`, { otlpEndpoint: core.DEFAULT_OTLP_ENDPOINT, telemetryTarget: core.DEFAULT_TELEMETRY_TARGET, @@ -2701,7 +2716,7 @@ export async function runQwenServe( performance.now() - runStartedAt, ); profileCheckpoint('serve_listener_ready'); - finalizeStartupProfile(daemonLog.getDaemonId() || 'serve'); + finalizeStartupProfile(`serve-${process.pid}`); // Listener-level connection cap, set inside the listen callback // because Node only exposes the underlying `Server` after diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index bc1733c539c..8fcd4fe96f2 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -112,6 +112,7 @@ import type { DaemonLogger } from './daemon-logger.js'; import { FsError, type WorkspaceFileSystemFactory } from './fs/index.js'; import { getRateLimiter } from './rate-limit.js'; import type { DaemonWorkspaceService } from './workspace-service/types.js'; +import type { WorkspaceRegistry } from './workspace-registry.js'; import { resetHomeEnvBootstrapForTesting } from '../config/settings.js'; import { resetTrustedFoldersForTesting, @@ -14015,6 +14016,38 @@ describe('runQwenServe SIGINT handler', () => { }); describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { + it('parks a single-workspace registry on app.locals for the canonical primary workspace', async () => { + const { createServeApp } = await import('./server.js'); + const app = createServeApp( + { + port: 0, + hostname: '127.0.0.1', + workspace: '/work/bound', + } as Parameters[0], + () => 0, + ); + const locals = app.locals as { + boundWorkspace?: string; + workspaceRegistry?: WorkspaceRegistry; + }; + + expect(locals.workspaceRegistry).toBeDefined(); + expect(locals.workspaceRegistry!.primary.workspaceCwd).toBe( + locals.boundWorkspace, + ); + expect(locals.workspaceRegistry!.list()).toEqual([ + locals.workspaceRegistry!.primary, + ]); + + const res = await request(app) + .get('/capabilities') + .set('Host', '127.0.0.1:0') + .expect(200); + expect(res.body.workspaceCwd).toBe( + locals.workspaceRegistry!.primary.workspaceCwd, + ); + }); + it('parks a default WorkspaceFileSystemFactory on app.locals when none is injected', async () => { const { createServeApp } = await import('./server.js'); const app = createServeApp( @@ -14050,7 +14083,14 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any { fsFactory: sentinel as any }, ); - expect((app.locals as { fsFactory?: unknown }).fsFactory).toBe(sentinel); + const locals = app.locals as { + fsFactory?: unknown; + workspaceRegistry?: WorkspaceRegistry; + }; + expect(locals.fsFactory).toBe(sentinel); + expect(locals.workspaceRegistry!.primary.routeFileSystemFactory).toBe( + sentinel, + ); }); it('passes custom ignore files through resolveBridgeFsFactory', async () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 06233443b6e..c958dc0d98e 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -113,6 +113,10 @@ import { installRateLimiter } from './server/rate-limiter-setup.js'; import { createServeFeatures } from './server/serve-features.js'; import { SessionArchiveCoordinator } from './server/session-archive.js'; import { installSelfOriginStripMiddleware } from './server/self-origin.js'; +import { + createSingleWorkspaceRegistry, + type WorkspaceRegistry, +} from './workspace-registry.js'; import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js'; import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js'; import { registerWorkspaceToolsRoutes } from './routes/workspace-tools.js'; @@ -455,6 +459,21 @@ export function createServeApp( bridge.publishWorkspaceEvent(event); }, }); + const workspaceRegistry = createSingleWorkspaceRegistry({ + workspaceCwd: boundWorkspace, + bridge, + workspaceService: workspace, + routeFileSystemFactory: fsFactory, + clientMcpSenderRegistry, + }); + (app.locals as { workspaceRegistry?: WorkspaceRegistry }).workspaceRegistry = + workspaceRegistry; + const primaryRuntime = workspaceRegistry.primary; + const primaryBoundWorkspace = primaryRuntime.workspaceCwd; + const primaryBridge = primaryRuntime.bridge; + const primaryWorkspace = primaryRuntime.workspaceService; + const primaryRouteFileSystemFactory = primaryRuntime.routeFileSystemFactory; + // Order matters: rejection guards (CORS / Host allowlist / bearer auth) // run BEFORE the JSON body parser. Otherwise an unauthenticated POST // gets a full 10MB `JSON.parse` before the 401 fires — a trivially @@ -487,7 +506,7 @@ export function createServeApp( const healthDemoRoutes = createHealthDemoRoutes({ opts, getPort, - bridge, + bridge: primaryBridge, getActiveSseCount, getRateLimiter: () => rateLimiter, }); @@ -548,12 +567,17 @@ export function createServeApp( requireAuth: opts.requireAuth === true, }); - app.use(daemonTelemetryMiddleware(boundWorkspace, deps.recordDaemonRequest)); + app.use( + daemonTelemetryMiddleware( + () => primaryBoundWorkspace, + deps.recordDaemonRequest, + ), + ); - const buildWorkspaceCtx = createBuildWorkspaceCtx(boundWorkspace); + const buildWorkspaceCtx = createBuildWorkspaceCtx(primaryBoundWorkspace); const acpHandleRef: { current?: AcpHttpHandle } = {}; - const workspaceRememberLane = new WorkspaceRememberTaskLane(bridge); + const workspaceRememberLane = new WorkspaceRememberTaskLane(primaryBridge); // Plan C CDP tunnel (issue #5626): process-scoped registry pairing the // extension `/acp` connection with the `/cdp` puppeteer endpoint. Inert until @@ -563,9 +587,9 @@ export function createServeApp( registerDaemonStatusRoutes(app, { opts, - boundWorkspace, - bridge, - workspace, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, + workspace: primaryWorkspace, daemonLog, startup: deps.startup, qwenCodeVersion: deps.qwenCodeVersion, @@ -585,53 +609,53 @@ export function createServeApp( qwenCodeVersion: deps.qwenCodeVersion, mode: opts.mode, currentServeFeatures, - boundWorkspace, - permissionPolicy: bridge.permissionPolicy, + boundWorkspace: primaryBoundWorkspace, + permissionPolicy: primaryBridge.permissionPolicy, maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession, languageCodes, }); registerWorkspaceStatusRoutes(app, { - boundWorkspace, - bridge, - workspace, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, + workspace: primaryWorkspace, sendBridgeError, }); // Workspace memory + agents CRUD routes. mountWorkspaceMemoryRoutes(app, { - bridge, - boundWorkspace, + bridge: primaryBridge, + boundWorkspace: primaryBoundWorkspace, mutate, parseClientId: parseClientIdHeader, safeBody, }); mountWorkspaceMemoryRememberRoutes(app, { - bridge, + bridge: primaryBridge, lane: workspaceRememberLane, mutate, parseClientId: parseClientIdHeader, safeBody, }); mountWorkspaceAgentsRoutes(app, { - bridge, - boundWorkspace, + bridge: primaryBridge, + boundWorkspace: primaryBoundWorkspace, mutate, parseClientId: parseClientIdHeader, safeBody, }); registerWorkspaceDiagnosticStatusRoutes(app, { - boundWorkspace, - bridge, - workspace, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, + workspace: primaryWorkspace, sendBridgeError, }); registerWorkspaceExtensionRoutes(app, { - boundWorkspace, - bridge, - workspace, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, + workspace: primaryWorkspace, mutate, safeBody, sendBridgeError, @@ -645,25 +669,25 @@ export function createServeApp( parseClientId: parseClientIdHeader, }); registerWorkspaceFileWriteRoutes(app, { - bridge, + bridge: primaryBridge, mutate, parseClientId: parseClientIdHeader, safeBody, }); registerWorkspaceSetupGithubRoutes(app, { - boundWorkspace, - bridge, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, mutate, parseClientId: parseClientIdHeader, safeBody, }); registerWorkspaceTrustRoutes(app, { - boundWorkspace, - workspace, + boundWorkspace: primaryBoundWorkspace, + workspace: primaryWorkspace, mutate, safeBody, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); const broadcastSettingsChanged = ( @@ -673,7 +697,7 @@ export function createServeApp( clientId: string | undefined, ) => { invalidateServeFeaturesCache(); - bridge.publishWorkspaceEvent({ + primaryBridge.publishWorkspaceEvent({ type: 'settings_changed', data: { key, value, scope }, ...(clientId ? { originatorClientId: clientId } : {}), @@ -683,7 +707,7 @@ export function createServeApp( if (deps.persistSetting) { const persistSetting = deps.persistSetting; registerWorkspaceSettingsRoutes(app, { - boundWorkspace, + boundWorkspace: primaryBoundWorkspace, mutate, safeBody, persistSetting: async (...args) => { @@ -691,19 +715,19 @@ export function createServeApp( }, broadcastSettingsChanged, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); } registerWorkspacePermissionsRoutes(app, { - boundWorkspace, + boundWorkspace: primaryBoundWorkspace, mutate, safeBody, - workspace, + workspace: primaryWorkspace, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); registerWorkspaceVoiceRoutes(app, { - boundWorkspace, + boundWorkspace: primaryBoundWorkspace, mutate, safeBody, persistSetting: deps.persistSetting, @@ -711,21 +735,21 @@ export function createServeApp( transcribe: deps.voiceTranscriber, broadcastSettingsChanged, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); // A2UI action inbound (the upstream half of A2UI-over-MCP): user // interactions from web clients are proxied to the UI MCP server's // standard `action` tool. registerA2uiActionRoutes(app, { - boundWorkspace, + boundWorkspace: primaryBoundWorkspace, mutate, safeBody, // UI-server discovery uses the daemon's workspace MCP status, which // includes servers registered at runtime. getMcpServers: async () => { const ctx = buildWorkspaceCtx('POST /session/:id/a2ui-action'); - const status = await workspace.getWorkspaceMcpStatus(ctx); + const status = await primaryWorkspace.getWorkspaceMcpStatus(ctx); return (status.servers ?? []) as Array<{ name: string; mcpStatus?: string; @@ -739,14 +763,14 @@ export function createServeApp( deviceFlowRegistry, getSupportedDeviceFlowProviders, sendBridgeError, - boundWorkspace, + boundWorkspace: primaryBoundWorkspace, allowPrivateAuthBaseUrl: opts.allowPrivateAuthBaseUrl === true, installAuthProvider: deps.installAuthProvider, }); registerSessionRoutes(app, { - boundWorkspace, - bridge, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, archiveCoordinator, mutate, sendBridgeError, @@ -757,33 +781,33 @@ export function createServeApp( }); registerWorkspaceMcpControlRoutes(app, { - boundWorkspace, - bridge, - workspace, + boundWorkspace: primaryBoundWorkspace, + bridge: primaryBridge, + workspace: primaryWorkspace, mutate, safeBody, sendBridgeError, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); registerWorkspaceLifecycleRoutes(app, { - boundWorkspace, - workspace, + boundWorkspace: primaryBoundWorkspace, + workspace: primaryWorkspace, mutate, safeBody, sendBridgeError, invalidateServeFeaturesCache, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); registerWorkspaceToolsRoutes(app, { - boundWorkspace, - workspace, + boundWorkspace: primaryBoundWorkspace, + workspace: primaryWorkspace, mutate, safeBody, sendBridgeError, parseAndValidateClientId: (req, res) => - parseAndValidateWorkspaceClientId(req, res, bridge), + parseAndValidateWorkspaceClientId(req, res, primaryBridge), }); // Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page). @@ -791,19 +815,19 @@ export function createServeApp( // session-side scheduler. Non-strict mutate: creating a scheduled prompt // is the same capability class as POST /session/:id/prompt. registerScheduledTasksRoutes(app, { - boundWorkspace, + boundWorkspace: primaryBoundWorkspace, mutate, safeBody, }); registerPermissionRoutes(app, { - bridge, + bridge: primaryBridge, mutate, sendPermissionVoteError, }); registerSseEventsRoutes(app, { - bridge, + bridge: primaryBridge, daemonLog, writerIdleTimeoutMs: opts.writerIdleTimeoutMs, sendBridgeError, @@ -816,11 +840,11 @@ 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. - acpHandleRef.current = mountAcpHttp(app, bridge, { - boundWorkspace, + acpHandleRef.current = mountAcpHttp(app, primaryBridge, { + boundWorkspace: primaryBoundWorkspace, archiveCoordinator, - workspace, - fsFactory, + workspace: primaryWorkspace, + fsFactory: primaryRouteFileSystemFactory, deviceFlowRegistry, token: opts.token, // Mirror the REST CORS allowlist onto the WS CSRF wall so an @@ -844,8 +868,8 @@ export function createServeApp( ? { clientMcpProviderFactory: (connectionId: string) => createClientMcpServerProvider( - clientMcpSenderRegistry, - bridge, + primaryRuntime.clientMcpSenderRegistry, + primaryBridge, connectionId, ), } @@ -860,7 +884,7 @@ export function createServeApp( extraWsRoutes: [ { path: '/voice/stream', - onConnection: createVoiceWsConnectionHandler(boundWorkspace), + onConnection: createVoiceWsConnectionHandler(primaryBoundWorkspace), }, ], }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 38bd972af60..4fd3a024da9 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -4,22 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { EventEmitter } from 'node:events'; import type { NextFunction, Request, Response } from 'express'; +const coreMocks = vi.hoisted(() => ({ + hashDaemonWorkspace: vi.fn((workspace: string) => `hash:${workspace}`), + recordDaemonError: vi.fn(), + recordDaemonHttpRequest: vi.fn(), + recordDaemonHttpResponse: vi.fn(), + withDaemonRequestSpan: vi.fn( + (_attrs: unknown, fn: (span: unknown) => Promise) => fn({}), + ), +})); + // The middleware only touches these five core helpers; stub them so the test is // a pure unit on the `recordRequest` seam. `withDaemonRequestSpan` just runs the // wrapped fn (which registers the res listeners and calls next()). vi.mock('@qwen-code/qwen-code-core', () => ({ - hashDaemonWorkspace: () => 'ws-hash', - recordDaemonError: vi.fn(), - recordDaemonHttpRequest: vi.fn(), - recordDaemonHttpResponse: vi.fn(), - withDaemonRequestSpan: ( - _attrs: unknown, - fn: (span: unknown) => Promise, - ) => fn({}), + ...coreMocks, })); import { daemonTelemetryMiddleware } from './telemetry.js'; @@ -35,9 +38,13 @@ function mockRes(statusCode: number): Response & EventEmitter { } describe('daemonTelemetryMiddleware — recordRequest seam', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('calls recordRequest with (durationMs, statusCode) once the response finishes on a matched route', () => { const recordRequest = vi.fn(); - const mw = daemonTelemetryMiddleware('/ws', recordRequest); + const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest); const res = mockRes(200); const next = vi.fn() as unknown as NextFunction; @@ -53,7 +60,7 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { it('records the real status code (not just 200) on error responses', () => { const recordRequest = vi.fn(); - const mw = daemonTelemetryMiddleware('/ws', recordRequest); + const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest); const res = mockRes(503); mw( mockReq('POST', '/session/abc/prompt'), @@ -66,7 +73,7 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { it('fires exactly once even if both finish and close emit', () => { const recordRequest = vi.fn(); - const mw = daemonTelemetryMiddleware('/ws', recordRequest); + const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest); const res = mockRes(200); mw( mockReq('GET', '/session/abc/artifacts'), @@ -80,7 +87,7 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { it('does NOT call recordRequest for an unmatched route', () => { const recordRequest = vi.fn(); - const mw = daemonTelemetryMiddleware('/ws', recordRequest); + const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest); const res = mockRes(200); const next = vi.fn() as unknown as NextFunction; mw(mockReq('GET', '/not-a-daemon-route'), res, next); @@ -91,7 +98,7 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { it('excludes the dashboard status poll (GET /daemon/status) from recordRequest', () => { const recordRequest = vi.fn(); - const mw = daemonTelemetryMiddleware('/ws', recordRequest); + const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest); const res = mockRes(200); // GET /daemon/status IS a matched telemetry route, but the metrics ring must // not count the dashboard's own 5s poll as request traffic. @@ -105,7 +112,7 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); it('is a silent no-op when recordRequest is omitted (the optional-chaining path)', () => { - const mw = daemonTelemetryMiddleware('/ws'); + const mw = daemonTelemetryMiddleware(() => '/ws'); const res = mockRes(200); expect(() => { mw( @@ -116,4 +123,41 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { res.emit('finish'); }).not.toThrow(); }); + + it('resolves workspace hash per request instead of closing over the primary workspace', () => { + let workspace = '/workspace/one'; + const mw = daemonTelemetryMiddleware(() => workspace); + const firstRes = mockRes(200); + + mw( + mockReq('POST', '/session'), + firstRes, + vi.fn() as unknown as NextFunction, + ); + firstRes.emit('finish'); + + workspace = '/workspace/two'; + const secondRes = mockRes(200); + mw( + mockReq('POST', '/session/abc/prompt'), + secondRes, + vi.fn() as unknown as NextFunction, + ); + secondRes.emit('finish'); + + expect(coreMocks.hashDaemonWorkspace).toHaveBeenNthCalledWith( + 1, + '/workspace/one', + ); + expect(coreMocks.hashDaemonWorkspace).toHaveBeenNthCalledWith( + 2, + '/workspace/two', + ); + expect(coreMocks.withDaemonRequestSpan.mock.calls[0]?.[0]).toMatchObject({ + workspaceHash: 'hash:/workspace/one', + }); + expect(coreMocks.withDaemonRequestSpan.mock.calls[1]?.[0]).toMatchObject({ + workspaceHash: 'hash:/workspace/two', + }); + }); }); diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 3326b70ad17..4b418e42f74 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -172,7 +172,7 @@ export function resolveDaemonTelemetryRoute( } export function daemonTelemetryMiddleware( - boundWorkspace: string, + resolveWorkspaceCwd: (req: Request) => string, // Optional in-process sink for the Daemon Status dashboard's time-series // charts. Fed the same (durationMs, statusCode) already computed for OTel, // so it adds no extra measurement — just a second consumer. Only known @@ -181,13 +181,13 @@ export function daemonTelemetryMiddleware( // traffic rather than static-asset or unrouted noise. recordRequest?: (durationMs: number, statusCode: number) => void, ): (req: Request, res: Response, next: NextFunction) => void { - const workspaceHash = hashDaemonWorkspace(boundWorkspace); return (req, res, next) => { const route = resolveDaemonTelemetryRoute(req); if (!route) { next(); return; } + const workspaceHash = hashDaemonWorkspace(resolveWorkspaceCwd(req)); const rawClientId = req.get(CLIENT_ID_HEADER); const clientId = rawClientId !== undefined && diff --git a/packages/cli/src/serve/workspace-registry.test.ts b/packages/cli/src/serve/workspace-registry.test.ts new file mode 100644 index 00000000000..0909cc6d914 --- /dev/null +++ b/packages/cli/src/serve/workspace-registry.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + createSingleWorkspaceRegistry, + type WorkspaceRuntime, +} from './workspace-registry.js'; + +describe('createSingleWorkspaceRegistry', () => { + it('exposes the supplied runtime as the primary and only runtime', () => { + const runtime = { workspaceCwd: '/work/primary' } as WorkspaceRuntime; + + const registry = createSingleWorkspaceRegistry(runtime); + + expect(registry.primary).toBe(runtime); + expect(registry.list()).toEqual([runtime]); + expect(registry.list()[0]).toBe(runtime); + }); + + it('looks up only the exact canonical workspace string', () => { + const runtime = { workspaceCwd: '/work/primary' } as WorkspaceRuntime; + + const registry = createSingleWorkspaceRegistry(runtime); + + expect(registry.getByWorkspaceCwd('/work/primary')).toBe(runtime); + expect(registry.getByWorkspaceCwd('/work')).toBeUndefined(); + expect(registry.getByWorkspaceCwd('/work/primary/child')).toBeUndefined(); + expect(registry.getByWorkspaceCwd('/work/primary/')).toBeUndefined(); + expect(registry.getByWorkspaceCwd('/other')).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts new file mode 100644 index 00000000000..ef20743a7e4 --- /dev/null +++ b/packages/cli/src/serve/workspace-registry.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import type { ClientMcpSenderRegistry } from './acp-http/client-mcp-sender-registry.js'; +import type { WorkspaceFileSystemFactory } from './fs/index.js'; +import type { DaemonWorkspaceService } from './workspace-service/types.js'; + +export interface WorkspaceRuntime { + readonly workspaceCwd: string; + readonly bridge: AcpSessionBridge; + readonly workspaceService: DaemonWorkspaceService; + readonly routeFileSystemFactory: WorkspaceFileSystemFactory; + readonly clientMcpSenderRegistry: ClientMcpSenderRegistry; +} + +export interface WorkspaceRegistry { + readonly primary: WorkspaceRuntime; + list(): readonly WorkspaceRuntime[]; + getByWorkspaceCwd(workspaceCwd: string): WorkspaceRuntime | undefined; +} + +export function createSingleWorkspaceRegistry( + runtime: WorkspaceRuntime, +): WorkspaceRegistry { + const runtimes = Object.freeze([runtime]); + return { + primary: runtime, + list: () => runtimes, + getByWorkspaceCwd: (workspaceCwd) => + workspaceCwd === runtime.workspaceCwd ? runtime : undefined, + }; +} From 70d8e6db89fa48cac8156310f5596e222c7341a4 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 6 Jul 2026 20:43:55 +0800 Subject: [PATCH 2/2] codex: address PR review feedback (#6394) Memoize daemon telemetry workspace hashes and let runQwenServe honestly accept yargs workspace array inputs while keeping internal ServeOptions single-workspace. Co-authored-by: Qwen-Coder --- packages/cli/src/commands/serve.ts | 2 +- packages/cli/src/serve/run-qwen-serve.test.ts | 4 ++-- packages/cli/src/serve/run-qwen-serve.ts | 10 ++++++-- .../cli/src/serve/server/telemetry.test.ts | 24 +++++++++++++++++++ packages/cli/src/serve/server/telemetry.ts | 11 ++++++++- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 458fd8f4699..d76168522dc 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -543,7 +543,7 @@ export const serveCommand: CommandModule = { maxPendingPromptsPerSession, maxConnections: argv['max-connections'], eventRingSize: argv['event-ring-size'], - workspace: argv.workspace as string | undefined, + workspace: argv.workspace, requireAuth: argv['require-auth'], enableSessionShell: argv['enable-session-shell'], serveWebShell: argv.web, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index ad67c9bcb39..db2ae3d627d 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -424,7 +424,7 @@ describe('runQwenServe telemetry validation', () => { mode: 'http-bridge', workspace: [primary, secondary], maxSessions: 1, - } as unknown as Parameters[0]), + }), ).rejects.toThrow(/Multiple --workspace values are not supported yet/); }); @@ -445,7 +445,7 @@ describe('runQwenServe telemetry validation', () => { workspace: [primary], maxSessions: 1, serveWebShell: false, - } as unknown as Parameters[0], + }, { bridge: makeRuntimeBridge(), daemonLogBaseDir: path.join(tmpDir, 'debug'), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index a9182350700..2fa0111d7d3 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -153,6 +153,11 @@ const DEFAULT_EVENT_RING_SIZE = 8000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000; const WORKSPACE_SETTING_SCOPE = 'Workspace' as import('../config/settings.js').SettingScope; + +type RunQwenServeOptions = Omit & { + token?: string; + workspace?: string | string[]; +}; type WorkspaceSettingsWrite = import('./workspace-service/types.js').WorkspaceSettingsWrite; @@ -1336,7 +1341,7 @@ function runSynchronousRequestGate( * hard rule, not a warning, per the threat model in the design issue. */ export async function runQwenServe( - optsIn: Omit & { token?: string }, + optsIn: RunQwenServeOptions, deps: RunQwenServeDeps = {}, ): Promise { const runStartedAt = performance.now(); @@ -1396,11 +1401,13 @@ export async function runQwenServe( const chromeExtensionOriginAllowed = hasChromeExtensionOrigin( optsIn.allowOrigins, ); + const rawWorkspace = resolveSingleWorkspaceInput(optsIn.workspace); const opts: ServeOptions = { ...optsIn, token, promptDeadlineMs, writerIdleTimeoutMs, + workspace: rawWorkspace, clientMcpOverWs: optsIn.clientMcpOverWs ?? (!envFlagDisabled(clientMcpOverWsEnv) && @@ -1640,7 +1647,6 @@ export async function runQwenServe( // multiple daemon processes, not intra-daemon routing. // // Boot-loud validation: absolute path, exists, is a directory. - const rawWorkspace = resolveSingleWorkspaceInput(opts.workspace); if (!path.isAbsolute(rawWorkspace)) { throw new Error( `Invalid --workspace "${rawWorkspace}": must be an absolute path.`, diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 4fd3a024da9..cf35e5d65ae 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -160,4 +160,28 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { workspaceHash: 'hash:/workspace/two', }); }); + + it('memoizes workspace hashes by resolved workspace cwd', () => { + const mw = daemonTelemetryMiddleware(() => '/workspace/one'); + const firstRes = mockRes(200); + const secondRes = mockRes(200); + + mw( + mockReq('POST', '/session'), + firstRes, + vi.fn() as unknown as NextFunction, + ); + firstRes.emit('finish'); + mw( + mockReq('POST', '/session/abc/prompt'), + secondRes, + vi.fn() as unknown as NextFunction, + ); + secondRes.emit('finish'); + + expect(coreMocks.hashDaemonWorkspace).toHaveBeenCalledTimes(1); + expect(coreMocks.hashDaemonWorkspace).toHaveBeenCalledWith( + '/workspace/one', + ); + }); }); diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 4b418e42f74..276e9b28f25 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -181,13 +181,22 @@ export function daemonTelemetryMiddleware( // traffic rather than static-asset or unrouted noise. recordRequest?: (durationMs: number, statusCode: number) => void, ): (req: Request, res: Response, next: NextFunction) => void { + const workspaceHashByCwd = new Map(); + const resolveWorkspaceHash = (workspaceCwd: string): string => { + const existing = workspaceHashByCwd.get(workspaceCwd); + if (existing !== undefined) return existing; + const workspaceHash = hashDaemonWorkspace(workspaceCwd); + workspaceHashByCwd.set(workspaceCwd, workspaceHash); + return workspaceHash; + }; + return (req, res, next) => { const route = resolveDaemonTelemetryRoute(req); if (!route) { next(); return; } - const workspaceHash = hashDaemonWorkspace(resolveWorkspaceCwd(req)); + const workspaceHash = resolveWorkspaceHash(resolveWorkspaceCwd(req)); const rawClientId = req.get(CLIENT_ID_HEADER); const clientId = rawClientId !== undefined &&