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:
@@ -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,2 +1 @@
|
||||
# AI service (v0.2) — learner chat/panels stream from this FastAPI service
|
||||
NEXT_PUBLIC_AI_SERVICE_URL=http://localhost:8420
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user