ed243594d2
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---
196 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
} |