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 import time
from collections import deque from collections import deque
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -217,3 +218,107 @@ async def delete_sandbox(
if test_layout is not None: if test_layout is not None:
result.headers["X-Workspace-Copy"] = str(test_layout.workspace) result.headers["X-Workspace-Copy"] = str(test_layout.workspace)
return result 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() assert (sandbox_root / sandbox_id / "workspace").is_dir()
finally: finally:
shutil.rmtree(sandbox_root, ignore_errors=True) 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 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,
});
}