Files
nextcraft/apps/web/hooks/use-sandbox-session.ts
T
CIAgent c760f9af2b feat(P06): engine client + sandbox session hook + files/exec routes (Wave 2, task 6-2-01)
Backend: /v1/sandboxes/{id}/files (list/read/write; traversal rejected 422) and
/v1/sandboxes/{id}/exec (bounded command, captured output — CUT-2: no shell relay);
async workspace resolution for tracked + shell layouts; 17 API tests green.
Web: lib/engine-client.ts — typed fetch client for all engines (sandboxes/files/exec/
variants/grade/defense/traces/lab-SSE/proctor) with honest error mapping (503 busy ->
EngineBusyError, 403 not-allowlisted, 429 rate-limited); hooks/use-sandbox-session.ts —
variant->sandbox->starter-files bootstrap, run/test/saveFile/openFile actions, idempotent
destroy on unmount (AbortController), busy/denied/error states surfaced. typecheck 7/7.

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

172 lines
5.3 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' });
try {
const variant = await generateVariant(MOCK_LEARNER_ID, compId, controller.signal);
const sandbox = await createSandbox(MOCK_LEARNER_ID, variant.task_id, controller.signal);
// 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) {
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).
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);
};
}, []);
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 };
}