Back to Qwen Code

DaemonWorkspaceService Implementation Plan

docs/superpowers/plans/2026-05-27-daemon-workspace-service.md

0.18.147.9 KB
Original Source

DaemonWorkspaceService Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extract all workspace-scoped capabilities from HttpAcpBridge into a new DaemonWorkspaceService, enabling /acp transport parity and honest rename to AcpSessionBridge.

Architecture: Scope-based split — workspace-scoped ops go to a new facade (DaemonWorkspaceService) with 4 internal sub-services; session-scoped ops stay in bridge. Child-dependent workspace ops delegate via injected callbacks. Both REST and /acp call the same L2 service.

Tech Stack: TypeScript, Vitest, Express (REST routes), JSON-RPC (ACP), supertest (integration)

Spec: docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md


File Map

New Files

FileResponsibility
packages/cli/src/serve/workspace-service/types.tsWorkspaceRequestContext, sub-service interfaces, deps interface, result types
packages/cli/src/serve/workspace-service/index.tsFacade factory createDaemonWorkspaceService
packages/cli/src/serve/workspace-service/fileService.tsFileService — wraps fsFactory
packages/cli/src/serve/workspace-service/authService.tsAuthService — wraps DeviceFlowRegistry
packages/cli/src/serve/workspace-service/agentsService.tsAgentsService — wraps SubagentManager
packages/cli/src/serve/workspace-service/memoryService.tsMemoryService — wraps memory file ops
packages/cli/src/serve/workspace-service/__tests__/fileService.test.tsFileService unit tests
packages/cli/src/serve/workspace-service/__tests__/authService.test.tsAuthService unit tests
packages/cli/src/serve/workspace-service/__tests__/agentsService.test.tsAgentsService unit tests
packages/cli/src/serve/workspace-service/__tests__/memoryService.test.tsMemoryService unit tests
packages/cli/src/serve/workspace-service/__tests__/facade.test.tsFacade + workspace-scoped methods (status/tool/init/restart) unit tests
packages/cli/src/serve/workspace-service/__tests__/e2e.test.tsREST ↔ /acp equivalence e2e tests

Modified Files

FileChange
packages/acp-bridge/src/bridgeTypes.tsRename interface + remove 8 methods + add 2 new methods
packages/acp-bridge/src/bridge.tsRemove 8 workspace methods, expose queryWorkspaceStatus + invokeWorkspaceCommand, rename factory
packages/acp-bridge/src/bridgeOptions.tsUpdate JSDoc references
packages/acp-bridge/src/status.tsUpdate error message class name
packages/cli/src/serve/httpAcpBridge.ts → rename to acpSessionBridge.tsUpdate re-exports
packages/cli/src/serve/runQwenServe.tsConstruct workspace service, inject callbacks
packages/cli/src/serve/server.tsRewire workspace routes to call service
packages/cli/src/serve/workspaceAgents.tsExtract business logic → agentsService, keep as route shell
packages/cli/src/serve/workspaceMemory.tsExtract business logic → memoryService, keep as route shell
packages/cli/src/serve/routes/workspaceFileRead.tsRewire to call FileService
packages/cli/src/serve/routes/workspaceFileWrite.tsRewire to call FileService

Task 1: Types & Interfaces

Files:

  • Create: packages/cli/src/serve/workspace-service/types.ts

  • Step 1: Create types file with all interfaces

ts
// packages/cli/src/serve/workspace-service/types.ts
import type { WorkspaceFileSystemFactory } from '../fs/index.js';
import type { DeviceFlowRegistry } from '../auth/deviceFlow.js';
import type {
  ServeWorkspaceMcpStatus,
  ServeWorkspaceSkillsStatus,
  ServeWorkspaceProvidersStatus,
  ServeWorkspaceEnvStatus,
  ServeWorkspacePreflightStatus,
} from '@qwen-code/acp-bridge';

// --- Request Context ---

export interface WorkspaceRequestContext {
  originatorClientId?: string;
  sessionId?: string;
  route: string;
  workspaceCwd: string;
}

// --- Sub-service interfaces ---

export interface FileService {
  read(
    ctx: WorkspaceRequestContext,
    path: string,
    opts?: { maxBytes?: number },
  ): Promise<FileReadResult>;
  readBytes(ctx: WorkspaceRequestContext, path: string): Promise<Buffer>;
  write(
    ctx: WorkspaceRequestContext,
    path: string,
    content: string,
    opts?: { mode?: string },
  ): Promise<FileWriteResult>;
  edit(
    ctx: WorkspaceRequestContext,
    path: string,
    edits: FileEdit[],
  ): Promise<FileEditResult>;
  glob(ctx: WorkspaceRequestContext, pattern: string): Promise<string[]>;
  list(ctx: WorkspaceRequestContext, path: string): Promise<ListEntry[]>;
  stat(ctx: WorkspaceRequestContext, path: string): Promise<StatResult>;
}

export interface AuthService {
  startFlow(ctx: WorkspaceRequestContext): Promise<DeviceFlowStartResult>;
  getFlowStatus(
    ctx: WorkspaceRequestContext,
    flowId: string,
  ): Promise<DeviceFlowStatus>;
  cancelFlow(ctx: WorkspaceRequestContext, flowId: string): Promise<void>;
  getAuthStatus(ctx: WorkspaceRequestContext): Promise<AuthStatusResult>;
}

export interface AgentsService {
  list(ctx: WorkspaceRequestContext): Promise<AgentSummary[]>;
  get(ctx: WorkspaceRequestContext, agentType: string): Promise<AgentDetail>;
  create(
    ctx: WorkspaceRequestContext,
    spec: AgentCreateSpec,
  ): Promise<AgentDetail>;
  update(
    ctx: WorkspaceRequestContext,
    agentType: string,
    spec: AgentUpdateSpec,
  ): Promise<AgentDetail>;
  delete(
    ctx: WorkspaceRequestContext,
    agentType: string,
    opts?: { scope?: string },
  ): Promise<void>;
}

export interface MemoryService {
  list(ctx: WorkspaceRequestContext): Promise<MemoryEntry[]>;
  read(ctx: WorkspaceRequestContext, key: string): Promise<MemoryContent>;
  write(
    ctx: WorkspaceRequestContext,
    key: string,
    content: string,
  ): Promise<void>;
  delete(ctx: WorkspaceRequestContext, key: string): Promise<void>;
}

// --- Facade interface ---

export interface DaemonWorkspaceService {
  file: FileService;
  auth: AuthService;
  agents: AgentsService;
  memory: MemoryService;

  initWorkspace(
    opts: InitWorkspaceOpts,
    ctx: WorkspaceRequestContext,
  ): Promise<void>;
  setToolEnabled(
    toolName: string,
    enabled: boolean,
    ctx: WorkspaceRequestContext,
  ): Promise<ToolToggleResult>;

  getMcpStatus(): Promise<ServeWorkspaceMcpStatus>;
  getSkillsStatus(): Promise<ServeWorkspaceSkillsStatus>;
  getProvidersStatus(): Promise<ServeWorkspaceProvidersStatus>;
  getEnvStatus(): Promise<ServeWorkspaceEnvStatus>;
  getPreflightStatus(): Promise<ServeWorkspacePreflightStatus>;
  restartMcpServer(
    serverName: string,
    ctx: WorkspaceRequestContext,
    opts?: RestartMcpOpts,
  ): Promise<RestartMcpResult>;
}

// --- Deps (callback injection) ---

export interface WorkspaceEvent {
  type: string;
  data: Record<string, unknown>;
  originatorClientId?: string;
}

export interface DaemonWorkspaceServiceDeps {
  fsFactory: WorkspaceFileSystemFactory;
  deviceFlowRegistry: DeviceFlowRegistry;
  subagentManager: unknown; // type from workspaceAgents.ts — refine during implementation
  boundWorkspace: string;
  contextFilename: string;
  persistDisabledTools: (
    workspace: string,
    tool: string,
    enabled: boolean,
  ) => Promise<void>;

  // Cross-cutting callbacks (session-derived infrastructure)
  publishWorkspaceEvent: (event: WorkspaceEvent) => void;
  knownClientIds: () => Set<string>;

  // Child delegation callbacks
  queryWorkspaceStatus: <T>(method: string, idle: () => T) => Promise<T>;
  invokeWorkspaceCommand: <T>(
    method: string,
    params?: Record<string, unknown>,
    opts?: { timeoutMs?: number },
  ) => Promise<T>;
}

// --- Result types (refine from existing code during implementation) ---

export interface FileReadResult {
  content: string;
  truncated: boolean;
  bytesRead: number;
}
export interface FileWriteResult {
  ok: boolean;
  filePath: string;
  bytesWritten: number;
  mode?: string;
}
export interface FileEdit {
  oldText: string;
  newText: string;
}
export interface FileEditResult {
  ok: boolean;
  filePath: string;
}
export interface ListEntry {
  name: string;
  type: 'file' | 'directory' | 'symlink';
}
export interface StatResult {
  exists: boolean;
  isFile: boolean;
  isDirectory: boolean;
  size: number;
}
export interface DeviceFlowStartResult {
  flowId: string;
  verificationUri: string;
  userCode: string;
}
export interface DeviceFlowStatus {
  state: string /* refine from existing types */;
}
export interface AuthStatusResult {
  authenticated: boolean /* refine from existing */;
}
export interface AgentSummary {
  agentType: string /* refine */;
}
export interface AgentDetail {
  agentType: string /* refine */;
}
export interface AgentCreateSpec {
  agentType: string;
  content: string /* refine */;
}
export interface AgentUpdateSpec {
  content: string /* refine */;
}
export interface MemoryEntry {
  key: string /* refine */;
}
export interface MemoryContent {
  key: string;
  content: string;
}
export interface InitWorkspaceOpts {
  /* refine from bridge.ts:3256 */
}
export interface ToolToggleResult {
  toolName: string;
  enabled: boolean;
}
export interface RestartMcpOpts {
  entryIndex?: number;
}
export interface RestartMcpResult {
  serverName: string;
  restarted: boolean;
  durationMs?: number;
}

Note: Result types marked /* refine */ should be aligned with existing response shapes during implementation. Read the current route handlers to get exact fields.

  • Step 2: Verify types compile

Run: cd packages/cli && npx tsc --noEmit src/serve/workspace-service/types.ts Expected: No errors (may need to adjust imports based on actual export paths)

  • Step 3: Commit
bash
git add packages/cli/src/serve/workspace-service/types.ts
git commit -m "feat(serve): add DaemonWorkspaceService type definitions"

Task 2: FileService (TDD)

Files:

  • Create: packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts

  • Create: packages/cli/src/serve/workspace-service/fileService.ts

  • Step 1: Write failing tests for FileService.read

ts
// packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createFileService } from '../fileService.js';
import type { WorkspaceRequestContext } from '../types.js';

function makeCtx(
  overrides: Partial<WorkspaceRequestContext> = {},
): WorkspaceRequestContext {
  return { route: 'GET /file', workspaceCwd: '/workspace', ...overrides };
}

describe('FileService', () => {
  describe('read', () => {
    it('calls fsFactory.forRequest with context and delegates to readFile', async () => {
      const mockFs = {
        readFile: vi
          .fn()
          .mockResolvedValue({
            content: 'hello',
            truncated: false,
            bytesRead: 5,
          }),
      };
      const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) };
      const service = createFileService({
        fsFactory: fsFactory as any,
        boundWorkspace: '/workspace',
      });

      const result = await service.read(
        makeCtx({ originatorClientId: 'c1' }),
        'src/app.ts',
      );

      expect(fsFactory.forRequest).toHaveBeenCalledWith({
        originatorClientId: 'c1',
        route: 'GET /file',
      });
      expect(mockFs.readFile).toHaveBeenCalledWith('src/app.ts', undefined);
      expect(result.content).toBe('hello');
    });

    it('works without originatorClientId (read-only, no auth required)', async () => {
      const mockFs = {
        readFile: vi
          .fn()
          .mockResolvedValue({ content: '', truncated: false, bytesRead: 0 }),
      };
      const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) };
      const service = createFileService({
        fsFactory: fsFactory as any,
        boundWorkspace: '/workspace',
      });

      await service.read(makeCtx(), 'README.md');

      expect(fsFactory.forRequest).toHaveBeenCalledWith({
        originatorClientId: undefined,
        route: 'GET /file',
      });
    });
  });
});
  • Step 2: Run test to verify it fails

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts Expected: FAIL — createFileService not found

  • Step 3: Implement FileService
ts
// packages/cli/src/serve/workspace-service/fileService.ts
import type { WorkspaceFileSystemFactory } from '../fs/index.js';
import type {
  FileService,
  WorkspaceRequestContext,
  FileReadResult,
  FileWriteResult,
  FileEdit,
  FileEditResult,
  ListEntry,
  StatResult,
} from './types.js';

export interface FileServiceDeps {
  fsFactory: WorkspaceFileSystemFactory;
  boundWorkspace: string;
}

export function createFileService(deps: FileServiceDeps): FileService {
  const { fsFactory } = deps;

  function scopedFs(ctx: WorkspaceRequestContext) {
    return fsFactory.forRequest({
      originatorClientId: ctx.originatorClientId,
      route: ctx.route,
      ...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}),
    });
  }

  return {
    async read(ctx, path, opts) {
      const fs = scopedFs(ctx);
      return fs.readFile(path, opts?.maxBytes);
    },
    async readBytes(ctx, path) {
      const fs = scopedFs(ctx);
      return fs.readFileBytes(path);
    },
    async write(ctx, path, content, opts) {
      const fs = scopedFs(ctx);
      return fs.writeFile(path, content, opts);
    },
    async edit(ctx, path, edits) {
      const fs = scopedFs(ctx);
      return fs.editFile(path, edits);
    },
    async glob(ctx, pattern) {
      const fs = scopedFs(ctx);
      return fs.glob(pattern);
    },
    async list(ctx, path) {
      const fs = scopedFs(ctx);
      return fs.listDirectory(path);
    },
    async stat(ctx, path) {
      const fs = scopedFs(ctx);
      return fs.stat(path);
    },
  };
}

Important: The method names on WorkspaceFileSystem (readFile, readFileBytes, writeFile, editFile, glob, listDirectory, stat) must be verified against the actual interface at packages/cli/src/serve/fs/workspaceFileSystem.ts. Adjust if they differ.

  • Step 4: Run test to verify it passes

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts Expected: PASS

  • Step 5: Add tests for write (trust gate validates clientId when present)

Add to the test file:

ts
describe('write', () => {
  it('passes originatorClientId to forRequest for audit', async () => {
    const mockFs = {
      writeFile: vi
        .fn()
        .mockResolvedValue({
          ok: true,
          filePath: '/workspace/f.ts',
          bytesWritten: 3,
        }),
    };
    const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) };
    const service = createFileService({
      fsFactory: fsFactory as any,
      boundWorkspace: '/workspace',
    });

    await service.write(
      makeCtx({ originatorClientId: 'c1', route: 'POST /file/write' }),
      'f.ts',
      'abc',
    );

    expect(fsFactory.forRequest).toHaveBeenCalledWith({
      originatorClientId: 'c1',
      route: 'POST /file/write',
    });
    expect(mockFs.writeFile).toHaveBeenCalledWith('f.ts', 'abc', undefined);
  });
});
  • Step 6: Run full FileService tests

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts Expected: All PASS

  • Step 7: Commit
bash
git add packages/cli/src/serve/workspace-service/fileService.ts packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts
git commit -m "feat(serve): add FileService wrapping fsFactory (TDD)"

Task 3: AuthService (TDD)

Files:

  • Create: packages/cli/src/serve/workspace-service/__tests__/authService.test.ts

  • Create: packages/cli/src/serve/workspace-service/authService.ts

  • Step 1: Read existing auth route logic

Read: packages/cli/src/serve/server.ts:794-966 (device flow routes) and packages/cli/src/serve/auth/deviceFlow.ts to understand the DeviceFlowRegistry interface.

  • Step 2: Write failing test
ts
// packages/cli/src/serve/workspace-service/__tests__/authService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createAuthService } from '../authService.js';
import type { WorkspaceRequestContext } from '../types.js';

const ctx: WorkspaceRequestContext = {
  route: 'POST /workspace/auth/device-flow',
  workspaceCwd: '/w',
};

describe('AuthService', () => {
  it('startFlow delegates to registry.start and returns flowId + verificationUri + userCode', async () => {
    const registry = {
      start: vi
        .fn()
        .mockReturnValue({
          id: 'flow-1',
          verificationUri: 'https://auth.example/device',
          userCode: 'ABCD-1234',
        }),
    };
    const service = createAuthService({ deviceFlowRegistry: registry as any });

    const result = await service.startFlow(ctx);

    expect(registry.start).toHaveBeenCalled();
    expect(result.flowId).toBe('flow-1');
    expect(result.verificationUri).toBe('https://auth.example/device');
  });

  it('cancelFlow delegates to registry.cancel', async () => {
    const registry = { cancel: vi.fn().mockReturnValue({ cancelled: true }) };
    const service = createAuthService({ deviceFlowRegistry: registry as any });

    await service.cancelFlow(ctx, 'flow-1');

    expect(registry.cancel).toHaveBeenCalledWith('flow-1', undefined);
  });
});
  • Step 3: Run test — verify fail

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/authService.test.ts Expected: FAIL

  • Step 4: Implement AuthService
ts
// packages/cli/src/serve/workspace-service/authService.ts
import type { DeviceFlowRegistry } from '../auth/deviceFlow.js';
import type {
  AuthService,
  WorkspaceRequestContext,
  DeviceFlowStartResult,
  DeviceFlowStatus,
  AuthStatusResult,
} from './types.js';

export interface AuthServiceDeps {
  deviceFlowRegistry: DeviceFlowRegistry;
}

export function createAuthService(deps: AuthServiceDeps): AuthService {
  const { deviceFlowRegistry } = deps;

  return {
    async startFlow(ctx) {
      const flow = deviceFlowRegistry.start(ctx.originatorClientId);
      return {
        flowId: flow.id,
        verificationUri: flow.verificationUri,
        userCode: flow.userCode,
      };
    },
    async getFlowStatus(ctx, flowId) {
      return deviceFlowRegistry.get(flowId);
    },
    async cancelFlow(ctx, flowId) {
      deviceFlowRegistry.cancel(flowId, ctx.originatorClientId);
    },
    async getAuthStatus(_ctx) {
      return deviceFlowRegistry.getStatus();
    },
  };
}

Note: Method names on DeviceFlowRegistry (start, get, cancel, getStatus) must be verified against packages/cli/src/serve/auth/deviceFlow.ts. Adjust signatures as needed.

  • Step 5: Run test — verify pass

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/authService.test.ts Expected: PASS

  • Step 6: Commit
bash
git add packages/cli/src/serve/workspace-service/authService.ts packages/cli/src/serve/workspace-service/__tests__/authService.test.ts
git commit -m "feat(serve): add AuthService wrapping DeviceFlowRegistry (TDD)"

Task 4: AgentsService (TDD)

Files:

  • Create: packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts

  • Create: packages/cli/src/serve/workspace-service/agentsService.ts

  • Step 1: Read existing agent logic

Read: packages/cli/src/serve/workspaceAgents.ts — extract the business logic (validation, SubagentManager calls, event publishing). Note: this file is ~700+ lines with route handling mixed in.

  • Step 2: Write failing test — list + clientId validation
ts
// packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createAgentsService } from '../agentsService.js';
import type { WorkspaceRequestContext } from '../types.js';

const ctx: WorkspaceRequestContext = {
  route: 'GET /workspace/agents',
  workspaceCwd: '/w',
  originatorClientId: 'c1',
};

describe('AgentsService', () => {
  it('list returns agents from subagentManager', async () => {
    const subagentManager = {
      list: vi.fn().mockResolvedValue([{ agentType: 'reviewer' }]),
    };
    const deps = {
      subagentManager,
      publishWorkspaceEvent: vi.fn(),
      knownClientIds: () => new Set(['c1']),
    };
    const service = createAgentsService(deps as any);

    const result = await service.list(ctx);

    expect(result).toEqual([{ agentType: 'reviewer' }]);
  });

  it('create publishes workspace event after success', async () => {
    const subagentManager = {
      create: vi
        .fn()
        .mockResolvedValue({ agentType: 'helper', content: '...' }),
    };
    const publishWorkspaceEvent = vi.fn();
    const deps = {
      subagentManager,
      publishWorkspaceEvent,
      knownClientIds: () => new Set(['c1']),
    };
    const service = createAgentsService(deps as any);

    await service.create(ctx, { agentType: 'helper', content: 'prompt' });

    expect(publishWorkspaceEvent).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'agent_created' }),
    );
  });

  it('rejects unknown clientId on mutation', async () => {
    const deps = {
      subagentManager: { create: vi.fn() },
      publishWorkspaceEvent: vi.fn(),
      knownClientIds: () => new Set(['c2']), // c1 not in set
    };
    const service = createAgentsService(deps as any);

    await expect(
      service.create(ctx, { agentType: 'x', content: '' }),
    ).rejects.toThrow(/not registered/);
  });
});
  • Step 3: Run test — verify fail

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/agentsService.test.ts Expected: FAIL

  • Step 4: Implement AgentsService

Extract business logic from packages/cli/src/serve/workspaceAgents.ts into:

ts
// packages/cli/src/serve/workspace-service/agentsService.ts
import type {
  AgentsService,
  WorkspaceRequestContext,
  WorkspaceEvent,
} from './types.js';

export interface AgentsServiceDeps {
  subagentManager: any; // refine type from workspaceAgents.ts
  publishWorkspaceEvent: (event: WorkspaceEvent) => void;
  knownClientIds: () => Set<string>;
}

function validateClientId(
  deps: AgentsServiceDeps,
  ctx: WorkspaceRequestContext,
): void {
  if (
    ctx.originatorClientId &&
    !deps.knownClientIds().has(ctx.originatorClientId)
  ) {
    throw new Error(
      `Client id "${ctx.originatorClientId}" is not registered for this workspace`,
    );
  }
}

export function createAgentsService(deps: AgentsServiceDeps): AgentsService {
  return {
    async list(_ctx) {
      return deps.subagentManager.list();
    },
    async get(_ctx, agentType) {
      return deps.subagentManager.get(agentType);
    },
    async create(ctx, spec) {
      validateClientId(deps, ctx);
      const result = await deps.subagentManager.create(spec);
      deps.publishWorkspaceEvent({
        type: 'agent_created',
        data: { agentType: spec.agentType },
        originatorClientId: ctx.originatorClientId,
      });
      return result;
    },
    async update(ctx, agentType, spec) {
      validateClientId(deps, ctx);
      const result = await deps.subagentManager.update(agentType, spec);
      deps.publishWorkspaceEvent({
        type: 'agent_updated',
        data: { agentType },
        originatorClientId: ctx.originatorClientId,
      });
      return result;
    },
    async delete(ctx, agentType, opts) {
      validateClientId(deps, ctx);
      await deps.subagentManager.delete(agentType, opts);
      deps.publishWorkspaceEvent({
        type: 'agent_deleted',
        data: { agentType },
        originatorClientId: ctx.originatorClientId,
      });
    },
  };
}

Important: The actual SubagentManager interface and event types must be extracted from workspaceAgents.ts during implementation. The above is the pattern; exact method names/params will differ.

  • Step 5: Run test — verify pass

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/agentsService.test.ts Expected: PASS

  • Step 6: Commit
bash
git add packages/cli/src/serve/workspace-service/agentsService.ts packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts
git commit -m "feat(serve): add AgentsService with clientId validation and event publish (TDD)"

Task 5: MemoryService (TDD)

Files:

  • Create: packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts

  • Create: packages/cli/src/serve/workspace-service/memoryService.ts

  • Step 1: Read existing memory logic

Read: packages/cli/src/serve/workspaceMemory.ts — understand how memory CRUD works (likely file-based with writeWorkspaceContextFile or similar).

  • Step 2: Write failing test
ts
// packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createMemoryService } from '../memoryService.js';
import type { WorkspaceRequestContext } from '../types.js';

const ctx: WorkspaceRequestContext = {
  route: 'POST /workspace/memory',
  workspaceCwd: '/w',
  originatorClientId: 'c1',
};

describe('MemoryService', () => {
  it('write publishes workspace event', async () => {
    const publishWorkspaceEvent = vi.fn();
    const deps = {
      // mock whatever memory backend is used
      publishWorkspaceEvent,
      knownClientIds: () => new Set(['c1']),
      boundWorkspace: '/w',
    };
    const service = createMemoryService(deps as any);

    await service.write(ctx, 'user-prefs', 'dark mode');

    expect(publishWorkspaceEvent).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'memory_written' }),
    );
  });

  it('rejects unknown clientId on write', async () => {
    const deps = {
      publishWorkspaceEvent: vi.fn(),
      knownClientIds: () => new Set(['other']),
      boundWorkspace: '/w',
    };
    const service = createMemoryService(deps as any);

    await expect(service.write(ctx, 'key', 'val')).rejects.toThrow(
      /not registered/,
    );
  });
});
  • Step 3: Implement MemoryService

Extract logic from packages/cli/src/serve/workspaceMemory.ts. Pattern identical to AgentsService: validate clientId on mutations, delegate to backend, publish event.

  • Step 4: Run tests — verify pass

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/memoryService.test.ts Expected: PASS

  • Step 5: Commit
bash
git add packages/cli/src/serve/workspace-service/memoryService.ts packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts
git commit -m "feat(serve): add MemoryService with event publish (TDD)"

Task 6: Facade + Workspace-Scoped Methods (TDD)

Files:

  • Create: packages/cli/src/serve/workspace-service/__tests__/facade.test.ts

  • Create: packages/cli/src/serve/workspace-service/index.ts

  • Step 1: Write failing test for facade construction + status delegation

ts
// packages/cli/src/serve/workspace-service/__tests__/facade.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createDaemonWorkspaceService } from '../index.js';
import type { WorkspaceRequestContext } from '../types.js';

const ctx: WorkspaceRequestContext = {
  route: 'POST /workspace/init',
  workspaceCwd: '/w',
};

describe('DaemonWorkspaceService', () => {
  function makeDeps(overrides = {}) {
    return {
      fsFactory: { forRequest: vi.fn().mockReturnValue({}) },
      deviceFlowRegistry: {},
      subagentManager: {},
      boundWorkspace: '/w',
      contextFilename: 'QWEN.md',
      persistDisabledTools: vi.fn(),
      publishWorkspaceEvent: vi.fn(),
      knownClientIds: () => new Set<string>(),
      queryWorkspaceStatus: vi
        .fn()
        .mockImplementation((_m, idle) => Promise.resolve(idle())),
      invokeWorkspaceCommand: vi.fn(),
      ...overrides,
    };
  }

  it('exposes file, auth, agents, memory sub-services', () => {
    const service = createDaemonWorkspaceService(makeDeps());
    expect(service.file).toBeDefined();
    expect(service.auth).toBeDefined();
    expect(service.agents).toBeDefined();
    expect(service.memory).toBeDefined();
  });

  it('getMcpStatus delegates to queryWorkspaceStatus callback', async () => {
    const idle = { servers: [] };
    const queryWorkspaceStatus = vi.fn().mockResolvedValue(idle);
    const service = createDaemonWorkspaceService(
      makeDeps({ queryWorkspaceStatus }),
    );

    const result = await service.getMcpStatus();

    expect(queryWorkspaceStatus).toHaveBeenCalled();
    expect(result).toBe(idle);
  });

  it('setToolEnabled calls persistDisabledTools + publishes event', async () => {
    const persistDisabledTools = vi.fn().mockResolvedValue(undefined);
    const publishWorkspaceEvent = vi.fn();
    const service = createDaemonWorkspaceService(
      makeDeps({ persistDisabledTools, publishWorkspaceEvent }),
    );

    const result = await service.setToolEnabled('Bash', false, ctx);

    expect(persistDisabledTools).toHaveBeenCalledWith('/w', 'Bash', false);
    expect(publishWorkspaceEvent).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'tool_toggled',
        data: { toolName: 'Bash', enabled: false },
      }),
    );
    expect(result).toEqual({ toolName: 'Bash', enabled: false });
  });
});
  • Step 2: Run test — verify fail

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/facade.test.ts Expected: FAIL

  • Step 3: Implement facade factory
ts
// packages/cli/src/serve/workspace-service/index.ts
import type {
  DaemonWorkspaceService,
  DaemonWorkspaceServiceDeps,
} from './types.js';
import { createFileService } from './fileService.js';
import { createAuthService } from './authService.js';
import { createAgentsService } from './agentsService.js';
import { createMemoryService } from './memoryService.js';
import { SERVE_STATUS_EXT_METHODS } from '@qwen-code/acp-bridge';

export {
  type DaemonWorkspaceService,
  type DaemonWorkspaceServiceDeps,
  type WorkspaceRequestContext,
} from './types.js';

export function createDaemonWorkspaceService(
  deps: DaemonWorkspaceServiceDeps,
): DaemonWorkspaceService {
  const file = createFileService({
    fsFactory: deps.fsFactory,
    boundWorkspace: deps.boundWorkspace,
  });
  const auth = createAuthService({
    deviceFlowRegistry: deps.deviceFlowRegistry,
  });
  const agents = createAgentsService({
    subagentManager: deps.subagentManager,
    publishWorkspaceEvent: deps.publishWorkspaceEvent,
    knownClientIds: deps.knownClientIds,
  });
  const memory = createMemoryService({
    publishWorkspaceEvent: deps.publishWorkspaceEvent,
    knownClientIds: deps.knownClientIds,
    boundWorkspace: deps.boundWorkspace,
  });

  return {
    file,
    auth,
    agents,
    memory,

    async initWorkspace(opts, ctx) {
      // Migrate logic from bridge.ts:3256 — local file creation via fsFactory
      const fs = deps.fsFactory.forRequest({
        originatorClientId: ctx.originatorClientId,
        route: ctx.route,
      });
      // ... path validation + file creation (copy from bridge.ts:3256-3350)
    },

    async setToolEnabled(toolName, enabled, ctx) {
      await deps.persistDisabledTools(deps.boundWorkspace, toolName, enabled);
      deps.publishWorkspaceEvent({
        type: 'tool_toggled',
        data: { toolName, enabled },
        ...(ctx.originatorClientId
          ? { originatorClientId: ctx.originatorClientId }
          : {}),
      });
      return { toolName, enabled };
    },

    async getMcpStatus() {
      return deps.queryWorkspaceStatus(
        SERVE_STATUS_EXT_METHODS.workspaceMcp,
        () => createIdleMcpStatus(deps.boundWorkspace),
      );
    },
    async getSkillsStatus() {
      return deps.queryWorkspaceStatus(
        SERVE_STATUS_EXT_METHODS.workspaceSkills,
        () => ({ skills: [] }),
      );
    },
    async getProvidersStatus() {
      return deps.queryWorkspaceStatus(
        SERVE_STATUS_EXT_METHODS.workspaceProviders,
        () => ({ providers: [] }),
      );
    },
    async getEnvStatus() {
      return deps.queryWorkspaceStatus(
        SERVE_STATUS_EXT_METHODS.workspaceEnv,
        () => ({ env: [] }),
      );
    },
    async getPreflightStatus() {
      return deps.queryWorkspaceStatus(
        SERVE_STATUS_EXT_METHODS.workspacePreflight,
        () => ({ checks: [] }),
      );
    },

    async restartMcpServer(serverName, ctx, opts) {
      const params: Record<string, unknown> = { serverName };
      if (opts?.entryIndex !== undefined)
        params['entryIndex'] = opts.entryIndex;
      const result = await deps.invokeWorkspaceCommand(
        SERVE_STATUS_EXT_METHODS.workspaceMcpRestart ??
          'qwen/control/workspace/mcp/restart',
        params,
      );
      deps.publishWorkspaceEvent({
        type: 'mcp_server_restarted',
        data: { serverName, ...(result as object) },
        ...(ctx.originatorClientId
          ? { originatorClientId: ctx.originatorClientId }
          : {}),
      });
      return result as any;
    },
  };
}

Critical: initWorkspace implementation must be copied from bridge.ts:3256-3350 (path validation, symlink checks, file creation). Use fsFactory.forRequest(ctx) instead of raw node:fs/promises — this fixes the existing FIXME.

  • Step 4: Run test — verify pass

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/facade.test.ts Expected: PASS

  • Step 5: Commit
bash
git add packages/cli/src/serve/workspace-service/index.ts packages/cli/src/serve/workspace-service/__tests__/facade.test.ts
git commit -m "feat(serve): add DaemonWorkspaceService facade with status/tool/init/restart (TDD)"

Task 7: Bridge — Expose Child Delegation + Remove Workspace Methods

Files:

  • Modify: packages/acp-bridge/src/bridge.ts

  • Modify: packages/acp-bridge/src/bridgeTypes.ts

  • Step 1: Add queryWorkspaceStatus and invokeWorkspaceCommand to bridge interface

In packages/acp-bridge/src/bridgeTypes.ts, add to the interface (which is still named HttpAcpBridge at this point):

ts
  queryWorkspaceStatus<T>(method: string, idle: () => T): Promise<T>;
  invokeWorkspaceCommand<T>(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<T>;
  • Step 2: Implement them in bridge.ts

In packages/acp-bridge/src/bridge.ts, add to the returned object (near the existing requestWorkspaceStatus usage):

ts
    queryWorkspaceStatus(method, idle) {
      return requestWorkspaceStatus(method, idle);
    },
    invokeWorkspaceCommand(method, params, opts) {
      const info = liveChannelInfo();
      if (!info) throw new SessionNotFoundError(`workspace-command:${method}`);
      const timeout = opts?.timeoutMs ?? initTimeoutMs;
      return withTimeout(
        Promise.race([
          info.connection.extMethod(method, { ...params, cwd: boundWorkspace }),
          getChannelClosedReject(info),
        ]),
        timeout,
        method,
      ) as Promise<any>;
    },
  • Step 3: Remove the 8 workspace methods from bridge

Remove from bridge.ts:

  • initWorkspace (lines ~3256-3550)
  • setWorkspaceToolEnabled (lines ~3071-3093)
  • getWorkspaceMcpStatus / getWorkspaceSkillsStatus / getWorkspaceProvidersStatus / getWorkspaceEnvStatus / getWorkspacePreflightStatus (lines ~2665-2790)
  • restartMcpServer (lines ~3093-3256)

Remove their signatures from bridgeTypes.ts.

  • Step 4: Run bridge tests to verify nothing is broken

Run: cd packages/acp-bridge && npx vitest run Expected: Some tests may reference removed methods — fix those (they should now test via the facade in integration).

  • Step 5: Commit
bash
git add packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridgeTypes.ts
git commit -m "refactor(bridge): extract workspace methods, expose queryWorkspaceStatus + invokeWorkspaceCommand"

Task 8: Bridge Rename (HttpAcpBridge → AcpSessionBridge)

Files:

  • Modify: packages/acp-bridge/src/bridgeTypes.ts

  • Modify: packages/acp-bridge/src/bridge.ts

  • Modify: packages/acp-bridge/src/bridgeOptions.ts

  • Modify: packages/acp-bridge/src/status.ts

  • Modify: packages/acp-bridge/src/index.ts

  • Rename: packages/cli/src/serve/httpAcpBridge.tspackages/cli/src/serve/acpSessionBridge.ts

  • Modify: packages/cli/src/serve/runQwenServe.ts (import paths)

  • Modify: all files importing HttpAcpBridge or createHttpAcpBridge

  • Step 1: Rename interface + factory function in acp-bridge package

In bridgeTypes.ts:

ts
// Before: export interface HttpAcpBridge {
// After:
export interface AcpSessionBridge {

In bridge.ts:

ts
// Before: export function createHttpAcpBridge(
// After:
export function createAcpSessionBridge(

Add deprecated re-export for safety:

ts
/** @deprecated Use AcpSessionBridge */
export type HttpAcpBridge = AcpSessionBridge;
/** @deprecated Use createAcpSessionBridge */
export const createHttpAcpBridge = createAcpSessionBridge;
  • Step 2: Rename file in cli package
bash
git mv packages/cli/src/serve/httpAcpBridge.ts packages/cli/src/serve/acpSessionBridge.ts
  • Step 3: Update all imports project-wide
bash
# Find and fix all references
grep -rn "HttpAcpBridge\|createHttpAcpBridge\|httpAcpBridge" packages/ --include="*.ts" | grep -v node_modules | grep -v ".test.ts"

Update each file to use new names. Key files:

  • packages/cli/src/serve/runQwenServe.ts

  • packages/cli/src/serve/workspaceAgents.ts

  • packages/cli/src/serve/workspaceMemory.ts

  • packages/cli/src/serve/server.ts

  • packages/acp-bridge/src/status.ts (error message string)

  • packages/acp-bridge/src/bridgeOptions.ts (JSDoc)

  • Step 4: Run typecheck

Run: cd packages/cli && npx tsc --noEmit && cd ../acp-bridge && npx tsc --noEmit Expected: No type errors

  • Step 5: Run full test suites

Run: cd packages/acp-bridge && npx vitest run && cd ../cli && npx vitest run Expected: All pass (tests still use deprecated alias or are updated)

  • Step 6: Commit
bash
git add -A
git commit -m "refactor(bridge): rename HttpAcpBridge → AcpSessionBridge"

Task 9: Wire Service into runQwenServe + REST Routes

Files:

  • Modify: packages/cli/src/serve/runQwenServe.ts

  • Modify: packages/cli/src/serve/server.ts

  • Modify: packages/cli/src/serve/workspaceAgents.ts

  • Modify: packages/cli/src/serve/workspaceMemory.ts

  • Modify: packages/cli/src/serve/routes/workspaceFileRead.ts

  • Modify: packages/cli/src/serve/routes/workspaceFileWrite.ts

  • Step 1: Construct service in runQwenServe.ts

Add after bridge construction:

ts
import { createDaemonWorkspaceService } from './workspace-service/index.js';

// After bridge is created:
const workspace = createDaemonWorkspaceService({
  fsFactory,
  deviceFlowRegistry,
  subagentManager, // from existing construction
  boundWorkspace,
  contextFilename,
  persistDisabledTools,
  publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event),
  knownClientIds: () => bridge.knownClientIds(),
  queryWorkspaceStatus: (method, idle) =>
    bridge.queryWorkspaceStatus(method, idle),
  invokeWorkspaceCommand: (method, params, opts) =>
    bridge.invokeWorkspaceCommand(method, params, opts),
});

Pass workspace to createServeApp.

  • Step 2: Rewire workspace status routes in server.ts

Replace direct bridge calls with service calls:

ts
// Before:
app.get('/workspace/mcp', async (_req, res) => {
  res.status(200).json(await bridge.getWorkspaceMcpStatus());
});

// After:
app.get('/workspace/mcp', async (_req, res) => {
  res.status(200).json(await workspace.getMcpStatus());
});

Repeat for /workspace/skills, /workspace/providers, /workspace/env, /workspace/preflight, /workspace/init, tool toggle route.

  • Step 3: Rewire workspaceAgents.ts route shell

Change mountWorkspaceAgentsRoutes to receive workspace.agents instead of bridge:

ts
// deps.bridge.publishWorkspaceEvent → service handles internally
// deps.bridge.knownClientIds() → service handles internally
// Route handler becomes thin: parse request → build ctx → call service → send response
  • Step 4: Rewire workspaceMemory.ts route shell

Same pattern as agents.

  • Step 5: Rewire file routes

workspaceFileRead.ts and workspaceFileWrite.ts — change from calling fsFactory.forRequest directly to calling workspace.file.*:

ts
// Before:
const fs = getFsFactory(req, res);
const result = await fs.readFile(path, maxBytes);

// After:
const ctx = buildRequestContext(req);
const result = await workspace.file.read(ctx, path, { maxBytes });
  • Step 6: Run full test suite

Run: cd packages/cli && npx vitest run Expected: All existing route tests pass (HTTP surface unchanged)

  • Step 7: Commit
bash
git add -A
git commit -m "refactor(serve): wire DaemonWorkspaceService into REST routes"

Task 10: /acp Northbound Method Dispatch

Files:

  • Modify: relevant /acp handler file (locate via grep -rn "extMethod\|acpHttp\|acp-integration" packages/cli/src/)

  • Create or modify: northbound method dispatcher

  • Step 1: Locate the /acp method dispatch entry point

bash
grep -rn "method.*dispatch\|handleMethod\|jsonrpc.*method" packages/cli/src/acp-integration/ packages/cli/src/serve/ --include="*.ts" | grep -v test | head -20
  • Step 2: Add workspace method dispatch

In the /acp handler that routes JSON-RPC methods, add a switch/map for qwen/workspace/*:

ts
// Pattern (exact location depends on codebase structure):
case 'qwen/workspace/fs/read': {
  const ctx = buildAcpRequestContext(connection, 'qwen/workspace/fs/read');
  const { path } = params;
  return workspace.file.read(ctx, path);
}
case 'qwen/workspace/fs/write': {
  const ctx = buildAcpRequestContext(connection, 'qwen/workspace/fs/write');
  const { path, content, mode } = params;
  return workspace.file.write(ctx, path, content, { mode });
}
// ... all 27 methods

Build a helper buildAcpRequestContext that extracts clientId from the ACP connection and constructs WorkspaceRequestContext.

  • Step 3: Add capabilities advertisement

Ensure _meta.qwen.methods includes all qwen/workspace/* methods in the initialize response.

  • Step 4: Run typecheck

Run: cd packages/cli && npx tsc --noEmit Expected: No errors

  • Step 5: Commit
bash
git add -A
git commit -m "feat(serve): add /acp northbound workspace methods (27 qwen/workspace/* endpoints)"

Task 11: E2e Equivalence Tests

Files:

  • Create: packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts

  • Step 1: Build /acp test harness helper

ts
// Helper for sending JSON-RPC to /acp endpoint via supertest
import request from 'supertest';

async function acpCall(
  app: any,
  method: string,
  params: Record<string, unknown> = {},
  token = 'test-token',
) {
  const res = await request(app)
    .post('/acp')
    .set('Authorization', `Bearer ${token}`)
    .set('Content-Type', 'application/json')
    .send({ jsonrpc: '2.0', id: 1, method, params });
  return res.body;
}
  • Step 2: Write equivalence tests
ts
// packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createServeApp } from '../../server.js';
// ... setup with mocked bridge + workspace

describe('REST ↔ /acp equivalence', () => {
  let app: any;

  beforeAll(() => {
    // Create app with both REST and /acp wired to same workspace service
    app = createServeApp({
      /* ... test deps */
    });
  });

  describe('file read', () => {
    it('returns same content via both transports', async () => {
      const restRes = await request(app)
        .get('/file?path=README.md')
        .set('Authorization', 'Bearer tok');
      const acpRes = await acpCall(app, 'qwen/workspace/fs/read', {
        path: 'README.md',
      });

      expect(restRes.body.content).toBe(acpRes.result.content);
    });
  });

  describe('trust gate rejection', () => {
    it('rejects invalid clientId via REST (400)', async () => {
      const res = await request(app)
        .post('/file/write')
        .set('Authorization', 'Bearer tok')
        .set('X-Qwen-Client-Id', 'unknown-client')
        .send({ path: 'x.ts', content: 'y' });
      expect(res.status).toBe(400);
      expect(res.body.code).toBe('invalid_client_id');
    });

    it('rejects invalid clientId via /acp (JSON-RPC error)', async () => {
      const res = await acpCall(app, 'qwen/workspace/fs/write', {
        path: 'x.ts',
        content: 'y',
      });
      expect(res.error.code).toBe(-32602);
      expect(res.error.message).toContain('invalid_client_id');
    });
  });
});
  • Step 3: Run e2e tests

Run: cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/e2e.test.ts Expected: PASS

  • Step 4: Commit
bash
git add packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts
git commit -m "test(serve): add REST ↔ /acp equivalence e2e tests"

Task 12: Final Verification

  • Step 1: Run full typecheck across all packages
bash
cd packages/acp-bridge && npx tsc --noEmit && cd ../cli && npx tsc --noEmit && cd ../sdk-typescript && npx tsc --noEmit

Expected: No errors

  • Step 2: Run full test suites
bash
cd packages/acp-bridge && npx vitest run && cd ../cli && npx vitest run

Expected: All pass. SDK tests should pass WITHOUT modification (REST surface unchanged).

  • Step 3: Verify SDK tests pass unmodified
bash
cd packages/sdk-typescript && npx vitest run

Expected: All pass — confirms backward compatibility.

  • Step 4: Run lint
bash
cd packages/cli && npm run lint && cd ../acp-bridge && npm run lint

Expected: No errors

  • Step 5: Final commit (if any cleanup needed)
bash
git status
# If clean, no commit needed. If lint fixes:
git add -A && git commit -m "chore: lint fixes"
  • Step 6: Verify git log is clean
bash
git log --oneline -15

Confirm commits tell a coherent story for the single-PR reviewer.