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---
This commit is contained in:
CIAgent
2026-09-12 17:21:30 +00:00
parent b4ae388f22
commit c760f9af2b
5 changed files with 641 additions and 2 deletions
+105
View File
@@ -23,6 +23,7 @@ never part of the API contract.
import time
from collections import deque
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, ConfigDict, Field
@@ -217,3 +218,107 @@ async def delete_sandbox(
if test_layout is not None:
result.headers["X-Workspace-Copy"] = str(test_layout.workspace)
return result
# -- workspace files + exec (Phase 6, REQ-3-008; CUT-2) -------------------------
#
# The build surface reads/writes/list workspace files and runs Run/Test
# commands through the manager's backend. NO interactive shell relay (CUT-2:
# keystroke-level stdin/stdout is v0.4) — each exec is a bounded command with
# captured output. Paths are WORKSPACE-RELATIVE; traversal outside the
# workspace is rejected (the workdir bind is the boundary, but the API adds
# its own containment check — defense in depth).
class FileWriteRequest(BaseModel):
path: str = Field(min_length=1)
content: str
class ExecRequest(BaseModel):
cmd: list[str] = Field(min_length=1)
class ExecResponse(BaseModel):
cmd: list[str]
returncode: int
stdout: str
stderr: str
duration_s: float
async def _workspace_dir(manager: SandboxManager, sandbox_id: str):
"""Resolve the sandbox workspace (tracked layout or shell layout)."""
info = await manager.get(sandbox_id) # raises SandboxNotFoundError -> 404
backend = manager._backend # noqa: SLF001 - API owns the composition seam
tracked = getattr(backend, "_tracked", {}).get(sandbox_id)
if tracked is not None:
return tracked.workspace, info
return info.workdir / "workspace", info
def _safe_rel_path(raw: str) -> Path:
"""Workspace-relative path; reject absolute/traversal paths."""
candidate = Path(raw)
if candidate.is_absolute() or ".." in candidate.parts:
raise HTTPException(status_code=422, detail=f"invalid workspace path {raw!r}")
return candidate
@router.get("/{sandbox_id}/files")
async def list_files(
sandbox_id: str,
manager: SandboxManager = Depends(get_sandbox_manager),
) -> dict:
try:
workspace, _ = await _workspace_dir(manager, sandbox_id)
except SandboxNotFoundError:
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
return {"files": sorted(p.name for p in workspace.iterdir()) if workspace.is_dir() else []}
@router.get("/{sandbox_id}/files/{path:path}")
async def read_file(
sandbox_id: str,
path: str,
manager: SandboxManager = Depends(get_sandbox_manager),
) -> dict:
workspace, _ = await _workspace_dir(manager, sandbox_id)
rel = _safe_rel_path(path)
target = workspace / rel
if not target.is_file():
raise HTTPException(status_code=404, detail=f"no file {path!r}")
return {"path": path, "content": target.read_text(errors="replace")}
@router.put("/{sandbox_id}/files/{path:path}")
async def write_file(
sandbox_id: str,
path: str,
body: FileWriteRequest,
manager: SandboxManager = Depends(get_sandbox_manager),
) -> dict:
workspace, _ = await _workspace_dir(manager, sandbox_id)
rel = _safe_rel_path(body.path)
target = workspace / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body.content)
return {"path": body.path, "written": True}
@router.post("/{sandbox_id}/exec", response_model=ExecResponse)
async def exec_command(
sandbox_id: str,
body: ExecRequest,
manager: SandboxManager = Depends(get_sandbox_manager),
) -> ExecResponse:
try:
await manager.get(sandbox_id)
except SandboxNotFoundError:
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
backend = manager._backend # noqa: SLF001 - API owns the composition seam
handle = manager._handles.get(sandbox_id) # noqa: SLF001
if handle is None:
raise HTTPException(status_code=404, detail=f"no live handle {sandbox_id!r}")
result = await backend.exec(handle, body.cmd)
return ExecResponse(**result.model_dump())
@@ -359,3 +359,50 @@ def test_real_backend_create_path_runs(tmp_path: Path) -> None:
assert (sandbox_root / sandbox_id / "workspace").is_dir()
finally:
shutil.rmtree(sandbox_root, ignore_errors=True)
class TestFilesAndExecRoutes:
"""Workspace CRUD + Run/Test exec (Phase 6, REQ-3-008, CUT-2)."""
def test_file_write_read_list_roundtrip(self, client):
handle = client.post(
"/v1/sandboxes", json={"learner_id": "pilot-learner"}
).json()
sbx = handle["id"]
put = client.put(
f"/v1/sandboxes/{sbx}/files/main.py",
json={"path": "main.py", "content": "print('hi')"},
)
assert put.status_code == 200, put.text
got = client.get(f"/v1/sandboxes/{sbx}/files/main.py")
assert got.status_code == 200
assert "print('hi')" in got.json()["content"]
listed = client.get(f"/v1/sandboxes/{sbx}/files")
assert "main.py" in listed.json()["files"]
client.delete(f"/v1/sandboxes/{sbx}")
def test_traversal_rejected(self, client):
handle = client.post(
"/v1/sandboxes", json={"learner_id": "pilot-learner"}
).json()
sbx = handle["id"]
bad = client.put(
f"/v1/sandboxes/{sbx}/files/..%2Fescape.txt",
json={"path": "../escape.txt", "content": "x"},
)
assert bad.status_code == 422
client.delete(f"/v1/sandboxes/{sbx}")
def test_unknown_file_404(self, client):
handle = client.post(
"/v1/sandboxes", json={"learner_id": "pilot-learner"}
).json()
sbx = handle["id"]
assert client.get(f"/v1/sandboxes/{sbx}/files/ghost.py").status_code == 404
client.delete(f"/v1/sandboxes/{sbx}")
def test_exec_unknown_sandbox_404(self, client):
resp = client.post(
"/v1/sandboxes/sbx-nope/exec", json={"cmd": ["echo", "hi"]}
)
assert resp.status_code == 404
-1
View File
@@ -1,2 +1 @@
# AI service (v0.2) — learner chat/panels stream from this FastAPI service
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420
+172
View File
@@ -0,0 +1,172 @@
'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 };
}
+316
View File
@@ -0,0 +1,316 @@
/**
* Nextcraft — engine client (v0.3, REQ-3-008).
*
* Typed fetch client for the real credential engines in ai-service:
* sandbox lifecycle + workspace files + Run/Test exec (CUT-2: bounded
* commands with captured output — NO interactive shell relay), variants,
* grading, oral defense, telemetry traces, lab feedback, proctor signals.
*
* Error discipline (G-5/D-032 surfaced honestly):
* 503 -> EngineBusyError (pool full — "environment busy, retry")
* 403 -> NotAllowlistedError (learner id not in the server allowlist)
* 429 -> RateLimitedError (per-learner/global caps)
*/
import type {
TaskVariant,
GradeRecord,
DefenseStart,
DefenseAnswer,
DefenseFinish,
DefenseSession,
TelemetryEvent,
} from '@nextcraft/types';
export const AI_SERVICE_URL =
process.env.NEXT_PUBLIC_AI_SERVICE_URL ?? 'http://localhost:8420';
/** v0.3 mock session constant (G-5: allowlisted server-side as pilot-learner). */
export const MOCK_LEARNER_ID = 'pilot-learner';
export class EngineError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'EngineError';
}
}
export class EngineBusyError extends EngineError {
constructor() {
super('Sandbox pool is full — retry shortly.', 503);
this.name = 'EngineBusyError';
}
}
export class NotAllowlistedError extends EngineError {
constructor() {
super('This learner is not allowlisted on this pilot.', 403);
this.name = 'NotAllowlistedError';
}
}
export class RateLimitedError extends EngineError {
constructor(message = 'Too many requests — slow down.') {
super(message, 429);
this.name = 'RateLimitedError';
}
}
async function parseError(resp: Response): Promise<EngineError> {
if (resp.status === 503) return new EngineBusyError();
if (resp.status === 403) return new NotAllowlistedError();
if (resp.status === 429) return new RateLimitedError();
let detail = `${resp.status} ${resp.statusText}`;
try {
const body = await resp.json();
if (typeof body?.detail === 'string') detail = body.detail;
} catch {
/* non-JSON error body */
}
return new EngineError(detail, resp.status);
}
async function jsonFetch<T>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${AI_SERVICE_URL}${path}`, {
signal: init?.signal,
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (!resp.ok) throw await parseError(resp);
return (await resp.json()) as T;
}
// -- sandboxes -----------------------------------------------------------------
export interface SandboxHandle {
id: string;
workdir: string;
created_at: string;
}
export async function createSandbox(
learnerId: string,
taskId?: string,
signal?: AbortSignal,
): Promise<SandboxHandle> {
return jsonFetch('/v1/sandboxes', {
method: 'POST',
body: JSON.stringify(taskId ? { learner_id: learnerId, task_id: taskId } : { learner_id: learnerId }),
signal,
});
}
export async function destroySandbox(id: string, signal?: AbortSignal): Promise<void> {
const resp = await fetch(`${AI_SERVICE_URL}/v1/sandboxes/${id}`, {
method: 'DELETE',
signal,
});
if (!resp.ok && resp.status !== 404) throw await parseError(resp);
}
export async function listSandboxes(signal?: AbortSignal): Promise<SandboxHandle[]> {
const body = await jsonFetch<{ sandboxes: SandboxHandle[] }>('/v1/sandboxes', { signal });
return body.sandboxes ?? [];
}
// -- workspace files (CUT-2: CRUD + bounded exec; no shell relay) -------------
export async function listFiles(sandboxId: string, signal?: AbortSignal): Promise<string[]> {
const body = await jsonFetch<{ files: string[] }>(
`/v1/sandboxes/${sandboxId}/files`,
{ signal },
);
return body.files ?? [];
}
export async function readFile(sandboxId: string, path: string, signal?: AbortSignal): Promise<string> {
const body = await jsonFetch<{ content: string }>(
`/v1/sandboxes/${sandboxId}/files/${encodeURIComponent(path)}`,
{ signal },
);
return body.content;
}
export async function writeFile(
sandboxId: string,
path: string,
content: string,
signal?: AbortSignal,
): Promise<void> {
await jsonFetch(`/v1/sandboxes/${sandboxId}/files/${encodeURIComponent(path)}`, {
method: 'PUT',
body: JSON.stringify({ path, content }),
signal,
});
}
export interface ExecResult {
cmd: string[];
returncode: number;
stdout: string;
stderr: string;
duration_s: number;
}
export async function runCommand(
sandboxId: string,
cmd: string[],
signal?: AbortSignal,
): Promise<ExecResult> {
return jsonFetch(`/v1/sandboxes/${sandboxId}/exec`, {
method: 'POST',
body: JSON.stringify({ cmd }),
signal,
});
}
// -- variants (REQ-3-005) ------------------------------------------------------
export async function generateVariant(
learnerId: string,
competencyId: string,
signal?: AbortSignal,
): Promise<TaskVariant> {
return jsonFetch('/v1/variants', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, competency_id: competencyId }),
signal,
});
}
// -- grading (REQ-3-004) -------------------------------------------------------
export async function requestGrade(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<GradeRecord> {
return jsonFetch('/v1/assessment/grade', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}
// -- oral defense (REQ-3-006) --------------------------------------------------
export async function startDefense(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<DefenseStart> {
return jsonFetch('/v1/defense/start', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}
export async function answerDefense(
defenseId: string,
text: string,
signal?: AbortSignal,
): Promise<DefenseAnswer> {
return jsonFetch(`/v1/defense/${defenseId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ text }).toString(),
signal,
});
}
export async function finishDefense(
defenseId: string,
signal?: AbortSignal,
): Promise<DefenseFinish> {
return jsonFetch(`/v1/defense/${defenseId}/finish`, {
method: 'POST',
signal,
});
}
export async function getDefense(
defenseId: string,
signal?: AbortSignal,
): Promise<DefenseSession> {
return jsonFetch(`/v1/defense/${defenseId}`, { signal });
}
// -- telemetry (REQ-3-003) -----------------------------------------------------
export async function getTrace(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<TelemetryEvent[]> {
const body = await jsonFetch<{ events: TelemetryEvent[] }>(
`/v1/telemetry/traces/${encodeURIComponent(learnerId)}/${encodeURIComponent(taskId)}`,
{ signal },
);
return body.events ?? [];
}
// -- lab feedback + proctor (SSE / JSON) ----------------------------------------
/** Stream Lab in-flow feedback (SSE) over the LIVE trace. */
export async function streamLabFeedback(
learnerId: string,
taskId: string,
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const { parseSseEvents } = await import('./sse');
const resp = await fetch(`${AI_SERVICE_URL}/v1/lab/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
if (!resp.ok) throw await parseError(resp);
if (!resp.body) return;
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseEvents(buffer);
buffer = rest;
for (const raw of events) {
if (raw === '[DONE]') return;
try {
const parsed = JSON.parse(raw);
if (parsed?.type === 'delta' && typeof parsed.content === 'string') {
onDelta(parsed.content);
}
if (parsed?.type === 'error') {
throw new EngineError(parsed.message ?? 'lab feedback stream error', 502);
}
} catch (err) {
if (err instanceof EngineError) throw err;
}
}
}
}
export interface ProctorAssessment {
signals: Array<{ signal_type: string; severity: string; note: string }>;
intervention: string;
summary: string;
}
export async function proctorSignals(
learnerId: string,
taskId: string,
signal?: AbortSignal,
): Promise<ProctorAssessment> {
return jsonFetch('/v1/proctor/signals', {
method: 'POST',
body: JSON.stringify({ learner_id: learnerId, task_id: taskId }),
signal,
});
}