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
This commit is contained in:
haozhe.yang 2026-06-29 19:26:30 +08:00
parent f08840f4ca
commit fc801c9ea0
5 changed files with 835 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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<string, unknown> },
handler: (
req: { id: string; query: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | 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<ApprovalRouteHost['get']>[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<ApprovalRouteHost['post']>[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(),
};
}

View file

@ -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<typeof registerSessionsRoutes>[0],
core,
);
registerApprovalsRoutes(
apiV1 as unknown as Parameters<typeof registerApprovalsRoutes>[0],
core,
);
registerWorkspacesRoutes(
apiV1 as unknown as Parameters<typeof registerWorkspacesRoutes>[0],
core,
);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
onShutdown: opts.onShutdown,
});

View file

@ -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<string, unknown> } | undefined,
handler: (
req: { id: string },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
patch(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
delete(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | 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<WorkspaceRouteHost['get']>[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<WorkspaceRouteHost['post']>[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<WorkspaceRouteHost['patch']>[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<WorkspaceRouteHost['delete']>[2],
);
}
// ---------------------------------------------------------------------------
// Projection — v2 `Workspace` onto the v1 wire `workspaceSchema`.
// ---------------------------------------------------------------------------
async function toWireWorkspace(core: Scope, ws: Workspace): Promise<WorkspaceWire> {
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<number> {
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,
};
}

View file

@ -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<T> {
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<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
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<T> };
}
async function patchJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
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<T> };
}
async function deleteJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`, { method: 'DELETE' });
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
it('creates a workspace with the full wire shape', async () => {
const root = home as string;
const { status, body } = await postJson<WorkspaceWire>('/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<WorkspaceWire>('/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<WorkspaceWire>('/api/v1/workspaces', { root });
const second = await postJson<WorkspaceWire>('/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<null>('/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<null>('/api/v1/workspaces', { root: missing });
expect(body.code).toBe(40409);
});
it('lists registered workspaces', async () => {
const root = home as string;
const created = await postJson<WorkspaceWire>('/api/v1/workspaces', { root });
const { body } = await getJson<ListWire>('/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<WorkspaceWire>('/api/v1/workspaces', { root });
const id = created.body.data.id;
const updated = await patchJson<WorkspaceWire>(`/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<null>('/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<WorkspaceWire>('/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<null>(`/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<WorkspaceWire>('/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<ListWire>('/api/v1/workspaces');
const ws = body.data.items.find((w) => w.id === created.body.data.id);
expect(ws?.session_count).toBe(1);
});
});