Files
nextcraft/apps/web/lib/engine-client.ts
T
CIAgent c760f9af2b feat(P06): engine client + sandbox session hook + files/exec routes (Wave 2, task 6-2-01)
Backend: /v1/sandboxes/{id}/files (list/read/write; traversal rejected 422) and
/v1/sandboxes/{id}/exec (bounded command, captured output — CUT-2: no shell relay);
async workspace resolution for tracked + shell layouts; 17 API tests green.
Web: lib/engine-client.ts — typed fetch client for all engines (sandboxes/files/exec/
variants/grade/defense/traces/lab-SSE/proctor) with honest error mapping (503 busy ->
EngineBusyError, 403 not-allowlisted, 429 rate-limited); hooks/use-sandbox-session.ts —
variant->sandbox->starter-files bootstrap, run/test/saveFile/openFile actions, idempotent
destroy on unmount (AbortController), busy/denied/error states surfaced. typecheck 7/7.

---ci---
phase: 6
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-008], partial: []}
---/ci---
2026-09-12 17:21:30 +00:00

316 lines
8.7 KiB
TypeScript

/**
* Nextcraft — engine client (v0.3, REQ-3-008).
*
* Typed fetch client for the real credential engines in ai-service:
* sandbox lifecycle + workspace files + Run/Test exec (CUT-2: bounded
* commands with captured output — NO interactive shell relay), variants,
* grading, oral defense, telemetry traces, lab feedback, proctor signals.
*
* Error discipline (G-5/D-032 surfaced honestly):
* 503 -> EngineBusyError (pool full — "environment busy, retry")
* 403 -> NotAllowlistedError (learner id not in the server allowlist)
* 429 -> RateLimitedError (per-learner/global caps)
*/
import type {
TaskVariant,
GradeRecord,
DefenseStart,
DefenseAnswer,
DefenseFinish,
DefenseSession,
TelemetryEvent,
} from '@nextcraft/types';
export const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
/** v0.3 mock session constant (G-5: allowlisted server-side as pilot-learner). */
export const MOCK_LEARNER_ID = 'pilot-learner';
export class EngineError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'EngineError';
}
}
export class EngineBusyError extends EngineError {
constructor() {
super('Sandbox pool is full — retry shortly.', 503);
this.name = 'EngineBusyError';
}
}
export class NotAllowlistedError extends EngineError {
constructor() {
super('This learner is not allowlisted on this pilot.', 403);
this.name = 'NotAllowlistedError';
}
}
export class RateLimitedError extends EngineError {
constructor(message = 'Too many requests — slow down.') {
super(message, 429);
this.name = 'RateLimitedError';
}
}
async function parseError(resp: Response): Promise<EngineError> {
if (resp.status === 503) return new EngineBusyError();
if (resp.status === 403) return new NotAllowlistedError();
if (resp.status === 429) return new RateLimitedError();
let detail = `${resp.status} ${resp.statusText}`;
try {
const body = await resp.json();
if (typeof body?.detail === 'string') detail = body.detail;
} catch {
/* non-JSON error body */
}
return new EngineError(detail, resp.status);
}
async function jsonFetch<T>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${AI_SERVICE_URL}${path}`, {
signal: init?.signal,
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (!resp.ok) throw await parseError(resp);
return (await resp.json()) as T;
}
// -- sandboxes -----------------------------------------------------------------
export interface SandboxHandle {
id: string;
workdir: string;
created_at: string;
}
export async function createSandbox(
learnerId: string,
taskId?: string,
signal?: AbortSignal,
): Promise<SandboxHandle> {
return jsonFetch('/v1/sandboxes', {
method: 'POST',
body: JSON.stringify(taskId ? { learner_id: learnerId, task_id: taskId } : { learner_id: learnerId }),
signal,
});
}
export async function destroySandbox(id: string, signal?: AbortSignal): Promise<void> {
const resp = await fetch(`${AI_SERVICE_URL}/v1/sandboxes/${id}`, {
method: 'DELETE',
signal,
});
if (!resp.ok && resp.status !== 404) throw await parseError(resp);
}
export async function listSandboxes(signal?: AbortSignal): Promise<SandboxHandle[]> {
const body = await jsonFetch<{ sandboxes: SandboxHandle[] }>('/v1/sandboxes', { signal });
return body.sandboxes ?? [];
}
// -- workspace files (CUT-2: CRUD + bounded exec; no shell relay) -------------
export async function listFiles(sandboxId: string, signal?: AbortSignal): Promise<string[]> {
const body = await jsonFetch<{ files: string[] }>(
`/v1/sandboxes/${sandboxId}/files`,
{ signal },
);
return body.files ?? [];
}
export async function readFile(sandboxId: string, path: string, signal?: AbortSignal): Promise<string> {
const body = await jsonFetch<{ content: string }>(
`/v1/sandboxes/${sandboxId}/files/${encodeURIComponent(path)}`,
{ signal },
);
return body.content;
}
export async function writeFile(
sandboxId: string,
path: string,
content: string,
signal?: AbortSignal,
): Promise<void> {
await jsonFetch(`/v1/sandboxes/${sandboxId}/files/${encodeURIComponent(path)}`, {
method: 'PUT',
body: JSON.stringify({ path, content }),
signal,
});
}
export interface ExecResult {
cmd: string[];
returncode: number;
stdout: string;
stderr: string;
duration_s: number;
}
export async function runCommand(
sandboxId: string,
cmd: string[],
signal?: AbortSignal,
): Promise<ExecResult> {
return jsonFetch(`/v1/sandboxes/${sandboxId}/exec`, {
method: 'POST',
body: JSON.stringify({ cmd }),
signal,
});
}
// -- variants (REQ-3-005) ------------------------------------------------------
export async function generateVariant(
learnerId: string,
competencyId: string,
signal?: AbortSignal,
): Promise<TaskVariant> {
return jsonFetch('/v1/variants', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, competency_id: competencyId }),
signal,
});
}
// -- grading (REQ-3-004) -------------------------------------------------------
export async function requestGrade(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<GradeRecord> {
return jsonFetch('/v1/assessment/grade', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}
// -- oral defense (REQ-3-006) --------------------------------------------------
export async function startDefense(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<DefenseStart> {
return jsonFetch('/v1/defense/start', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}
export async function answerDefense(
defenseId: string,
text: string,
signal?: AbortSignal,
): Promise<DefenseAnswer> {
return jsonFetch(`/v1/defense/${defenseId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ text }).toString(),
signal,
});
}
export async function finishDefense(
defenseId: string,
signal?: AbortSignal,
): Promise<DefenseFinish> {
return jsonFetch(`/v1/defense/${defenseId}/finish`, {
method: 'POST',
signal,
});
}
export async function getDefense(
defenseId: string,
signal?: AbortSignal,
): Promise<DefenseSession> {
return jsonFetch(`/v1/defense/${defenseId}`, { signal });
}
// -- telemetry (REQ-3-003) -----------------------------------------------------
export async function getTrace(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<TelemetryEvent[]> {
const body = await jsonFetch<{ events: TelemetryEvent[] }>(
`/v1/telemetry/traces/${encodeURIComponent(learnerId)}/${encodeURIComponent(taskId)}`,
{ signal },
);
return body.events ?? [];
}
// -- lab feedback + proctor (SSE / JSON) ----------------------------------------
/** Stream Lab in-flow feedback (SSE) over the LIVE trace. */
export async function streamLabFeedback(
learnerId: string,
taskId: string,
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const { parseSseEvents } = await import('./sse');
const resp = await fetch(`${AI_SERVICE_URL}/v1/lab/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
if (!resp.ok) throw await parseError(resp);
if (!resp.body) return;
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseEvents(buffer);
buffer = rest;
for (const raw of events) {
if (raw === '[DONE]') return;
try {
const parsed = JSON.parse(raw);
if (parsed?.type === 'delta' && typeof parsed.content === 'string') {
onDelta(parsed.content);
}
if (parsed?.type === 'error') {
throw new EngineError(parsed.message ?? 'lab feedback stream error', 502);
}
} catch (err) {
if (err instanceof EngineError) throw err;
}
}
}
}
export interface ProctorAssessment {
signals: Array<{ signal_type: string; severity: string; note: string }>;
intervention: string;
summary: string;
}
export async function proctorSignals(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<ProctorAssessment> {
return jsonFetch('/v1/proctor/signals', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}