12b2300f6f
---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---
186 lines
5.9 KiB
TypeScript
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 };
|
|
} |