Files
nextcraft/apps/web/components/learner/build-surface.tsx
T
CIAgent ed243594d2 feat(P06): real build + defense surfaces, E2E credential flow (Waves 3-5)
Wave 3 (Task 6-3-01): /build/[competencyId] rewritten as a real build surface —
variant statement + starter files in a live namespace sandbox, workspace file tree +
editor with save, Run/Test buttons executing bounded commands in-sandbox with
read-only TerminalFrame output (CUT-2), live TelemetryStatus pulse, Lab feedback over
the live trace, honest 503-busy/403-429-denied states with retry.
Wave 4 (Task 6-4-01): /defend/[competencyId] rewritten — DefenseSession: start ->
examiner question -> typed answers (mic capture w/ MediaRecorder consent + denied
fallback; browser-SR first-class per CUT-1) -> finish -> verdict + integrity signals
-> Grade My Work renders real rubric bars from the trace digest. Dead mock components
disposed (oral-defense-interface, assessor-results-panel, proctor-banner — G-5 class).
Wave 5 (Task 6-5-01): test_e2e_credential_flow — REAL uvicorn + REAL namespaces:
variant -> sandbox -> in-sandbox exec -> trace -> grade (seed stamped) -> coaching ->
defense -> verdict -> proctor; corpus-fixture scan of all payloads. E2E caught a real
bug: the sandboxes API dropped task_id (every HTTP-created sandbox was capture-less)
— fixed. README E2E + manual browser pass documented.

pnpm build 4/4; typecheck FULL TURBO; backend suite 397 green; ruff clean.

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

196 lines
7.2 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@nextcraft/ui';
import {
MOCK_LEARNER_ID,
getTrace,
type ExecResult,
} from '../../lib/engine-client';
import { useSandboxSession } from '../../hooks/use-sandbox-session';
import { FileTree } from './file-tree';
import { RunControls } from './run-controls';
import { SandboxTerminal } from './sandbox-terminal';
import { LabFeedbackPanel } from './lab-feedback-panel';
/**
* The real build environment (REQ-3-008):
* variant statement + starter files in a live namespace sandbox, file CRUD,
* Run/Test with read-only output (CUT-2), live telemetry status, and Lab
* feedback over the live trace. Honest states throughout: busy (503 →
* retry), denied (403/429), error + retry.
*/
export function BuildSurface({
competencyId,
stackTitle,
}: {
competencyId: string;
stackTitle: string;
}) {
const session = useSandboxSession(competencyId);
const [activePath, setActivePath] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState('');
const [execResult, setExecResult] = useState<ExecResult | null>(null);
const [running, setRunning] = useState(false);
const [telemetryCount, setTelemetryCount] = useState(0);
// Load the active file into the editor when selection changes.
useEffect(() => {
if (!activePath) return;
void session.openFile(activePath).then((content) => {
if (content !== null) setEditorContent(content);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- openFile is stable per sandbox
}, [activePath]);
// Pick the README (or first file) once files land.
useEffect(() => {
if (!activePath && session.files.length > 0) {
setActivePath(session.files.find((f) => f === 'README.md') ?? session.files[0]);
}
}, [session.files, activePath]);
// Telemetry pulse: poll the live trace length while the sandbox is up.
useEffect(() => {
if (!session.variant || !session.sandboxId) return;
let cancelled = false;
const timer = setInterval(async () => {
try {
const trace = await getTrace(MOCK_LEARNER_ID, session.variant!.task_id);
if (!cancelled) setTelemetryCount(trace.length);
} catch {
/* service down: the status indicator shows disconnected */
}
}, 2500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [session.variant, session.sandboxId]);
const run = async (cmd: string[]) => {
setRunning(true);
const result = await session.run(cmd);
setExecResult(result);
setRunning(false);
void session.refreshFiles();
};
const save = async () => {
if (!activePath) return;
await session.saveFile(activePath, editorContent);
};
if (session.status === 'starting' || session.status === 'idle') {
return (
<div className="flex flex-col items-center gap-3 py-16 text-slate-500 dark:text-slate-400">
<Loader2 className="h-6 w-6 animate-spin text-primary-500" aria-hidden />
<p className="text-sm">Creating your build environment</p>
</div>
);
}
if (session.status === 'busy') {
return (
<div
role="alert"
className="flex flex-col items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-6 py-10 text-center dark:border-amber-700 dark:bg-amber-900/30"
>
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" aria-hidden />
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Environment busy all sandbox slots are taken.
</p>
<Button onClick={session.retry} variant="outline" size="sm">
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
</Button>
</div>
);
}
if (session.status === 'denied' || session.status === 'error') {
return (
<div
role="alert"
className="flex flex-col items-center gap-3 rounded-lg border border-red-300 bg-red-50 px-6 py-10 text-center dark:border-red-800 dark:bg-red-900/30"
>
<AlertTriangle className="h-6 w-6 text-red-500" aria-hidden />
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{session.errorMessage ?? 'Could not start the build environment.'}
</p>
<Button onClick={session.retry} variant="outline" size="sm">
<RefreshCw className="h-3.5 w-3.5" aria-hidden /> Retry
</Button>
</div>
);
}
return (
<div className="space-y-6">
<header className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600 dark:text-primary-400">
{stackTitle} · build
</p>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{session.variant?.statement ?? 'Your task'}
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
task {session.variant?.task_id} · variant seed{' '}
<code className="font-mono">{session.variant?.seed.slice(0, 12)}</code>
</p>
</header>
<RunControls
command="python -m pytest -q"
running={running}
busy={false}
onRun={() => void run(['python', '-m', 'pytest', '-q'])}
onTest={() => void run(['pytest', '-q'])}
/>
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<aside className="rounded-lg border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Workspace
</h2>
<FileTree
files={session.files}
activePath={activePath}
onSelect={setActivePath}
/>
</aside>
<div className="space-y-4">
<section className="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{activePath ?? 'editor'}
</h2>
<Button onClick={() => void save()} variant="outline" size="sm">
Save
</Button>
</div>
<textarea
value={editorContent}
onChange={(e) => setEditorContent(e.target.value)}
spellCheck={false}
aria-label={`Editing ${activePath ?? 'file'}`}
className="h-64 w-full resize-y rounded-md border border-slate-200 bg-slate-50 p-3 font-mono text-xs leading-relaxed text-slate-800 focus:border-primary-400 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
/>
</section>
<SandboxTerminal
result={execResult}
running={running}
telemetryActive={session.sandboxId !== null && telemetryCount > 0}
eventCount={telemetryCount}
/>
</div>
</div>
{session.variant && (
<LabFeedbackPanel learnerId={MOCK_LEARNER_ID} taskId={session.variant.task_id} />
)}
</div>
);
}