Files
nextcraft/apps/web/hooks/use-sandbox-session.ts
T
CIAgent 12b2300f6f fix(P07): final review — CORS PUT, WS origin gate, ingest leak+O(n²), symlink escape, retry leak, doc-reality gaps
---ci---
phase: 7
milestone: v0.3
status: review
lessons:
  - P0 CORS: allow_methods lacked PUT while the build surface writes files with PUT — every cross-origin Save failed preflight; pinned with tests/api/test_cors.py
  - P0 ingest leak: queue-overflow flood path returned without the disconnect sentinel, parking the drainer forever (one leaked task-set per flooded trace); sentinel now always enqueued, real-server regression test added
  - P1 perf: flood cap counted rows via len(get_trace(...)) — O(trace) per append, O(n²) per session; TraceStore.count() (COUNT(*)) added and wired
  - P0 security: file routes followed exec-planted symlinks out of the workspace bind; _resolve_in_workspace refuses escapes (422), read/write now 404 on unknown sandboxes (was 500)
  - P1 security: WS ingest accepted any browser Origin (CORS middleware does not cover WS); localhost dev origins + no-Origin (capture agent) allowed, others 1008
  - P1 correctness: use-sandbox-session leaked a created sandbox on any mid-start failure (per-learner cap 1 → all retries 429 forever); failed starts now destroy what they created
  - P2 testing: reconnect-flush test killed mid-burst (nondeterministic under load, reproduced on pre-change code); now waits for server-side observation of the pre-kill burst — the underlying one-line replay-margin/ACK gap is documented for v0.4
  - maintainability: grading-store/templates/grading.ts docstrings claimed grading is variant-blind (stale pre-P4 text) — updated; ARCHITECTURE.md referenced nonexistent voice/openai_audio.py; dead if TYPE_CHECKING: pass blocks removed
---/ci---
2026-09-12 20:02:10 +00:00

186 lines
5.9 KiB
TypeScript

'use client';
/**
* useSandboxSession — a learner's real build session (v0.3, REQ-3-008).
*
* On mount: generate the per-learner variant for the competency, create a
* telemetry-wired sandbox, and load the starter files into the workspace.
* On unmount: destroy the sandbox (idempotent; AbortController pattern).
*
* CUT-2: run/test actions execute bounded commands in the sandbox and return
* captured output — there is NO interactive shell relay in v0.3.
*
* D-032/G-5 surfaced honestly: 503 pool-full becomes `status='busy'` with a
* retry action; 403/429 become `status='denied'` with the engine's message.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
EngineError,
MOCK_LEARNER_ID,
createSandbox,
destroySandbox,
generateVariant,
listFiles,
readFile,
runCommand,
writeFile,
type ExecResult,
} from '../lib/engine-client';
import type { TaskVariant } from '@nextcraft/types';
export type SandboxStatus = 'idle' | 'starting' | 'ready' | 'busy' | 'denied' | 'error';
export interface SandboxSessionState {
status: SandboxStatus;
variant: TaskVariant | null;
sandboxId: string | null;
files: string[];
errorMessage: string | null;
}
const DEFAULT_STATE: SandboxSessionState = {
status: 'idle',
variant: null,
sandboxId: null,
files: [],
errorMessage: null,
};
export function useSandboxSession(competencyId: string | null) {
const [state, setState] = useState<SandboxSessionState>(DEFAULT_STATE);
const abortRef = useRef<AbortController | null>(null);
const start = useCallback(async (compId: string) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setState({ ...DEFAULT_STATE, status: 'starting' });
let createdId: string | null = null;
try {
const variant = await generateVariant(MOCK_LEARNER_ID, compId, controller.signal);
const sandbox = await createSandbox(MOCK_LEARNER_ID, variant.task_id, controller.signal);
createdId = sandbox.id;
// Materialize the variant's starter files into the sandbox workspace.
for (const [path, content] of Object.entries(variant.starter_files ?? {})) {
await writeFile(sandbox.id, path, content, controller.signal);
}
const files = await listFiles(sandbox.id, controller.signal);
setState({ status: 'ready', variant, sandboxId: sandbox.id, files, errorMessage: null });
} catch (err) {
// A created sandbox must not outlive a failed start (per-learner cap
// is 1 — a leaked one blocks every retry with 429 forever). This
// covers aborts mid-start, failed starter-file writes, and errors
// after create; a 409/404 on destroy is benign.
if (createdId) void destroySandbox(createdId).catch(() => undefined);
if (controller.signal.aborted) return;
if (err instanceof EngineError) {
setState({
...DEFAULT_STATE,
status: err.status === 503 ? 'busy' : err.status === 403 || err.status === 429 ? 'denied' : 'error',
errorMessage: err.message,
});
return;
}
setState({
...DEFAULT_STATE,
status: 'error',
errorMessage: err instanceof Error ? err.message : 'Failed to start build session.',
});
}
}, []);
useEffect(() => {
if (!competencyId) return;
void start(competencyId);
return () => {
abortRef.current?.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- competencyId is the only dep
}, [competencyId]);
// Unmount: destroy the sandbox (idempotent; a killed session is fine).
// The ref is ALSO updated inside start() (via this effect watching state
// changes) so unmount-mid-start finds the id even before 'ready' lands.
const sandboxIdRef = useRef<string | null>(null);
useEffect(() => {
sandboxIdRef.current = state.sandboxId;
}, [state.sandboxId]);
useEffect(() => {
return () => {
const id = sandboxIdRef.current;
if (id) void destroySandbox(id).catch(() => undefined);
};
}, []);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const run = useCallback(
async (cmd: string[]): Promise<ExecResult | null> => {
if (!state.sandboxId) return null;
try {
return await runCommand(state.sandboxId, cmd);
} catch (err) {
setState((s) => ({
...s,
status: 'error',
errorMessage: err instanceof Error ? err.message : 'run failed',
}));
return null;
}
},
[state.sandboxId],
);
const test = useCallback(async (): Promise<ExecResult | null> => {
if (!state.variant || !state.sandboxId) return null;
// All v0.3 templates ship pytest-based starter tests (PLAN Task 6-3-01).
return run(['pytest', '-q']);
}, [run, state.variant, state.sandboxId]);
const saveFile = useCallback(
async (path: string, content: string): Promise<boolean> => {
if (!state.sandboxId) return false;
try {
await writeFile(state.sandboxId, path, content);
const files = await listFiles(state.sandboxId);
setState((s) => ({ ...s, files }));
return true;
} catch {
return false;
}
},
[state.sandboxId],
);
const openFile = useCallback(
async (path: string): Promise<string | null> => {
if (!state.sandboxId) return null;
try {
return await readFile(state.sandboxId, path);
} catch {
return null;
}
},
[state.sandboxId],
);
const refreshFiles = useCallback(async (): Promise<void> => {
if (!state.sandboxId) return;
try {
const files = await listFiles(state.sandboxId);
setState((s) => ({ ...s, files }));
} catch {
/* sandbox gone; leave state as-is */
}
}, [state.sandboxId]);
const retry = useCallback(() => {
if (competencyId) void start(competencyId);
}, [competencyId, start]);
return { ...state, run, test, saveFile, openFile, refreshFiles, retry };
}