mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 21:56:00 +00:00
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:
parent
f08840f4ca
commit
fc801c9ea0
5 changed files with 835 additions and 0 deletions
145
packages/agent-core-v2/examples/interaction.example.ts
Normal file
145
packages/agent-core-v2/examples/interaction.example.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
213
packages/server-v2/src/routes/approvals.ts
Normal file
213
packages/server-v2/src/routes/approvals.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
277
packages/server-v2/src/routes/workspaces.ts
Normal file
277
packages/server-v2/src/routes/workspaces.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
190
packages/server-v2/test/workspaces.test.ts
Normal file
190
packages/server-v2/test/workspaces.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue