c760f9af2b
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---
316 lines
8.7 KiB
TypeScript
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,
|
|
});
|
|
} |