Files
mcpctl/src/mcpd/tests/inference-task-routes.test.ts

269 lines
10 KiB
TypeScript
Raw Normal View History

feat(mcpd): async inference task API + tasks RBAC resource (v5 Stage 3) Exposes the durable queue (Stage 1+2) as a first-class API so callers can enqueue work, get a task id immediately, and poll/stream/cancel without holding open the original HTTP connection. New endpoints (`/api/v1/inference-tasks`): POST / → enqueue, return task id (201 + row). failFast:false — task stays pending if no worker is up; future bindSession drains. Rejects 400 for public Llms (the existing /llms/<name>/infer is the right tool there) and 404 for missing Llms. GET / → list owner's tasks. Optional ?status, ?poolName, ?agentId, ?limit query. Owner-scoped at the route layer; cross- user listing requires resource-wide grant. GET /:id → poll one task. 404 (not 403) on a foreign-owner id to prevent enumeration. DELETE /:id → cancel a non-terminal task. Already- terminal rows return 200 + current shape (no-op). 404 on foreign owner. GET /:id/stream → SSE feed of `chunk` and `terminal` events. Re-fetches the row at subscribe time so already-completed tasks emit one terminal event and close immediately. RBAC: - New `tasks` resource added to RBAC_RESOURCES + the URL→permission map in main.ts. Default action mapping: GET=view, POST=create, DELETE=delete. The route layer enforces owner-scoping ON TOP of the hook (404 on foreign owner) — without this, anyone with `view:tasks` could list/peek every user's queued work. - Singular alias `task` and the multi-word `inference-task` / `inference-tasks` all normalize to `tasks` so users can write `mcpctl create rbac-binding --resource task --role view ...` or any of the variants and have it map correctly. Tests: 9 new route tests covering the wire shapes, owner scoping (matching/foreign), public-Llm rejection, missing-Llm 404, list filter, and cancel semantics (pending→cancelled, terminal→no-op). mcpd 893/893 (was 884, +9). Live smoke: POST against a public Llm returns the documented 400, POST against missing returns 404, GET list returns [] cleanly. Stage 4 (next): CLI surface (`mcpctl get tasks`, `--async` flag on chat-llm), GC ticker, smoke test (enqueue → connect worker → drain), docs.
2026-04-28 15:06:31 +01:00
/**
* Route-level tests for the v5 async inference task API.
* Service + state-machine details are tested in
* inference-task-service.test.ts; this file just covers the wire shapes
* + owner scoping.
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerInferenceTaskRoutes } from '../src/routes/inference-tasks.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IInferenceTaskService } from '../src/services/inference-task.service.js';
import type { LlmService, LlmView } from '../src/services/llm.service.js';
import type { IVirtualLlmService } from '../src/services/virtual-llm.service.js';
import type { InferenceTask } from '@prisma/client';
let app: FastifyInstance;
function makeRow(overrides: Partial<InferenceTask> = {}): InferenceTask {
return {
id: overrides.id ?? 'task-1',
status: 'pending',
poolName: 'qwen-pool',
llmName: 'vllm-local',
model: 'qwen3',
tier: null,
claimedBy: null,
requestBody: { messages: [] } as InferenceTask['requestBody'],
responseBody: null,
errorMessage: null,
streaming: false,
createdAt: new Date('2026-04-28T00:00:00Z'),
claimedAt: null,
streamStartedAt: null,
completedAt: null,
ownerId: 'owner-1',
agentId: null,
...overrides,
};
}
function makeLlmView(overrides: Partial<LlmView> = {}): LlmView {
return {
id: 'llm-1',
name: 'vllm-local',
type: 'openai',
model: 'qwen3',
url: '',
tier: 'fast',
description: '',
apiKeyRef: null,
extraConfig: {},
poolName: 'qwen-pool',
kind: 'virtual',
status: 'active',
lastHeartbeatAt: null,
inactiveSince: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockTasks(rows: InferenceTask[] = []): IInferenceTaskService {
const byId = new Map(rows.map((r) => [r.id, r]));
return {
enqueue: vi.fn(),
waitFor: vi.fn(),
tryClaim: vi.fn(),
markRunning: vi.fn(),
pushChunk: vi.fn(() => true),
subscribeChunksUnsafe: vi.fn(() => () => undefined),
complete: vi.fn(),
fail: vi.fn(),
cancel: vi.fn(async (id) => {
const r = byId.get(id);
if (r === undefined) return null;
const next = { ...r, status: 'cancelled' as const, completedAt: new Date() };
byId.set(id, next);
return next;
}),
revertHeldBy: vi.fn(async () => []),
findPendingForPools: vi.fn(async () => []),
findById: vi.fn(async (id) => byId.get(id) ?? null),
list: vi.fn(async (filter) => {
let out = [...byId.values()];
if (filter.ownerId !== undefined) out = out.filter((r) => r.ownerId === filter.ownerId);
if (filter.poolName !== undefined) out = out.filter((r) => r.poolName === filter.poolName);
if (filter.status !== undefined) {
const s = Array.isArray(filter.status) ? filter.status : [filter.status];
out = out.filter((r) => s.includes(r.status));
}
return out;
}),
gcSweep: vi.fn(async () => ({ erroredOut: 0, deleted: 0 })),
};
}
function mockLlms(view: LlmView | null): LlmService {
return {
getByName: vi.fn(async (name: string) => {
if (view !== null && view.name === name) return view;
const err = new Error(`Llm not found: ${name}`);
(err as { name: string }).name = 'NotFoundError';
throw err;
}),
} as unknown as LlmService;
}
function mockVirtualLlms(): IVirtualLlmService & { _calls: unknown[] } {
const calls: unknown[] = [];
return {
_calls: calls,
register: vi.fn(),
heartbeat: vi.fn(),
bindSession: vi.fn(),
unbindSession: vi.fn(),
enqueueInferTask: vi.fn(async (llmName, request, streaming, options) => {
calls.push({ llmName, request, streaming, options });
return {
taskId: 'task-1',
done: Promise.resolve({ status: 200, body: null }),
onChunk: () => () => undefined,
};
}),
completeTask: vi.fn(),
pushTaskChunk: vi.fn(),
failTask: vi.fn(),
gcSweep: vi.fn(),
} as unknown as IVirtualLlmService & { _calls: unknown[] };
}
afterEach(async () => {
if (app) await app.close();
});
async function buildApp(deps: {
tasks: IInferenceTaskService;
llms: LlmService;
virtualLlms: IVirtualLlmService;
userId?: string;
}): Promise<FastifyInstance> {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
// Stub the auth hook the routes rely on for ownerId.
app.addHook('onRequest', async (request) => {
request.userId = deps.userId ?? 'owner-1';
});
registerInferenceTaskRoutes(app, deps);
await app.ready();
return app;
}
beforeEach(() => {
// Augment FastifyRequest with userId for the type system. We don't
// need to do anything at runtime — the addHook above sets it.
});
describe('Inference Task Routes (v5 Stage 3)', () => {
it('POST /api/v1/inference-tasks enqueues the task with failFast:false', async () => {
const tasks = mockTasks([makeRow({ id: 'task-1' })]);
const llms = mockLlms(makeLlmView());
const virtualLlms = mockVirtualLlms();
await buildApp({ tasks, llms, virtualLlms });
const res = await app.inject({
method: 'POST',
url: '/api/v1/inference-tasks',
payload: { llmName: 'vllm-local', request: { model: 'qwen3', messages: [{ role: 'user', content: 'hi' }] } },
});
expect(res.statusCode).toBe(201);
const body = res.json<{ id: string; status: string; poolName: string; streaming: boolean }>();
expect(body.id).toBe('task-1');
expect(body.poolName).toBe('qwen-pool');
expect(body.streaming).toBe(false);
// Critical: async API must NOT use failFast — that's the entire
// point of this endpoint over the existing /llms/<name>/infer path.
expect(virtualLlms.enqueueInferTask).toHaveBeenCalledWith(
'vllm-local',
expect.objectContaining({ messages: expect.any(Array) }),
false,
{ failFast: false },
);
});
it('POST rejects when llmName is missing', async () => {
await buildApp({ tasks: mockTasks(), llms: mockLlms(null), virtualLlms: mockVirtualLlms() });
const res = await app.inject({
method: 'POST',
url: '/api/v1/inference-tasks',
payload: { request: {} },
});
expect(res.statusCode).toBe(400);
expect(res.json<{ error: string }>().error).toMatch(/llmName/);
});
it('POST rejects with 400 when targeting a public Llm (sync /infer path is the right tool)', async () => {
await buildApp({
tasks: mockTasks(),
llms: mockLlms(makeLlmView({ kind: 'public' })),
virtualLlms: mockVirtualLlms(),
});
const res = await app.inject({
method: 'POST',
url: '/api/v1/inference-tasks',
payload: { llmName: 'vllm-local', request: { messages: [] } },
});
expect(res.statusCode).toBe(400);
expect(res.json<{ error: string }>().error).toMatch(/not a virtual provider/);
});
it('GET /api/v1/inference-tasks/:id returns the row when owner matches', async () => {
const tasks = mockTasks([makeRow({ id: 'task-1', ownerId: 'owner-1' })]);
await buildApp({ tasks, llms: mockLlms(null), virtualLlms: mockVirtualLlms(), userId: 'owner-1' });
const res = await app.inject({ method: 'GET', url: '/api/v1/inference-tasks/task-1' });
expect(res.statusCode).toBe(200);
expect(res.json<{ id: string }>().id).toBe('task-1');
});
it('GET /api/v1/inference-tasks/:id returns 404 (not 403) on a foreign owner', async () => {
// Owner-scoped 404 prevents id-enumeration via differential status.
const tasks = mockTasks([makeRow({ id: 'task-1', ownerId: 'someone-else' })]);
await buildApp({ tasks, llms: mockLlms(null), virtualLlms: mockVirtualLlms(), userId: 'owner-1' });
const res = await app.inject({ method: 'GET', url: '/api/v1/inference-tasks/task-1' });
expect(res.statusCode).toBe(404);
});
it('GET /api/v1/inference-tasks lists only the caller\'s own tasks by default', async () => {
const tasks = mockTasks([
makeRow({ id: 'task-1', ownerId: 'owner-1' }),
makeRow({ id: 'task-2', ownerId: 'owner-1' }),
makeRow({ id: 'task-3', ownerId: 'someone-else' }),
]);
await buildApp({ tasks, llms: mockLlms(null), virtualLlms: mockVirtualLlms(), userId: 'owner-1' });
const res = await app.inject({ method: 'GET', url: '/api/v1/inference-tasks' });
expect(res.statusCode).toBe(200);
const rows = res.json<Array<{ id: string }>>();
expect(rows.map((r) => r.id).sort()).toEqual(['task-1', 'task-2']);
});
it('DELETE /api/v1/inference-tasks/:id cancels a pending row', async () => {
const tasks = mockTasks([makeRow({ id: 'task-1', ownerId: 'owner-1', status: 'pending' })]);
await buildApp({ tasks, llms: mockLlms(null), virtualLlms: mockVirtualLlms(), userId: 'owner-1' });
const res = await app.inject({ method: 'DELETE', url: '/api/v1/inference-tasks/task-1' });
expect(res.statusCode).toBe(200);
expect(res.json<{ status: string }>().status).toBe('cancelled');
expect(tasks.cancel).toHaveBeenCalledWith('task-1');
});
it('DELETE on a foreign-owner task returns 404 without revealing existence', async () => {
const tasks = mockTasks([makeRow({ id: 'task-1', ownerId: 'someone-else' })]);
await buildApp({ tasks, llms: mockLlms(null), virtualLlms: mockVirtualLlms(), userId: 'owner-1' });
const res = await app.inject({ method: 'DELETE', url: '/api/v1/inference-tasks/task-1' });
expect(res.statusCode).toBe(404);
expect(tasks.cancel).not.toHaveBeenCalled();
});
it('DELETE on an already-terminal task is a no-op (returns 200 with current row)', async () => {
const tasks = mockTasks([makeRow({ id: 'task-1', ownerId: 'owner-1', status: 'completed' })]);
await buildApp({ tasks, llms: mockLlms(null), virtualLlms: mockVirtualLlms(), userId: 'owner-1' });
const res = await app.inject({ method: 'DELETE', url: '/api/v1/inference-tasks/task-1' });
expect(res.statusCode).toBe(200);
expect(res.json<{ status: string }>().status).toBe('completed');
// cancel was NOT called — terminal rows aren't transitioned.
expect(tasks.cancel).not.toHaveBeenCalled();
});
});