'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(DEFAULT_STATE); const abortRef = useRef(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(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 => { 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 => { 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 => { 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 => { if (!state.sandboxId) return null; try { return await readFile(state.sandboxId, path); } catch { return null; } }, [state.sandboxId], ); const refreshFiles = useCallback(async (): Promise => { 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 }; }