From fc801c9ea0856a5c7f64eabf98c43cbdee7688d0 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 19:26:30 +0800 Subject: [PATCH] feat(server-v2): port v1 approvals and workspaces routes - add `/sessions/{sid}/approvals` GET (list pending) and POST (resolve) routes, backed by IApprovalService and IInteractionService; duplicate resolves return 40902 via the kernel's recently-resolved window - add `/workspaces` GET / POST / PATCH / DELETE routes, backed by IWorkspaceRegistry, projecting v2 workspace records onto the v1 wire shape - register both route groups in registerApiV1Routes - add an agent-core-v2 interaction example that resolves the Session-scoped kernel and approval/question facades through the scope tree --- .../examples/interaction.example.ts | 145 +++++++++ packages/server-v2/src/routes/approvals.ts | 213 ++++++++++++++ .../src/routes/registerApiV1Routes.ts | 10 + packages/server-v2/src/routes/workspaces.ts | 277 ++++++++++++++++++ packages/server-v2/test/workspaces.test.ts | 190 ++++++++++++ 5 files changed, 835 insertions(+) create mode 100644 packages/agent-core-v2/examples/interaction.example.ts create mode 100644 packages/server-v2/src/routes/approvals.ts create mode 100644 packages/server-v2/src/routes/workspaces.ts create mode 100644 packages/server-v2/test/workspaces.test.ts diff --git a/packages/agent-core-v2/examples/interaction.example.ts b/packages/agent-core-v2/examples/interaction.example.ts new file mode 100644 index 000000000..3d258bc5f --- /dev/null +++ b/packages/agent-core-v2/examples/interaction.example.ts @@ -0,0 +1,145 @@ +/** + * Scenario: the **interaction** kernel and its `approval` / `question` facades, + * resolved through the **Session scope** they belong to. + * + * All three Services are registered at `LifecycleScope.Session`, so this + * example resolves them from a real Session scope (`createScopedTestHost` → + * `host.child(LifecycleScope.Session, …)`), the same layer production uses. The + * scoped registry is cleared and re-populated explicitly in `beforeEach` rather + * than relying on import-order side effects. + * + * `IInteractionService` is the only Service that owns state — a pending set + * plus a recently-resolved ledger — and it is domain-agnostic. + * `IApprovalService` and `IQuestionService` are zero-state typed facades over + * it: they tag each request with `kind: 'approval'` / `kind: 'question'`, + * rename the resolve verb (`decide` / `answer` → `respond`), and cast the + * stored payload back to the typed request on `listPending`. + * + * Two calling styles are demonstrated: + * + * - **Blocking** (`request`): the caller `await`s a Promise that parks until a + * response arrives. Used by in-turn code (a tool gating on a user decision). + * - **Non-blocking** (`enqueue` + `onDidResolve`): the caller parks the request + * and returns its `id` immediately; the outcome is delivered through the + * `onDidResolve` stream. Used by edge callers that stream the result rather + * than awaiting a Promise (e.g. over WebSocket). + * + * The final scenario proves Session-scope isolation: two sessions hold + * independent brokers, so a request parked in session A is invisible to, and + * not resolvable from, session B. + */ + +import type { ToolInputDisplay } from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, test } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { + _clearScopedRegistryForTests, + LifecycleScope, + registerScopedService, + type Scope, +} from '#/_base/di/scope'; +import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test'; +import { type ApprovalRequest, ApprovalService, IApprovalService } from '#/approval'; +import { IInteractionService, InteractionService } from '#/interaction'; +import { IQuestionService, QuestionService } from '#/question'; + +const display: ToolInputDisplay = { kind: 'command', command: 'rm -rf /tmp/demo' }; + +function approval(id: string): ApprovalRequest { + return { id, toolName: 'bash', action: 'run', display }; +} + +describe('interaction kernel + approval/question facades (Session scope)', () => { + let disposables: DisposableStore; + let host: ScopedTestHost; + let session: Scope; + + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService(LifecycleScope.Session, IInteractionService, InteractionService, InstantiationType.Delayed, 'interaction'); + registerScopedService(LifecycleScope.Session, IApprovalService, ApprovalService, InstantiationType.Delayed, 'approval'); + registerScopedService(LifecycleScope.Session, IQuestionService, QuestionService, InstantiationType.Delayed, 'question'); + + disposables = new DisposableStore(); + host = createScopedTestHost(); + session = host.child(LifecycleScope.Session, 'session-a'); + }); + afterEach(() => { + disposables.dispose(); + host.dispose(); + }); + + test('blocking: approval.request parks until decide resolves the Promise', async () => { + const approvals = session.accessor.get(IApprovalService); + + // The caller (e.g. a tool) awaits the decision. Nothing resolves yet. + const decision = approvals.request(approval('bash-1')); + console.log('1) after request, pending approvals:', approvals.listPending().map((r) => r.id)); + + // The edge (HTTP/WS `approvals:decide`) supplies the user's decision. + approvals.decide('bash-1', { decision: 'approved' }); + console.log('2) resolved decision:', await decision); + console.log('3) after decide, pending approvals:', approvals.listPending()); + }); + + test('non-blocking: question.enqueue returns immediately; the answer streams over onDidResolve', () => { + const interaction = session.accessor.get(IInteractionService); + const questions = session.accessor.get(IQuestionService); + + // Edge callers observe outcomes through the stream instead of awaiting. + const resolved: { id: string; response: unknown }[] = []; + disposables.add(interaction.onDidResolve((r) => resolved.push(r))); + + // enqueue parks the request and returns its id without blocking. + const parked = questions.enqueue({ id: 'q-name', prompt: 'What is your name?' }); + console.log('1) enqueued question (id known up front):', parked); + console.log('2) pending questions:', questions.listPending()); + + // The answer arrives later (HTTP/WS `questions:answer`) and fans out. + questions.answer('q-name', 'kimi'); + console.log('3) onDidResolve stream delivered:', resolved); + console.log('4) after answer, pending questions:', questions.listPending()); + }); + + test('one kernel backs both facades; onDidChange announces every mutation', () => { + const interaction = session.accessor.get(IInteractionService); + const approvals = session.accessor.get(IApprovalService); + const questions = session.accessor.get(IQuestionService); + + let changes = 0; + disposables.add(interaction.onDidChange(() => changes++)); + + void approvals.request(approval('bash-1')); // change #1 (park approval) + questions.enqueue({ id: 'q-name', prompt: 'name?' }); // change #2 (park question) + + // The kernel sees every pending interaction, regardless of which facade parked it. + console.log('1) kernel listPending (all kinds):', interaction.listPending().map((i) => i.kind)); + console.log('2) kernel listPending("approval"):', interaction.listPending('approval').map((i) => i.id)); + console.log('3) kernel listPending("question"):', interaction.listPending('question').map((i) => i.id)); + + approvals.decide('bash-1', { decision: 'rejected' }); // change #3 (resolve approval) + questions.answer('q-name', 'kimi'); // change #4 (resolve question) + console.log('4) onDidChange fired', changes, 'times (park x2 + resolve x2)'); + }); + + test('Session scope isolates brokers: a request parked in A is invisible to B', async () => { + const sessionB = host.child(LifecycleScope.Session, 'session-b'); + + const approvalsA = session.accessor.get(IApprovalService); + const approvalsB = sessionB.accessor.get(IApprovalService); + console.log('1) distinct broker instances per session:', approvalsA !== approvalsB); + + const decisionA = approvalsA.request(approval('bash-1')); + console.log('2) A pending after park:', approvalsA.listPending().map((r) => r.id)); + console.log('3) B pending (isolated):', approvalsB.listPending().map((r) => r.id)); + + // Deciding from B is a no-op — the id is parked in A's kernel, not B's. + approvalsB.decide('bash-1', { decision: 'approved' }); + console.log('4) A still pending after B.decide (no-op):', approvalsA.listPending().map((r) => r.id)); + + approvalsA.decide('bash-1', { decision: 'approved' }); + console.log('5) A resolved by its own broker:', await decisionA); + }); +}); diff --git a/packages/server-v2/src/routes/approvals.ts b/packages/server-v2/src/routes/approvals.ts new file mode 100644 index 000000000..261f3b9ac --- /dev/null +++ b/packages/server-v2/src/routes/approvals.ts @@ -0,0 +1,213 @@ +/** + * `/sessions/{sid}/approvals*` route handlers — server-v2 port. + * + * Implements the v1 `/api/v1/sessions/{sid}/approvals` wire contract on top of + * `agent-core-v2` services. Backed by the Session-scoped `IApprovalService` + * (for `decide`) and `IInteractionService` (for the pending list, including the + * `createdAt` metadata the facade does not surface). + * + * GET /sessions/{sid}/approvals?status=pending data: { items: ApprovalRequest[] } + * POST /sessions/{sid}/approvals/{aid} body: ApprovalResponse + * data: { resolved: true, resolved_at } + * + * Error mapping (REST.md §3.6): + * - 40401 (session.not_found) — no live session matches {sid} + * - 40404 (approval.not_found) — no pending approval matches {aid} + * - 40902 (approval.already_resolved)— duplicate resolve; custom envelope + * `{code:40902, data:{resolved:false}}` + * - 40001 (validation.failed) — bad body via the Zod preHandler + * + * **Idempotency**: the interaction kernel remembers recently-resolved ids (60s + * window). A re-POST of a just-resolved id hits `isRecentlyResolved` → 40902; + * an id that never existed (or fell out of the window) → 40404. + * + * **Wire fidelity gaps**: + * - `expires_at` — v2 interactions never expire; we emit a stable derived + * value (`created_at + 24h`) because the wire schema requires it. + * - `tool_call_id` / `session_id` — v2 marks them optional on the payload; + * we fall back to the interaction id / path session id when absent. + */ + +import { + IApprovalService, + IInteractionService, + ISessionLifecycleService, + type ApprovalRequest, + type ApprovalResponse, + type Interaction, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { + approvalAlreadyResolvedDataSchema, + approvalResolveRequestSchema, + approvalResolveResultSchema, + ErrorCode, + listPendingApprovalsQuerySchema, + listPendingApprovalsResponseSchema, +} from '@moonshot-ai/protocol'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; + +interface ApprovalRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const approvalParamsSchema = z.object({ + session_id: z.string().min(1), + approval_id: z.string().min(1), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +/** Stable, derived expiry horizon: v2 approvals do not expire. */ +const APPROVAL_EXPIRY_MS = 24 * 60 * 60 * 1000; + +export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/approvals', + params: sessionIdParamSchema, + querystring: listPendingApprovalsQuerySchema, + success: { data: listPendingApprovalsResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: 'List pending approval requests for a session', + tags: ['approvals'], + }, + async (req, reply) => { + const { session_id } = req.params; + const handle = core.accessor.get(ISessionLifecycleService).get(session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const pending = handle.accessor.get(IInteractionService).listPending('approval'); + const items = pending.map((i) => toWireApproval(i, session_id)); + reply.send(okEnvelope({ items }, req.id)); + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const resolveRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/approvals/{approval_id}', + params: approvalParamsSchema, + body: approvalResolveRequestSchema, + success: { data: approvalResolveResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.APPROVAL_NOT_FOUND]: {}, + [ErrorCode.APPROVAL_ALREADY_RESOLVED]: { + dataSchema: approvalAlreadyResolvedDataSchema, + }, + }, + description: 'Resolve an approval request', + tags: ['approvals'], + }, + async (req, reply) => { + const { session_id, approval_id } = req.params; + const handle = core.accessor.get(ISessionLifecycleService).get(session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + const interaction = handle.accessor.get(IInteractionService); + const isPending = interaction + .listPending('approval') + .some((i) => i.id === approval_id); + + if (!isPending) { + if (interaction.isRecentlyResolved(approval_id)) { + reply.send({ + code: ErrorCode.APPROVAL_ALREADY_RESOLVED, + msg: `approval ${approval_id} already resolved`, + data: { resolved: false as const }, + request_id: req.id, + }); + return; + } + reply.send( + errEnvelope(ErrorCode.APPROVAL_NOT_FOUND, `approval ${approval_id} not found`, req.id), + ); + return; + } + + const body = req.body; + const response: ApprovalResponse = { + decision: body.decision, + scope: body.scope, + feedback: body.feedback, + selectedLabel: body.selected_label, + }; + handle.accessor.get(IApprovalService).decide(approval_id, response); + reply.send( + okEnvelope({ resolved: true as const, resolved_at: new Date().toISOString() }, req.id), + ); + }, + ); + app.post( + resolveRoute.path, + resolveRoute.options, + resolveRoute.handler as Parameters[2], + ); +} + +// --------------------------------------------------------------------------- +// Projection — v2 interaction (approval kind) onto the v1 wire +// `approvalRequestSchema`. +// --------------------------------------------------------------------------- + +function toWireApproval(interaction: Interaction, sessionId: string): { + approval_id: string; + session_id: string; + turn_id?: number; + tool_call_id: string; + tool_name: string; + action: string; + tool_input_display: unknown; + created_at: string; + expires_at: string; +} { + const p = interaction.payload as ApprovalRequest; + return { + approval_id: interaction.id, + session_id: p.sessionId ?? sessionId, + turn_id: p.turnId, + tool_call_id: p.toolCallId ?? interaction.id, + tool_name: p.toolName, + action: p.action, + tool_input_display: p.display, + created_at: new Date(interaction.createdAt).toISOString(), + expires_at: new Date(interaction.createdAt + APPROVAL_EXPIRY_MS).toISOString(), + }; +} diff --git a/packages/server-v2/src/routes/registerApiV1Routes.ts b/packages/server-v2/src/routes/registerApiV1Routes.ts index b4ff7fdc3..e72734c6b 100644 --- a/packages/server-v2/src/routes/registerApiV1Routes.ts +++ b/packages/server-v2/src/routes/registerApiV1Routes.ts @@ -11,12 +11,14 @@ import type { Scope } from '@moonshot-ai/agent-core-v2'; import { ulid } from 'ulid'; import { okEnvelope } from '../envelope'; +import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; import { registerConfigRoutes } from './config'; import { registerMetaRoute } from './meta'; import { registerOAuthRoutes } from './oauth'; import { registerSessionsRoutes } from './sessions'; import { registerShutdownRoutes } from './shutdown'; +import { registerWorkspacesRoutes } from './workspaces'; interface ApiV1AppHost { register( @@ -61,6 +63,14 @@ export async function registerApiV1Routes( apiV1 as unknown as Parameters[0], core, ); + registerApprovalsRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerWorkspacesRoutes( + apiV1 as unknown as Parameters[0], + core, + ); registerShutdownRoutes(apiV1 as unknown as Parameters[0], { onShutdown: opts.onShutdown, }); diff --git a/packages/server-v2/src/routes/workspaces.ts b/packages/server-v2/src/routes/workspaces.ts new file mode 100644 index 000000000..af6b672f7 --- /dev/null +++ b/packages/server-v2/src/routes/workspaces.ts @@ -0,0 +1,277 @@ +/** + * `/workspaces` route handlers — server-v2 port. + * + * Implements the v1 `/api/v1/workspaces` wire contract on top of + * `agent-core-v2` services. Backed by `IWorkspaceRegistry` (Core scope) for the + * catalog, `IHostFileSystem` to validate roots and detect git, and + * `ISessionIndex` to derive `session_count`. + * + * GET /workspaces list + * POST /workspaces register (idempotent on root) + * PATCH /workspaces/{workspace_id} rename (display name only) + * DELETE /workspaces/{workspace_id} unregister + * + * **Wire fidelity**: the v1 `workspaceSchema` carries more fields than v2's + * `Workspace` (`{ id, root, name, createdAt, lastOpenedAt }`). The handler + * projects the v2 record onto the v1 shape, deriving the extra fields: + * - `is_git_repo` / `branch` — best-effort `.git` detection (branch is not + * resolved and stays `null`). + * - `created_at` / `last_opened_at` — from the registry's in-memory + * timestamps (reset on restart; the registry is still a skeleton). + * - `session_count` — count of persisted sessions for the workspace. + */ + +import { + IHostFileSystem, + ISessionIndex, + IWorkspaceRegistry, + type Scope, + type Workspace, +} from '@moonshot-ai/agent-core-v2'; +import { + ErrorCode, + createWorkspaceRequestSchema, + createWorkspaceResponseSchema, + deleteWorkspaceResponseSchema, + listWorkspacesResponseSchema, + updateWorkspaceRequestSchema, + updateWorkspaceResponseSchema, + workspaceIdParamSchema, +} from '@moonshot-ai/protocol'; +import type { Workspace as WorkspaceWire } from '@moonshot-ai/protocol'; +import { isAbsolute, join } from 'node:path'; + +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; + +interface WorkspaceRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + patch( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): void { + const listRoute = defineRoute( + { + method: 'GET', + path: '/workspaces', + success: { data: listWorkspacesResponseSchema }, + description: 'List registered workspaces', + tags: ['workspaces'], + }, + async (req, reply) => { + const items = await core.accessor.get(IWorkspaceRegistry).list(); + const projected = await Promise.all(items.map((ws) => toWireWorkspace(core, ws))); + reply.send(okEnvelope({ items: projected }, req.id)); + }, + ); + app.get(listRoute.path, listRoute.options, listRoute.handler as Parameters[2]); + + const createRoute = defineRoute( + { + method: 'POST', + path: '/workspaces', + body: createWorkspaceRequestSchema, + success: { data: createWorkspaceResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + }, + description: 'Register a workspace (idempotent on root)', + tags: ['workspaces'], + }, + async (req, reply) => { + const root = req.body.root; + if (!isAbsolute(root)) { + reply.send( + buildValidationEnvelope( + [{ path: 'root', message: 'root must be an absolute path' }], + req.id, + ), + ); + return; + } + const hostFs = core.accessor.get(IHostFileSystem); + try { + const stat = await hostFs.stat(root); + if (!stat.isDirectory) { + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `root ${root} is not a directory`, req.id), + ); + return; + } + } catch { + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `root ${root} does not exist`, req.id)); + return; + } + const ws = await core.accessor.get(IWorkspaceRegistry).createOrTouch(root, req.body.name); + reply.send(okEnvelope(await toWireWorkspace(core, ws), req.id)); + }, + ); + app.post( + createRoute.path, + createRoute.options, + createRoute.handler as Parameters[2], + ); + + const updateRoute = defineRoute( + { + method: 'PATCH', + path: '/workspaces/{workspace_id}', + params: workspaceIdParamSchema, + body: updateWorkspaceRequestSchema, + success: { data: updateWorkspaceResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Rename a workspace (display name only)', + tags: ['workspaces'], + }, + async (req, reply) => { + const { workspace_id } = req.params; + const ws = await core.accessor + .get(IWorkspaceRegistry) + .update(workspace_id, { name: req.body.name }); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id), + ); + return; + } + reply.send(okEnvelope(await toWireWorkspace(core, ws), req.id)); + }, + ); + app.patch( + updateRoute.path, + updateRoute.options, + updateRoute.handler as Parameters[2], + ); + + const deleteRoute = defineRoute( + { + method: 'DELETE', + path: '/workspaces/{workspace_id}', + params: workspaceIdParamSchema, + success: { data: deleteWorkspaceResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Unregister a workspace (does not remove on-disk content)', + tags: ['workspaces'], + }, + async (req, reply) => { + const { workspace_id } = req.params; + const registry = core.accessor.get(IWorkspaceRegistry); + const existing = await registry.get(workspace_id); + if (existing === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id), + ); + return; + } + await registry.delete(workspace_id); + reply.send(okEnvelope({ deleted: true as const }, req.id)); + }, + ); + app.delete( + deleteRoute.path, + deleteRoute.options, + deleteRoute.handler as Parameters[2], + ); +} + +// --------------------------------------------------------------------------- +// Projection — v2 `Workspace` onto the v1 wire `workspaceSchema`. +// --------------------------------------------------------------------------- + +async function toWireWorkspace(core: Scope, ws: Workspace): Promise { + const [git, sessionCount] = await Promise.all([ + detectGit(core, ws.root), + countSessions(core, ws.id), + ]); + return { + id: ws.id, + root: ws.root, + name: ws.name, + is_git_repo: git.isGitRepo, + branch: git.branch, + created_at: new Date(ws.createdAt).toISOString(), + last_opened_at: new Date(ws.lastOpenedAt).toISOString(), + session_count: sessionCount, + }; +} + +async function detectGit( + core: Scope, + root: string, +): Promise<{ isGitRepo: boolean; branch: string | null }> { + try { + await core.accessor.get(IHostFileSystem).stat(join(root, '.git')); + return { isGitRepo: true, branch: null }; + } catch { + return { isGitRepo: false, branch: null }; + } +} + +async function countSessions(core: Scope, workspaceId: string): Promise { + const page = await core.accessor + .get(ISessionIndex) + .list({ workspaceId, includeArchived: true }); + return page.items.length; +} + +function buildValidationEnvelope( + details: { path: string; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const first = details[0]; + const msg = first === undefined ? 'validation failed' : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} diff --git a/packages/server-v2/test/workspaces.test.ts b/packages/server-v2/test/workspaces.test.ts new file mode 100644 index 000000000..87465bda8 --- /dev/null +++ b/packages/server-v2/test/workspaces.test.ts @@ -0,0 +1,190 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface WorkspaceWire { + id: string; + root: string; + name: string; + is_git_repo: boolean; + branch: string | null; + created_at: string; + last_opened_at: string; + session_count: number; +} + +interface ListWire { + items: WorkspaceWire[]; +} + +describe('server-v2 /api/v1/workspaces', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-workspaces-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const hasBody = body !== undefined; + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: hasBody ? { 'content-type': 'application/json' } : undefined, + body: hasBody ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function patchJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function deleteJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { method: 'DELETE' }); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('creates a workspace with the full wire shape', async () => { + const root = home as string; + const { status, body } = await postJson('/api/v1/workspaces', { + root, + name: 'proj', + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.root).toBe(root); + expect(body.data.name).toBe('proj'); + expect(body.data.id).toMatch(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/); + expect(typeof body.data.is_git_repo).toBe('boolean'); + expect(body.data.branch).toBeNull(); + expect(typeof body.data.session_count).toBe('number'); + expect(Number.isNaN(Date.parse(body.data.created_at))).toBe(false); + expect(Number.isNaN(Date.parse(body.data.last_opened_at))).toBe(false); + }); + + it('derives the default name from the root when name is omitted', async () => { + const root = home as string; + const { body } = await postJson('/api/v1/workspaces', { root }); + expect(body.code).toBe(0); + expect(body.data.name.length).toBeGreaterThan(0); + }); + + it('is idempotent on root (createOrTouch)', async () => { + const root = home as string; + const first = await postJson('/api/v1/workspaces', { root }); + const second = await postJson('/api/v1/workspaces', { root }); + expect(first.body.data.id).toBe(second.body.data.id); + }); + + it('rejects a relative root (40001)', async () => { + const { body } = await postJson('/api/v1/workspaces', { root: 'relative/path' }); + expect(body.code).toBe(40001); + expect(body.details?.[0]?.path).toBe('root'); + }); + + it('rejects a nonexistent root (40409)', async () => { + const missing = join(home as string, 'does-not-exist'); + const { body } = await postJson('/api/v1/workspaces', { root: missing }); + expect(body.code).toBe(40409); + }); + + it('lists registered workspaces', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const { body } = await getJson('/api/v1/workspaces'); + expect(body.code).toBe(0); + expect(body.data.items.some((w) => w.id === created.body.data.id)).toBe(true); + }); + + it('renames a workspace via PATCH', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const updated = await patchJson(`/api/v1/workspaces/${id}`, { name: 'renamed' }); + expect(updated.body.code).toBe(0); + expect(updated.body.data.name).toBe('renamed'); + expect(updated.body.data.id).toBe(id); + }); + + it('returns 40410 when patching an unknown workspace', async () => { + const { body } = await patchJson('/api/v1/workspaces/wd_missing_000000000000', { + name: 'nope', + }); + expect(body.code).toBe(40410); + }); + + it('deletes a workspace and 40410 on a second delete', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const deleted = await deleteJson<{ deleted: boolean }>(`/api/v1/workspaces/${id}`); + expect(deleted.body.code).toBe(0); + expect(deleted.body.data).toEqual({ deleted: true }); + + const again = await deleteJson(`/api/v1/workspaces/${id}`); + expect(again.body.code).toBe(40410); + }); + + it('reflects session_count for sessions created in the workspace', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + expect(created.body.data.session_count).toBe(0); + + // Create a session bound to this workspace via cwd. + const session = await postJson<{ id: string }>('/api/v1/sessions', { metadata: { cwd: root } }); + expect(session.body.code).toBe(0); + + const { body } = await getJson('/api/v1/workspaces'); + const ws = body.data.items.find((w) => w.id === created.body.data.id); + expect(ws?.session_count).toBe(1); + }); +});