a905eb8c67
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---
328 lines
9.1 KiB
TypeScript
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,
|
|
});
|
|
} |