Files
nextcraft/apps/web/lib/engine-client.ts
T
CIAgent a905eb8c67 fix(P06): defend page keys defense+grading on the REAL variant task_id (verifier P0)
The defend page fabricated taskId = `task-${competencyId}`, but variant
task_ids are `task-<seed[:16]>` (D-029) — so in the real browser flow the
defense ran against an empty trace (no digest grounding) and Grade My Work
always returned UNGRADABLE_EMPTY_TRACE. The E2E test masked this by passing
the real task_id directly.

Fix: DefenseSession resolves the learner's stored variant by competency
via GET /v1/variants?learner_id (new listVariants client), defends + grades
under the variant's real task_id, and shows an honest empty state when no
build session exists for the competency yet.

---ci---
phase: 6
milestone: v0.3
status: verify
requirements:
  covered: [REQ-3-008]
  partial: []
lessons:
  - A green E2E test can still mask a broken UI wiring when the test hand-picks
    the join key the UI is supposed to derive; verify browser flows against the
    ids the pages actually construct, not the engine contract alone.
---/ci---
2026-09-12 18:27:43 +00:00

328 lines
9.1 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,
});
}
/** The learner's stored variants, chronological (D-029 — one per template). */
export async function listVariants(
learnerId: string,
signal?: AbortSignal,
): Promise<TaskVariant[]> {
const body = await jsonFetch<{ variants: TaskVariant[] }>(
`/v1/variants?learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
return body.variants ?? [];
}
// -- 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,
});
}