merge(P07): final review + audit → milestone/v0.3-credential-engines

---ci---
phase: 7
milestone: v0.3
status: ship
---/ci---
This commit is contained in:
CIAgent
2026-09-12 21:44:46 +00:00
23 changed files with 455 additions and 98 deletions
+48 -15
View File
@@ -26,6 +26,8 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
| pydantic | 2.13.x | Request/response models, structured outputs | | pydantic | 2.13.x | Request/response models, structured outputs |
| pydantic-settings | 2.15.x | Settings + env-file loading (replaces python-dotenv) | | pydantic-settings | 2.15.x | Settings + env-file loading (replaces python-dotenv) |
| httpx | 0.28.x | Async LLM HTTP client (ollama-cloud + local providers) | | httpx | 0.28.x | Async LLM HTTP client (ollama-cloud + local providers) |
| sqlmodel / sqlalchemy | 0.0.24 / 2.x | Typed SQLite persistence for the v0.3 engine stores (D-027) |
| python-multipart | 0.0.x | Multipart audio upload for the defense answer route (REQ-3-006) |
| sse-starlette | 3.4.x | SSE framing, ping keep-alive | | sse-starlette | 3.4.x | SSE framing, ping keep-alive |
| pytest | 9.x | Test runner | | pytest | 9.x | Test runner |
| pytest-asyncio | 1.4.x | Async tests (auto mode) | | pytest-asyncio | 1.4.x | Async tests (auto mode) |
@@ -65,14 +67,14 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
| Component | Description | Boundaries | Depends On | | Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------| |-----------|-------------|------------|------------|
| `ai_service/main.py` | FastAPI app factory, lifespan (httpx client pool, provider factory), CORS (localhost only), /health | App entry | config, llm, agents, api | | `ai_service/main.py` | FastAPI app factory, lifespan (httpx client pool, provider factory, SandboxManager + reaper loop, SQLite engine stores on app.state), CORS (localhost only, incl. PUT for file writes), /health | App entry | config, llm, agents, api, engines |
| `ai_service/config.py` | pydantic-settings Settings (env_prefix="AI_", env_file, SecretStr key) | Configuration only | None | | `ai_service/config.py` | pydantic-settings Settings (env_prefix="AI_", env_file, SecretStr key) | Configuration only | None |
| `ai_service/api/` | Endpoints: chat.py (POST /v1/chat/stream, SSE), lab.py, assessment.py, proctor.py, mentor.py; deps.py (DI) | Composes agents + sessions; never imported by llm/ or agents/ | agents, llm | | `ai_service/api/` | Endpoints: chat.py (POST /v1/chat/stream, SSE), lab.py, assessment.py (POST /v1/assessment/evaluate v0.2 + POST /v1/assessment/grade v0.3), proctor.py, mentor.py, sandboxes.py (lifecycle + files/exec routes, G-5 abuse gates), telemetry.py (WS ingest + trace/gaps reads), variants.py (seeded per-learner variants), defense.py (defense loop, REQ-3-006); deps.py (DI) | Composes agents + sessions + engines; never imported by llm/ or agents/ | agents, llm, sandbox, telemetry, grading, variants, voice |
| `ai_service/llm/` | types.py (Message; ChatDelta/ChoiceDelta removed in P3 — no consumers), base.py (LLMProvider protocol), openai_compat.py (ollama-cloud + local), mock.py (deterministic), factory.py | Never imports agents/ or api/ | config | | `ai_service/llm/` | types.py (Message; ChatDelta/ChoiceDelta removed in P3 — no consumers), base.py (LLMProvider protocol), openai_compat.py (ollama-cloud + local), mock.py (deterministic), factory.py | Never imports agents/ or api/ | config |
| `ai_service/agents/` | base.py (BaseAgent ABC), registry.py, session.py (SessionStore), structured.py (JSON defense), coach/tutor/lab/assessor/proctor/mentor.py | Never imports api/ | llm, prompts, corpus | | `ai_service/agents/` | base.py (BaseAgent ABC), registry.py, session.py (SessionStore), structured.py (JSON defense), coach/tutor/lab/assessor/proctor/mentor.py + examiner.py (seventh agent, v0.3) | Never imports api/ | llm, prompts, corpus, telemetry |
| `ai_service/prompts/` | Per-agent system prompt constants + render_context functions (str.format_map) | Data only | None | | `ai_service/prompts/` | Per-agent system prompt constants + render_context functions (str.format_map) | Data only | None |
| `ai_service/corpus/` | Mock engine inputs: learner_context.py, telemetry.py (Lab/Proctor scenarios), artifacts.py (pre-baked artifacts, rubrics, transcripts) | Pydantic-typed; aligned with TS packages/mock-data by convention | None | | `ai_service/corpus/` | Mock engine inputs: learner_context.py, telemetry.py (Lab/Proctor scenarios), artifacts.py (pre-baked artifacts, rubrics, transcripts). Since v0.3 P6 these are DORMANT, test-only fixtures (dormant-header noted) — the live learner path uses real engine inputs | Pydantic-typed; aligned with TS packages/mock-data by convention | None |
| `scripts/` | bootstrap.sh (venv + pip install idempotent), dev.sh (exports keys from .ciagent/.env.secrets → uvicorn), test.sh (pytest), lint.sh (ruff check, G-3) | Dev entry points | pyproject.toml | | `scripts/` | bootstrap.sh (venv + pip install idempotent), dev.sh (exports keys from .ciagent/.env.secrets → uvicorn), test.sh (pytest), lint.sh (ruff check, v0.2 G-3) | Dev entry points | pyproject.toml |
| `tests/` | conftest.py (mock provider, settings override, TestClient), health, llm (MockTransport parser), agents (framework + per-agent), api (SSE stream tests) | Mock provider only — no cloud | all | | `tests/` | conftest.py (mock provider, settings override, TestClient), health, llm (MockTransport parser), agents (framework + per-agent), api (SSE stream tests) | Mock provider only — no cloud | all |
**Module boundary rules:** `llm/` never imports `agents/` or `api/`; `agents/` never imports `api/`; `api/` composes both via DI. `corpus/` is the only home of mock engine data. Prompts are code — versioned and reviewed in git. **Module boundary rules:** `llm/` never imports `agents/` or `api/`; `agents/` never imports `api/`; `api/` composes both via DI. `corpus/` is the only home of mock engine data. Prompts are code — versioned and reviewed in git.
@@ -85,7 +87,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
| `ai_service/telemetry/` | `models.py` (TelemetryEvent, TraceSpan), `store.py` (TraceStore protocol + SQLite impl D-027), `ingest.py` (WebSocket /v1/telemetry/ingest, seq gap detection D-026) | Persistence; never imports agents/ | config | | `ai_service/telemetry/` | `models.py` (TelemetryEvent, TraceSpan), `store.py` (TraceStore protocol + SQLite impl D-027), `ingest.py` (WebSocket /v1/telemetry/ingest, seq gap detection D-026) | Persistence; never imports agents/ | config |
| `ai_service/grading/` | `features.py` (deterministic trace digest D-028), `engine.py` (rubric scoring orchestration), `store.py` (GradeStore) | LLM only via digest; never sees raw trace | llm, telemetry, prompts | | `ai_service/grading/` | `features.py` (deterministic trace digest D-028), `engine.py` (rubric scoring orchestration), `store.py` (GradeStore) | LLM only via digest; never sees raw trace | llm, telemetry, prompts |
| `ai_service/variants/` | `templates.py` (task template library), `generator.py` (seeded LLM instantiation D-029), `store.py` (VariantStore) | LLM via structured output | llm, grading | | `ai_service/variants/` | `templates.py` (task template library), `generator.py` (seeded LLM instantiation D-029), `store.py` (VariantStore) | LLM via structured output | llm, grading |
| `ai_service/voice/` | `base.py` (VoiceProvider protocol D-030), `openai_audio.py` (STT/TTS vs compatible endpoint), `browser.py` (native SR/TTS fallback descriptor), `mock.py` (deterministic) | Never imports agents/ or api/ | config | | `ai_service/voice/` | `base.py` (VoiceProvider protocol D-030), `browser.py` (native SR/TTS fallback descriptor), `mock.py` (deterministic; the real server STT/TTS provider is the v0.4 seam — GRILL CUT-1/G-7), `factory.py` (provider selection), `defense_store.py` (DefenseStore: transcripts + integrity signals, D-027) | Never imports agents/ or api/ | config |
| `ai_service/agents/examiner.py` | Seventh agent: oral defense examiner; streams over existing SSE, consumes process traces + emits integrity signals | reuses BaseAgent (D-018) | llm, prompts, telemetry | | `ai_service/agents/examiner.py` | Seventh agent: oral defense examiner; streams over existing SSE, consumes process traces + emits integrity signals | reuses BaseAgent (D-018) | llm, prompts, telemetry |
| `ai_service/data/*.db` | SQLite databases (telemetry/grades/variants/defenses) | gitignored | — | | `ai_service/data/*.db` | SQLite databases (telemetry/grades/variants/defenses) | gitignored | — |
| `scripts/sandbox-agent.py` | Tiny in-namespace capture process shipped into the sandbox; streams telemetry to ingest | standalone | stdlib only | | `scripts/sandbox-agent.py` | Tiny in-namespace capture process shipped into the sandbox; streams telemetry to ingest | standalone | stdlib only |
@@ -96,21 +98,21 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
| Component | Description | Boundaries | Depends On | | Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------| |-----------|-------------|------------|------------|
| `app/(learner)/` | Learner surface route group: landing, catalog, competency stack, dashboard, byte viewer, sandbox mockup, assessment mockup | Learner-only routes and layouts | packages/ui, packages/mock-data, packages/types | | `app/(learner)/` | Learner surface route group: landing, catalog, competency stack, dashboard, byte viewer, build surface (`/build/[competencyId]` — real in-browser build), defense surface (`/defend/[competencyId]` — live oral defense + grading) | Learner-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/(marketplace)/` | Marketplace surface route group: job board, job detail, employer profile, search/filter, pricing | Marketplace-only routes and layouts | packages/ui, packages/mock-data, packages/types | | `app/(marketplace)/` | Marketplace surface route group: job board, job detail, employer profile, search/filter, pricing | Marketplace-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/(employer)/` | Employer dashboard route group: overview, talent search, candidate profile, posting management | Employer-only routes and layouts | packages/ui, packages/mock-data, packages/types | | `app/(employer)/` | Employer dashboard route group: overview, talent search, candidate profile, posting management | Employer-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/(admin)/` | Admin surface route group: overview, learner management, competency graph viewer, moderation | Admin-only routes and layouts | packages/ui, packages/mock-data, packages/types | | `app/(admin)/` | Admin surface route group: overview, learner management, competency graph viewer, moderation | Admin-only routes and layouts | packages/ui, packages/mock-data, packages/types |
| `app/layout.tsx` | Root layout: theme provider, navigation shell, responsive container | All routes | packages/ui | | `app/layout.tsx` | Root layout: theme provider, navigation shell, responsive container | All routes | packages/ui |
| `components/` | Surface-specific components (learner/, marketplace/, employer/, admin/) plus shared chrome (navigation-shell, header/footer, role-switcher, theme-provider, breadcrumbs, dark-mode-toggle) | App-level components | packages/ui | | `components/` | Surface-specific components (learner/, marketplace/, employer/, admin/) plus shared chrome (navigation-shell, header/footer, role-switcher, theme-provider, breadcrumbs, dark-mode-toggle); v0.3 learner: build-surface, sandbox-terminal (read-only exec output), defense-session | App-level components | packages/ui |
| `hooks/` | use-chat-stream.ts — SSE client hook: fetch + ReadableStream, byte buffering + frame reassembly, idempotent AbortController cleanup | Client components only | ai-service SSE | | `hooks/` | use-chat-stream.ts — SSE client hook: fetch + ReadableStream, byte buffering + frame reassembly, idempotent AbortController cleanup; use-sandbox-session.ts (v0.3) — sandbox lifecycle for the build session: create on task open, destroy on unmount, mid-start failure cleanup, 503/403/429 honest surfaces | Client components only | ai-service SSE / engine API |
| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, G-1), breadcrumbs.ts, format.ts | Pure utilities | None | | `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, v0.2 G-1), breadcrumbs.ts, format.ts, engine-client.ts (v0.3: typed fetch client for /v1/sandboxes, files/exec, variants, grade, defense, traces) | Pure utilities | None |
### packages/ui — Shared Component Library ### packages/ui — Shared Component Library
| Component | Description | Boundaries | Depends On | | Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------| |-----------|-------------|------------|------------|
| `tokens/` | Design tokens as TS constants: colors, spacing, radii, shadows, breakpoints (mirrored as Tailwind v4 `@theme` tokens in apps/web globals.css) | Foundation layer — no dependencies | None | | `tokens/` | Design tokens as TS constants: colors, spacing, radii, shadows, breakpoints (mirrored as Tailwind v4 `@theme` tokens in apps/web globals.css) | Foundation layer — no dependencies | None |
| `primitives/` | Button, Input, Card, Badge, Avatar — each with a Storybook story | Atomic UI components | tokens, packages/types | | `primitives/` | Button, Input, Card, Badge, Avatar (v0.1) + TerminalFrame, TelemetryStatus, MicControl, GradeBadge, TranscriptViewer (v0.3 build/defense surfaces) — each with a Storybook story | Atomic UI components | tokens, packages/types |
Composite/layout/theme components (navigation shell, tables, chat panels, graph viewer, theme provider) live in `apps/web/components/` as app-level components, not in packages/ui. Composite/layout/theme components (navigation shell, tables, chat panels, graph viewer, theme provider) live in `apps/web/components/` as app-level components, not in packages/ui.
@@ -134,11 +136,42 @@ Composite/layout/theme components (navigation shell, tables, chat panels, graph
| `marketplace.ts` | Job, Employer, Candidate, JobPosting, TalentMatch, SearchFilter | Marketplace types | None | | `marketplace.ts` | Job, Employer, Candidate, JobPosting, TalentMatch, SearchFilter | Marketplace types | None |
| `user.ts` | Learner, Admin, EmployerUser, AgeGroup, Role | User types | None | | `user.ts` | Learner, Admin, EmployerUser, AgeGroup, Role | User types | None |
| `ui.ts` | Component props, theme config, breakpoint definitions | UI types | None | | `ui.ts` | Component props, theme config, breakpoint definitions | UI types | None |
| `telemetry.ts` | TelemetryEvent/ExecResult wire shapes for the live build surface (v0.3) | Engine types | None |
| `variants.ts` | Variant/TaskTemplate shapes for per-learner task statements (v0.3) | Engine types | None |
| `grading.ts` | GradeRecord/RubricScore shapes for live grading display (v0.3) | Engine types | None |
| `defense.ts` | DefenseSession/transcript/integrity-signal shapes for the defense surface (v0.3) | Engine types | None |
--- ---
## Data Flow ## Data Flow
### v0.3 credential flow (current)
```
[learner build surface /build/*] [learner defense surface /defend/*]
file CRUD + Run/Test (HTTP) mic MediaRecorder / typed + TTS playback
│ │
▼ ▼
[api/sandboxes files/exec] ──exec──▶ [namespace sandbox] [api/defense start/answer/finish]
│ │ capture agent │
│ ▼ (WS telemetry) ▼
│ [api/telemetry ingest] [DefenseStore (SQLite)]
│ │ SQLite │ transcript + integrity signals
│ ▼ │
│ [TraceStore] ────▶ [GradingEngine: digest (grading/features)
│ │ + rubric LLM (D-028)] ──▶ [GradeStore]
│ ▼ ▼
└──▶ Lab agent (live digest) Assessor (grade output) / Proctor (integrity)
Examiner agent (SSE) ◀── defense sessions
variants: [api/variants] ◀── [VariantStore (seeded, D-029)] ── per-learner task statements
```
- Lab consumes the live trace digest; Assessor consumes grading-engine output; Proctor consumes telemetry + defense integrity signals (REQ-3-007) — no mock fallback in the learner path (v0.2 corpus scenarios are dormant test-only fixtures).
- The learner's path is: variant task → in-sandbox build (telemetry streams to SQLite) → grade My Work (rubric scores from the real trace) → oral defense → verdict.
- Flooded/gapped traces are terminal: ingest closes 1008 and marks INCOMPLETE_FLOODED (G-3); the grader returns UNGRADABLE_TRACE_INCOMPLETE (G-4) — no credential from an incomplete trace.
### v0.2 chat flow (complete, still live)
``` ```
[packages/mock-data + packages/types] [ai_service/corpus] [packages/mock-data + packages/types] [ai_service/corpus]
│ (TS, web surfaces) │ (Python, agent inputs) │ (TS, web surfaces) │ (Python, agent inputs)
@@ -153,9 +186,9 @@ Composite/layout/theme components (navigation shell, tables, chat panels, graph
(https://ollama.com/v1) (https://ollama.com/v1)
``` ```
- Web surfaces remain server-component-first; client components (chat, filters, graph viewer, dark mode toggle) fetch directly from ai-service over SSE (A-002: no Next.js API-route proxy in v0.2). - Web surfaces remain server-component-first; client components (chat, filters, graph viewer, dark mode toggle) fetch directly from ai-service over SSE (A-002: no Next.js API-route proxy).
- The LLM provider layer is a dumb pipe — OpenAI-compatible chunks pass through byte-identical; envelope logic (meta/done/error) lives only in the API layer (D-016). - The LLM provider layer is a dumb pipe — OpenAI-compatible chunks pass through byte-identically; envelope logic (meta/done/error) lives only in the API layer (D-016).
- Lab/Assessor/Proctor read mock scenarios from `ai_service/corpus/` — real engines are v0.3+. - The v0.2 corpus scenarios (`ai_service/corpus/`) are retained as dormant, test-only fixtures (dormant-header noted); they are no longer inputs to the live learner path.
- All automated tests use the deterministic mock provider; the cloud is for manual probes only. - All automated tests use the deterministic mock provider; the cloud is for manual probes only.
--- ---
@@ -167,7 +200,7 @@ Composite/layout/theme components (navigation shell, tables, chat panels, graph
3. **Process-trace grading engine** — deterministic feature/digest computation + rubric scoring via LLM structured output + GradeStore; calibrated against v0.2 mock corpora 3. **Process-trace grading engine** — deterministic feature/digest computation + rubric scoring via LLM structured output + GradeStore; calibrated against v0.2 mock corpora
4. **Variant task generation** — template library + seeded LLM instantiation + VariantStore + difficulty normalization anchors 4. **Variant task generation** — template library + seeded LLM instantiation + VariantStore + difficulty normalization anchors
5. **Oral / voice defense** — VoiceProvider protocol + STT/TTS + mock + browser fallback + Examiner agent + transcript/integrity-signal capture 5. **Oral / voice defense** — VoiceProvider protocol + STT/TTS + mock + browser fallback + Examiner agent + transcript/integrity-signal capture
6. **Agent re-grounding + learner surface integration** — Lab/Assessor/Proctor consume real telemetry/grades/defense signals; learner sandbox mockup → real in-browser xterm.js build/run; assessment mockup → live defense + live grading 6. **Agent re-grounding + learner surface integration** — Lab/Assessor/Proctor consume real telemetry/grades/defense signals; learner sandbox mockup → real in-browser build/run (Run/Test buttons executing in a namespace sandbox, read-only exec-output panel — no interactive shell, CUT-2/G-8); assessment mockup → live defense + live grading
--- ---
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"phase": 6, "phase": 7,
"stage": "complete", "stage": "audit",
"milestone": "v0.3", "milestone": "v0.3",
"phase_role": "execution", "phase_role": "final",
"attempts": 0, "attempts": 0,
"updated_at": "2026-09-12T18:31:23Z" "updated_at": "2026-09-12T20:55:00Z"
} }
+3 -5
View File
@@ -2,7 +2,7 @@
## Persona Roster ## Persona Roster
> **v0.3 update (RESEARCH, lead-developer assessment):** backend-engineer territory extended to the new engine modules (telemetry/grading/variants persistence + APIs). New phase-relevant custom personas added: **sandbox-engineer** (Linux-namespace isolation infra) and **voice-engineer** (STT/TTS + Examiner agent audio pipeline). ai-engineer re-scoped to LLM/agents/prompts + grading/variant/voice *model-facing* logic. **security-auditor stays inactive** (KYC deferred per founder directive). frontend-engineer gains real-sandbox (xterm.js), live-telemetry, and live-defense surfaces. > **v0.3 update (RESEARCH, lead-developer assessment):** backend-engineer territory extended to the new engine modules (telemetry/grading/variants persistence + APIs). New phase-relevant custom personas added: **sandbox-engineer** (Linux-namespace isolation infra) and **voice-engineer** (STT/TTS + Examiner agent audio pipeline). ai-engineer re-scoped to LLM/agents/prompts + grading/variant/voice *model-facing* logic. **security-auditor stays inactive** (KYC deferred per founder directive). frontend-engineer gains real-sandbox (read-only exec-output terminal frame, CUT-2/G-8 — interactive xterm relay is v0.4), live-telemetry, and live-defense surfaces.
### lead-developer ### lead-developer
```yaml ```yaml
@@ -31,7 +31,7 @@ territory:
```yaml ```yaml
active: true active: true
phase_specific: false phase_specific: false
reason: Phase 6 real-engine learner-surface integration — xterm.js in-browser terminal, file-tree/run/test controls, live telemetry panels, live voice defense UI, live grading display. Owns all page components, layouts, surface-specific UI. reason: Phase 6 real-engine learner-surface integration — read-only exec-output terminal frame (CUT-2, no interactive shell), file-tree/run/test controls, live telemetry panels, live voice defense UI, live grading display. Owns all page components, layouts, surface-specific UI.
domain: frontend domain: frontend
frameworks: frameworks:
- react - react
@@ -40,15 +40,13 @@ frameworks:
- lucide-react - lucide-react
- recharts - recharts
- react-flow - react-flow
- "@xterm/xterm"
- "@xterm/addon-fit"
constraints: constraints:
- component-first - component-first
- server-components-default - server-components-default
- minimal-client-js - minimal-client-js
- sse-client-buffering (buffer bytes, split frames on \n\n, join data: lines) - sse-client-buffering (buffer bytes, split frames on \n\n, join data: lines)
- abortcontroller-cleanup (idempotent abort in effect cleanup) - abortcontroller-cleanup (idempotent abort in effect cleanup)
- websocket-lifecycle (typed messages, reconnect backoff, cleanup) - fetch-lifecycle (typed engine-client calls, retry/teardown, cleanup)
- mediarecorder-permission-ux (mic consent, graceful no-mic fallback) - mediarecorder-permission-ux (mic consent, graceful no-mic fallback)
- responsive-all-breakpoints - responsive-all-breakpoints
- dark-mode-support - dark-mode-support
+2 -2
View File
@@ -46,7 +46,7 @@ All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See
|---|-----------|------------|-------------| |---|-----------|------------|-------------|
| A-101 | Sandbox isolation technology? | **`unshare` user+mount+pid+net namespace subprocess isolation** per sandbox (probe-verified: in-ns uid=0, network fully isolated with 0 interfaces, writes land in an isolated bind-mounted workdir; proc-remount is not permitted in this context but is not required). No Docker/Podman/VMs — none present on the box; no sudo. A `SandboxBackend` protocol keeps a future containerd swap possible. Falls back further to a plain chroot-free subprocess with a cwd-jail if userns ever unavailable (tested path is userns). | 0.8 | | A-101 | Sandbox isolation technology? | **`unshare` user+mount+pid+net namespace subprocess isolation** per sandbox (probe-verified: in-ns uid=0, network fully isolated with 0 interfaces, writes land in an isolated bind-mounted workdir; proc-remount is not permitted in this context but is not required). No Docker/Podman/VMs — none present on the box; no sudo. A `SandboxBackend` protocol keeps a future containerd swap possible. Falls back further to a plain chroot-free subprocess with a cwd-jail if userns ever unavailable (tested path is userns). | 0.8 |
| A-102 | Sandbox scope in v0.3? | **Coding IDE only** (web terminal + file tree + run/test). The "design tool" and "simulation" environments specified in REQ-F-021 are deferred to v0.4 — a single real build environment is enough to prove the credential pipeline end-to-end (telemetry → trace → grade → defense). | 0.75 | | A-102 | Sandbox scope in v0.3? | **Coding IDE only** (web terminal + file tree + run/test). The "design tool" and "simulation" environments specified in REQ-F-021 are deferred to v0.4 — a single real build environment is enough to prove the credential pipeline end-to-end (telemetry → trace → grade → defense). | 0.75 |
| A-103 | Live in-browser build UX? | **WebSocket xterm.js terminal** attached to the bwrap sandbox shell + HTTP file-tree/CRUD + run/test buttons. No full Monaco LSP in v0.3 — a code editor with syntax highlight (existing) + real shell is sufficient and far cheaper. | 0.72 | | A-103 | Live in-browser build UX? | **Run/Test buttons executing in the namespace sandbox + HTTP file-tree/CRUD + read-only exec-output panel** (CUT-2/G-8 — the interactive xterm.js shell relay is deferred to v0.4; `@xterm/*` is not a v0.3 dependency). No full Monaco LSP in v0.3 — a code editor with syntax highlight (existing) is sufficient and far cheaper. | 0.72 |
| A-104 | Telemetry transport? | **WebSocket** from sandbox to a new ingestion endpoint on ai-service for live events; **SQLite-backed** ordered event log (`ai_service/telemetry/`) gives durability + at-least-once delivery + replay. Events carry monotonic `seq` per (learner,task) so gaps are detectable. | 0.8 | | A-104 | Telemetry transport? | **WebSocket** from sandbox to a new ingestion endpoint on ai-service for live events; **SQLite-backed** ordered event log (`ai_service/telemetry/`) gives durability + at-least-once delivery + replay. Events carry monotonic `seq` per (learner,task) so gaps are detectable. | 0.8 |
| A-105 | Where do traces live? | **SQLite** (`ai_service` data dir), introducing the first real persistence. SQLModel/SQLAlchemy for typed access. Chosen over Postgres because solo-founder + single box + low write volume; the `TraceStore` protocol is Postgres-migration-ready like SessionStore was. | 0.75 | | A-105 | Where do traces live? | **SQLite** (`ai_service` data dir), introducing the first real persistence. SQLModel/SQLAlchemy for typed access. Chosen over Postgres because solo-founder + single box + low write volume; the `TraceStore` protocol is Postgres-migration-ready like SessionStore was. | 0.75 |
| A-106 | Process-trace grading model? | **LLM-based grader**: structure the trace into a compact timeline digest (command categories, error/fix cycles, idle gaps, test passes) → Assessor-style rubric prompt → structured score via existing D-020 JSON defense. Deterministic features (test pass/fail, edit count) computed in code, not left to the LLM. | 0.7 | | A-106 | Process-trace grading model? | **LLM-based grader**: structure the trace into a compact timeline digest (command categories, error/fix cycles, idle gaps, test passes) → Assessor-style rubric prompt → structured score via existing D-020 JSON defense. Deterministic features (test pass/fail, edit count) computed in code, not left to the LLM. | 0.7 |
@@ -54,7 +54,7 @@ All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See
| A-108 | Voice defense — STT/TTS providers? | **Provider-agnostic, mock-first like the LLM layer (D-014).** Real path: browser `MediaRecorder` → audio to ai-service → **OpenAI-compatible `/audio/transcriptions`** (Whisper STT) and **`/audio/speech`** (TTS) against ollama-cloud or a compatible endpoint; fallbacks: browser `SpeechRecognition`/`speechSynthesis` when no server keys. `VoiceProvider` protocol + deterministic mock (returns canned transcript) so tests never call a voice API. | 0.62 | | A-108 | Voice defense — STT/TTS providers? | **Provider-agnostic, mock-first like the LLM layer (D-014).** Real path: browser `MediaRecorder` → audio to ai-service → **OpenAI-compatible `/audio/transcriptions`** (Whisper STT) and **`/audio/speech`** (TTS) against ollama-cloud or a compatible endpoint; fallbacks: browser `SpeechRecognition`/`speechSynthesis` when no server keys. `VoiceProvider` protocol + deterministic mock (returns canned transcript) so tests never call a voice API. | 0.62 |
| A-109 | Defense dialogue shape? | Reuse BaseAgent: an `Examiner` agent (seventh agent) streams examiner questions over the existing SSE pipeline; integrity signals (long pauses, off-scope answers, reading-from-notes cadence) emitted alongside the transcript to Proctor. | 0.8 | | A-109 | Defense dialogue shape? | Reuse BaseAgent: an `Examiner` agent (seventh agent) streams examiner questions over the existing SSE pipeline; integrity signals (long pauses, off-scope answers, reading-from-notes cadence) emitted alongside the transcript to Proctor. | 0.8 |
| A-110 | KYC / age-gating in v0.3? | **Deferred per founder directive.** No real identity backend. Age-gating stays the v0.1 visual flow mockup. Personas omit a security-engineer; security review via verifier + Phase 7 secrets-hygiene checklist. **Abuse control is NOT deferred with KYC (G-5):** v0.3 ships per-learner sandbox caps (`AI_SANDBOX_MAX_PER_LEARNER`), a global create-rate cap, and a server-side `learner_id` allowlist (`AI_LEARNER_ALLOWLIST`) so the unauthenticated surface cannot exhaust shared NPROC/disk. Documented in the release note. | 0.98 | | A-110 | KYC / age-gating in v0.3? | **Deferred per founder directive.** No real identity backend. Age-gating stays the v0.1 visual flow mockup. Personas omit a security-engineer; security review via verifier + Phase 7 secrets-hygiene checklist. **Abuse control is NOT deferred with KYC (G-5):** v0.3 ships per-learner sandbox caps (`AI_SANDBOX_MAX_PER_LEARNER`), a global create-rate cap, and a server-side `learner_id` allowlist (`AI_LEARNER_ALLOWLIST`) so the unauthenticated surface cannot exhaust shared NPROC/disk. Documented in the release note. | 0.98 |
| A-111 | New services vs extend ai-service? | **Extend ai-service**, don't fork new Python apps. Telemetry ingestion, trace grading, variant generation, voice, and sandbox orchestration all live as new modules in `apps/ai-service` (they share the LLM provider pool + config + session infra). Only the in-sandbox capture agent is a separate tiny Python process shipped into the bwrap environment. | 0.82 | | A-111 | New services vs extend ai-service? | **Extend ai-service**, don't fork new Python apps. Telemetry ingestion, trace grading, variant generation, voice, and sandbox orchestration all live as new modules in `apps/ai-service` (they share the LLM provider pool + config + session infra). Only the in-sandbox capture agent is a separate tiny Python process shipped into the namespace sandbox. | 0.82 |
| A-112 | Sandbox on a single dev/school box — capacity? | v0.3 targets **15 concurrent sandboxes** (founder + pilot learners). No horizontal scaling, no queue. Concurrency guard returns 503 when full. Scaling is post-MVP. | 0.8 | | A-112 | Sandbox on a single dev/school box — capacity? | v0.3 targets **15 concurrent sandboxes** (founder + pilot learners). No horizontal scaling, no queue. Concurrency guard returns 503 when full. Scaling is post-MVP. | 0.8 |
## Clarified Assumptions (v0.2 CLARIFY stage, full autonomy — auto-resolved) ## Clarified Assumptions (v0.2 CLARIFY stage, full autonomy — auto-resolved)
+27 -27
View File
@@ -65,58 +65,58 @@
| ID | Description | Priority | Phase | Status | | ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------| |----|-------------|----------|-------|--------|
| REQ-001 | Monorepo scaffolding: pnpm workspaces, turborepo, Next.js app, TypeScript config, ESLint, Prettier | critical | 1 | complete | | REQ-001 | Monorepo scaffolding: pnpm workspaces, turborepo, Next.js app, TypeScript config, ESLint, Prettier | critical | 1 | complete |
| REQ-002 | Shared component library: design tokens, Button, Input, Card, Badge, Avatar, Dialog, Navigation, Table, Tabs, Progress, Tooltip, Skeleton, Toast | critical | 1 | pending | | REQ-002 | Shared component library: design tokens, Button, Input, Card, Badge, Avatar, Dialog, Navigation, Table, Tabs, Progress, Tooltip, Skeleton, Toast | critical | 1 | complete |
| REQ-003 | Mock data layer: typed mock data for competency stacks, job listings, candidate profiles, employers, learner progress | critical | 1 | pending | | REQ-003 | Mock data layer: typed mock data for competency stacks, job listings, candidate profiles, employers, learner progress | critical | 1 | complete |
| REQ-004 | Routing and navigation: App Router route groups for (learner), (marketplace), (employer), (admin); shared layout components; cross-surface navigation | critical | 1 | pending | | REQ-004 | Routing and navigation: App Router route groups for (learner), (marketplace), (employer), (admin); shared layout components; cross-surface navigation | critical | 1 | complete |
| REQ-005 | Responsive layout system: mobile, tablet, desktop breakpoints; container components; grid system | high | 1 | pending | | REQ-005 | Responsive layout system: mobile, tablet, desktop breakpoints; container components; grid system | high | 1 | complete |
### Learner Surface ### Learner Surface
| ID | Description | Priority | Phase | Status | | ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------| |----|-------------|----------|-------|--------|
| REQ-006 | Landing page: hero, value proposition, program highlights, how-it-works (Byte→Build→Demonstrate→Defend), testimonials mockup, CTA to program catalog | critical | 2 | pending | | REQ-006 | Landing page: hero, value proposition, program highlights, how-it-works (Byte→Build→Demonstrate→Defend), testimonials mockup, CTA to program catalog | critical | 2 | complete |
| REQ-007 | Program catalog: grid of competency stacks (AI Orchestration Engineer, AI Safety & Governance Lead, Human-AI Product Designer, AI-Augmented Field Operator, Computational Sciences Practitioner); stack cards with role descriptions | critical | 2 | pending | | REQ-007 | Program catalog: grid of competency stacks (AI Orchestration Engineer, AI Safety & Governance Lead, Human-AI Product Designer, AI-Augmented Field Operator, Computational Sciences Practitioner); stack cards with role descriptions | critical | 2 | complete |
| REQ-008 | Competency stack view: selected stack detail with 12-18 competencies listed, progress indicators, microcredential badges, mastery status | critical | 2 | pending | | REQ-008 | Competency stack view: selected stack detail with 12-18 competencies listed, progress indicators, microcredential badges, mastery status | critical | 2 | complete |
| REQ-009 | Learner dashboard: active competencies, progress graph, recent artifacts, upcoming defenses, AI tutor chat mockup, milestone tracker | critical | 2 | pending | | REQ-009 | Learner dashboard: active competencies, progress graph, recent artifacts, upcoming defenses, AI tutor chat mockup, milestone tracker | critical | 2 | complete |
| REQ-010 | Byte tutorial viewer: 3-7 minute micro-tutorial layout with concept panel, worked example panel, code/design/simulation viewer mockup | high | 2 | pending | | REQ-010 | Byte tutorial viewer: 3-7 minute micro-tutorial layout with concept panel, worked example panel, code/design/simulation viewer mockup | high | 2 | complete |
| REQ-011 | Build sandbox mockup: sandboxed IDE/design tool/simulation UI mockup with toolbar, file explorer, editor area, telemetry sidebar (process capture indicators) | high | 2 | pending | | REQ-011 | Build sandbox mockup: sandboxed IDE/design tool/simulation UI mockup with toolbar, file explorer, editor area, telemetry sidebar (process capture indicators) | high | 2 | complete |
| REQ-012 | Assessment/defense mockup: rubric display, AI reviewer panel, oral defense interface (voice/mic mockup), process trace timeline, artifact viewer | high | 2 | pending | | REQ-012 | Assessment/defense mockup: rubric display, AI reviewer panel, oral defense interface (voice/mic mockup), process trace timeline, artifact viewer | high | 2 | complete |
### Marketplace Surface ### Marketplace Surface
| ID | Description | Priority | Phase | Status | | ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------| |----|-------------|----------|-------|--------|
| REQ-013 | Job board listing: searchable grid of AI-era job listings, filter sidebar (skills, seniority, location, salary), result cards with match score | critical | 3 | pending | | REQ-013 | Job board listing: searchable grid of AI-era job listings, filter sidebar (skills, seniority, location, salary), result cards with match score | critical | 3 | complete |
| REQ-014 | Job detail page: full job description, required competencies, employer info, application CTA, related jobs, AI-matched skills breakdown | critical | 3 | pending | | REQ-014 | Job detail page: full job description, required competencies, employer info, application CTA, related jobs, AI-matched skills breakdown | critical | 3 | complete |
| REQ-015 | Employer profile: company overview, logo, description, social links, open positions, company culture mockup | high | 3 | pending | | REQ-015 | Employer profile: company overview, logo, description, social links, open positions, company culture mockup | high | 3 | complete |
| REQ-016 | Search/filter UI: semantic search bar, skill tags, category filters, seniority filter, remote/on-site toggle, saved searches mockup | critical | 3 | pending | | REQ-016 | Search/filter UI: semantic search bar, skill tags, category filters, seniority filter, remote/on-site toggle, saved searches mockup | critical | 3 | complete |
| REQ-017 | Pricing page: job posting packages (single, bundle, enterprise), talent access plans, subscription tiers, feature comparison table | high | 3 | pending | | REQ-017 | Pricing page: job posting packages (single, bundle, enterprise), talent access plans, subscription tiers, feature comparison table | high | 3 | complete |
### Employer Dashboard ### Employer Dashboard
| ID | Description | Priority | Phase | Status | | ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------| |----|-------------|----------|-------|--------|
| REQ-018 | Employer dashboard overview: active postings, applicant pipeline, talent matches, analytics mockup (charts, placement stats) | critical | 4 | pending | | REQ-018 | Employer dashboard overview: active postings, applicant pipeline, talent matches, analytics mockup (charts, placement stats) | critical | 4 | complete |
| REQ-019 | Talent search: searchable candidate database with AI-matched filters, candidate cards showing competency stack, microcredentials, artifact count, defense score | critical | 4 | pending | | REQ-019 | Talent search: searchable candidate database with AI-matched filters, candidate cards showing competency stack, microcredentials, artifact count, defense score | critical | 4 | complete |
| REQ-020 | Candidate profile view: full candidate profile with artifact gallery, process trace summary, oral defense transcripts, competency graph, microcredential verification | high | 4 | pending | | REQ-020 | Candidate profile view: full candidate profile with artifact gallery, process trace summary, oral defense transcripts, competency graph, microcredential verification | high | 4 | complete |
| REQ-021 | Posting management: create/edit/delete job postings, posting status tracking, applicant list per posting, interview pipeline mockup | high | 4 | pending | | REQ-021 | Posting management: create/edit/delete job postings, posting status tracking, applicant list per posting, interview pipeline mockup | high | 4 | complete |
### Admin Surface ### Admin Surface
| ID | Description | Priority | Phase | Status | | ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------| |----|-------------|----------|-------|--------|
| REQ-022 | Admin overview: platform metrics (learners, employers, placements, completion rate, NPS), recent activity feed, system health mockup | critical | 5 | pending | | REQ-022 | Admin overview: platform metrics (learners, employers, placements, completion rate, NPS), recent activity feed, system health mockup | critical | 5 | complete |
| REQ-023 | Learner management: searchable learner table, learner detail view, progress tracking, competency completion status, credential issuance log | high | 5 | pending | | REQ-023 | Learner management: searchable learner table, learner detail view, progress tracking, competency completion status, credential issuance log | high | 5 | complete |
| REQ-024 | Competency graph viewer: interactive visualization of competency stacks and their relationships, node/edge graph using react-flow, stack details on node click | high | 5 | pending | | REQ-024 | Competency graph viewer: interactive visualization of competency stacks and their relationships, node/edge graph using react-flow, stack details on node click | high | 5 | complete |
| REQ-025 | Marketplace moderation: job posting review queue, employer verification queue, flagged content, content moderation tools mockup | high | 5 | pending | | REQ-025 | Marketplace moderation: job posting review queue, employer verification queue, flagged content, content moderation tools mockup | high | 5 | complete |
### Polish & Integration ### Polish & Integration
| ID | Description | Priority | Phase | Status | | ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------| |----|-------------|----------|-------|--------|
| REQ-026 | Cross-surface navigation: role switcher (learner/employer/admin), breadcrumbs, consistent header/footer across all surfaces | critical | 6 | pending | | REQ-026 | Cross-surface navigation: role switcher (learner/employer/admin), breadcrumbs, consistent header/footer across all surfaces | critical | 6 | complete |
| REQ-027 | Visual consistency audit: typography scale, color palette, spacing system, dark mode toggle, accessibility baseline (WCAG AA contrast) | high | 6 | pending | | REQ-027 | Visual consistency audit: typography scale, color palette, spacing system, dark mode toggle, accessibility baseline (WCAG AA contrast) | high | 6 | complete |
| REQ-028 | Component library finalization: Storybook setup, component documentation, prop tables, usage examples | medium | 6 | pending | | REQ-028 | Component library finalization: Storybook setup, component documentation, prop tables, usage examples | medium | 6 | complete |
--- ---
+5 -5
View File
@@ -18,14 +18,14 @@
| # | Name | Status | Depends On | Requirements | Success Criteria | | # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------| |---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | in-progress | — | — | Specification, clarify, research, plan complete; .ciagent/ files updated for v0.3 | | 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan complete; .ciagent/ files updated for v0.3 |
| 1 | Sandbox fabric | complete | 0 | REQ-3-001, REQ-3-002 | Isolated per-learner sandbox environments provisioned (IDE / design / simulation); lifecycle API (create/destroy/snapshot); resource limits enforced; no cross-tenant access | | 1 | Sandbox fabric | complete | 0 | REQ-3-001, REQ-3-002 | Isolated per-learner sandbox environments provisioned (IDE / design / simulation); lifecycle API (create/destroy/snapshot); resource limits enforced; no cross-tenant access |
| 2 | Live build telemetry | complete | 1 | REQ-3-003 | In-environment capture of process events (commands, file diffs, run/test results, keystroke-level activity) streamed to ai-service; reliable transport; per-learner trace persistence | | 2 | Live build telemetry | complete | 1 | REQ-3-003 | In-environment capture of process events (commands, file diffs, run/test results, keystroke-level activity) streamed to ai-service; reliable transport; per-learner trace persistence |
| 3 | Process-trace grading engine | complete | 2 | REQ-3-004 | Grades artifacts from their full process traces (not just final output); emits structured rubric-aligned scores; feeds Assessor real inputs | | 3 | Process-trace grading engine | complete | 2 | REQ-3-004 | Grades artifacts from their full process traces (not just final output); emits structured rubric-aligned scores; feeds Assessor real inputs |
| 4 | Variant task generation | complete | 1 | REQ-3-005 | Per-learner task variants generated so no two learners receive identical prompts; variant seed recorded for grading fairness | | 4 | Variant task generation | complete | 1 | REQ-3-005 | Per-learner task variants generated so no two learners receive identical prompts; variant seed recorded for grading fairness |
| 5 | Oral / voice defense | complete | 3 | REQ-3-006 | AI examiner conducts spoken defense of submitted work; STT → dialogue → TTS; transcript + integrity signals captured; feeds Proctor/Mentor | | 5 | Oral / voice defense | complete | 3 | REQ-3-006 | AI examiner conducts spoken defense of submitted work; STT → dialogue → TTS; transcript + integrity signals captured; feeds Proctor/Mentor |
| 6 | Agent re-grounding + learner surface integration | complete | 2,3,4,5 | REQ-3-007, REQ-3-008 | Lab/Assessor/Proctor consume real engine inputs; v0.1 sandbox + assessment mockups wired to real engines (in-browser build/run, live telemetry, live defense) | | 6 | Agent re-grounding + learner surface integration | complete | 2,3,4,5 | REQ-3-007, REQ-3-008 | Lab/Assessor/Proctor consume real engine inputs; v0.1 sandbox + assessment mockups wired to real engines (in-browser build/run, live telemetry, live defense) |
| 7 | Final review + ship | pending | 6 | — | Code review clean; audit passes; milestone tagged (v0.2.x final patch); release created on Gitea | | 7 | Final review + ship | in-progress | 6 | — | Code review clean; audit passes; milestone tagged (v0.2.x final patch); release created on Gitea |
--- ---
@@ -51,9 +51,9 @@
**Requirements:** REQ-3-001, REQ-3-002 **Requirements:** REQ-3-001, REQ-3-002
**Key deliverables:** **Key deliverables:**
- Sandbox orchestrator service: create/list/destroy/snapshot sandbox instances (IDE, design tool, simulation) - Sandbox orchestrator service: create/list/destroy/snapshot sandbox instances (coding IDE design tool and simulation environments deferred to v0.4 per D-025)
- Isolation boundary: per-learner containerization or VM-grade isolation; no cross-tenant filesystem/network access - Isolation boundary: per-learner Linux user/mount/pid/net namespace subprocess isolation (`unshare`, D-024); no cross-tenant filesystem/network access
- Resource limits: CPU/memory/disk/time quotas per sandbox - Resource limits: CPU/memory/single-file-size quotas (rlimits) + wall-clock timeout reaper + best-effort workdir-size sweep; per-sandbox pids + hard disk quota accepted as v0.3 gaps (G-1/G-2)
- Sandbox lifecycle API consumed by ai-service and the web learner surface - Sandbox lifecycle API consumed by ai-service and the web learner surface
**Success criteria:** **Success criteria:**
-4
View File
@@ -7,7 +7,6 @@ No session chat — each request is one live-trace read.
""" """
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import TYPE_CHECKING
from ..config import Settings from ..config import Settings
from ..corpus.learner_context import LearnerContext, get_learner_context from ..corpus.learner_context import LearnerContext, get_learner_context
@@ -16,9 +15,6 @@ from ..llm.base import LLMProvider
from ..prompts.lab import SYSTEM_PROMPT, render_context, render_digest_timeline from ..prompts.lab import SYSTEM_PROMPT, render_context, render_digest_timeline
from .base import BaseAgent from .base import BaseAgent
if TYPE_CHECKING: # pragma: no cover
pass
class LabAgent(BaseAgent): class LabAgent(BaseAgent):
name = "lab" name = "lab"
@@ -21,7 +21,6 @@ from __future__ import annotations
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -42,9 +41,6 @@ from .deps import (
get_voice_store, get_voice_store,
) )
if TYPE_CHECKING: # pragma: no cover
pass
router = APIRouter(prefix="/v1/defense", tags=["defense"]) router = APIRouter(prefix="/v1/defense", tags=["defense"])
#: A-109: learner turns slower than this are flagged as long pauses (ms). #: A-109: learner turns slower than this are flagged as long pauses (ms).
+30 -4
View File
@@ -268,6 +268,26 @@ def _safe_rel_path(raw: str) -> Path:
return candidate return candidate
def _resolve_in_workspace(workspace: Path, rel: Path) -> Path:
"""Resolve `rel` under `workspace`, refusing symlink escapes (P7).
The lexical check in `_safe_rel_path` cannot see symlinks: an exec can
plant `ln -s /etc target` in the workspace and a follow-up read/write
would follow it OUT of the bind. Resolve with the workspace as the
anchor (strict: a symlink chain escaping raises) and confirm the
normalized target still sits inside the workspace — defense in depth
for both read_file and write_file.
"""
try:
target = (workspace / rel).resolve(strict=False)
target.relative_to(workspace.resolve(strict=False))
except ValueError:
raise HTTPException(
status_code=422, detail=f"path escapes the workspace: {rel.as_posix()!r}"
) from None
return target
@router.get("/{sandbox_id}/files") @router.get("/{sandbox_id}/files")
async def list_files( async def list_files(
sandbox_id: str, sandbox_id: str,
@@ -286,9 +306,12 @@ async def read_file(
path: str, path: str,
manager: SandboxManager = Depends(get_sandbox_manager), manager: SandboxManager = Depends(get_sandbox_manager),
) -> dict: ) -> dict:
workspace, _ = await _workspace_dir(manager, sandbox_id) try:
workspace, _ = await _workspace_dir(manager, sandbox_id)
except SandboxNotFoundError:
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
rel = _safe_rel_path(path) rel = _safe_rel_path(path)
target = workspace / rel target = _resolve_in_workspace(workspace, rel)
if not target.is_file(): if not target.is_file():
raise HTTPException(status_code=404, detail=f"no file {path!r}") raise HTTPException(status_code=404, detail=f"no file {path!r}")
return {"path": path, "content": target.read_text(errors="replace")} return {"path": path, "content": target.read_text(errors="replace")}
@@ -301,9 +324,12 @@ async def write_file(
body: FileWriteRequest, body: FileWriteRequest,
manager: SandboxManager = Depends(get_sandbox_manager), manager: SandboxManager = Depends(get_sandbox_manager),
) -> dict: ) -> dict:
workspace, _ = await _workspace_dir(manager, sandbox_id) try:
workspace, _ = await _workspace_dir(manager, sandbox_id)
except SandboxNotFoundError:
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
rel = _safe_rel_path(body.path) rel = _safe_rel_path(body.path)
target = workspace / rel target = _resolve_in_workspace(workspace, rel)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body.content) target.write_text(body.content)
return {"path": body.path, "written": True} return {"path": body.path, "written": True}
+27 -4
View File
@@ -9,10 +9,13 @@ only wires `app.state.trace_store` / `app.state.trace_integrity` /
GET /v1/telemetry/traces/{learner_id}/{task_id} ordered trace; 404 unknown GET /v1/telemetry/traces/{learner_id}/{task_id} ordered trace; 404 unknown
GET /v1/telemetry/gaps/{learner_id}/{task_id} missing seqs ; 404 unknown GET /v1/telemetry/gaps/{learner_id}/{task_id} missing seqs ; 404 unknown
The WS route is a thin DI shell: it validates the query-param identity, The WS route is a thin DI shell: it validates the query-param identity and
pulls store/integrity/settings from `app.state`, and calls the Origin (browser pages are gated to the localhost dev origins — CORS
`telemetry_ingest_endpoint(...)` — the engine stays FastAPI-DI-free so it's middleware does not cover WS upgrades; the stdlib capture agent sends no
testable without a router and the api/ layer owns all composition. Origin and is unaffected), pulls store/integrity/settings from `app.state`,
and calls `telemetry_ingest_endpoint(...)` — the engine stays
FastAPI-DI-free so it's testable without a router and the api/ layer owns
all composition.
Unknown-trace contract: a trace is KNOWN when it has >=1 stored event OR Unknown-trace contract: a trace is KNOWN when it has >=1 stored event OR
carries an integrity flag — a flooded trace with zero stored rows still 200s carries an integrity flag — a flooded trace with zero stored rows still 200s
@@ -21,6 +24,8 @@ so Proctor/grader can read WHY it's unusable (G-4 consumes
the map so HTTP consumers never touch process internals. the map so HTTP consumers never touch process internals.
""" """
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, WebSocket from fastapi import APIRouter, Depends, HTTPException, WebSocket
from pydantic import BaseModel from pydantic import BaseModel
@@ -34,6 +39,15 @@ from .deps import get_trace_integrity, get_trace_store
router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"]) router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"])
#: Browser Origins allowed to open the ingest socket (A-008 mirror). The
#: stdlib capture agent sends NO Origin header (it is not a browser) and
#: stays allowed; a malicious page loaded in the learner's browser would
#: carry an Origin and must not be able to poison/flood the trace. CORS
#: middleware does NOT cover WebSocket upgrades, so this gate is explicit.
_ALLOWED_WS_ORIGINS = frozenset(
{"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8420"}
)
# --- WS ingest (D-026) --------------------------------------------------------- # --- WS ingest (D-026) ---------------------------------------------------------
@@ -45,6 +59,15 @@ async def telemetry_ingest_ws(websocket: WebSocket) -> None:
The engine's session + flood logic is fully typed and testable without The engine's session + flood logic is fully typed and testable without
FastAPI; this shim is the only place the two layers meet. FastAPI; this shim is the only place the two layers meet.
""" """
origin = (websocket.headers.get("origin") or "").strip()
if origin and origin not in _ALLOWED_WS_ORIGINS:
# Same-origin dev pages (Next.js on :3000, the service itself on
# :8420) pass; anything else is refused pre-accept. Non-browser
# producers (the capture agent, tests) send no Origin and pass.
await websocket.close(
code=1008, reason=f"origin {origin!r} not allowed for telemetry ingest"
)
return
query = websocket.query_params query = websocket.query_params
learner_id = query.get("learner_id", "") learner_id = query.get("learner_id", "")
task_id = query.get("task_id", "") task_id = query.get("task_id", "")
@@ -27,16 +27,11 @@ Feature semantics (conservative, deterministic):
from __future__ import annotations from __future__ import annotations
from collections import Counter from collections import Counter
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..telemetry.models import TelemetryEvent from ..telemetry.models import TelemetryEvent
if TYPE_CHECKING: # pragma: no cover - import cycle guard for type checkers
pass
_IDLE_DEFAULT_S: float = 120.0 _IDLE_DEFAULT_S: float = 120.0
_TEST_HINTS = ("test", "pytest", "vitest", "jest", "mocha", "unittest", "go test", "npm test") _TEST_HINTS = ("test", "pytest", "vitest", "jest", "mocha", "unittest", "go test", "npm test")
+7 -3
View File
@@ -59,8 +59,11 @@ class GradeRecord(SQLModel, table=True):
(learner_id, task_id) pair — the same pair as trace (learner_id, task_id) pair — the same pair as trace
identity, so a grade is keyed by the exact trace it identity, so a grade is keyed by the exact trace it
was computed from. was computed from.
variant_seed — task-variant seed; None until P4 (D-029). v0.3 variant_seed — task-variant seed (D-029); None when the graded task
grading is variant-blind. is not variant-derived. Since Phase 4 the engine
stamps the graded variant's seed here (MH#4) and the
template's difficulty anchors ship to the grader
prompt — this column is the audit join for that.
digest — compact deterministic trace digest (D-028) that fed digest — compact deterministic trace digest (D-028) that fed
the rubric prompt; persisted for auditability so the the rubric prompt; persisted for auditability so the
LLM's input stays reproducible. LLM's input stays reproducible.
@@ -85,7 +88,8 @@ class GradeRecord(SQLModel, table=True):
learner_id: str = Field(primary_key=True) learner_id: str = Field(primary_key=True)
task_id: str = Field(primary_key=True) task_id: str = Field(primary_key=True)
variant_seed: str | None = Field(default=None) # null until P4 (D-029) # None only for non-variant tasks (MH#4 stamps variant seeds since P4).
variant_seed: str | None = Field(default=None)
# JSON columns: stored as TEXT on SQLite, native JSONB on Postgres (D-027). # JSON columns: stored as TEXT on SQLite, native JSONB on Postgres (D-027).
digest: dict[str, Any] = Field(default_factory=dict, sa_type=JSON) digest: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
scores: dict[str, Any] = Field(default_factory=dict, sa_type=JSON) scores: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
+6 -2
View File
@@ -171,11 +171,15 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan) app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan)
# A-008: localhost-only CORS, no credentials # A-008: localhost-only CORS, no credentials. PUT is CONTRACT, not
# trivia: the learner build surface writes workspace files with PUT
# (engine-client writeFile) — v0.3 initially shipped without it and
# every cross-origin Save failed preflight (caught in P7 review;
# tests/api/test_cors.py pins the policy now).
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"], allow_headers=["Content-Type"],
allow_credentials=False, allow_credentials=False,
) )
+13 -7
View File
@@ -143,11 +143,9 @@ class IngestSession:
- `telemetry_max_events_per_task` is consulted at connect and re-checked - `telemetry_max_events_per_task` is consulted at connect and re-checked
per append against the DURABLE row count (cap compares against stored per append against the DURABLE row count (cap compares against stored
events, so a skipped-ahead seq cannot burn budget that was never sent). events, so a skipped-ahead seq cannot burn budget that was never sent).
Durable count via `len(get_trace(...))` reads the trace per append — Durable count via `TraceStore.count()` (COUNT(*)) — a single aggregate
O(trace) per event; v0.3 pilot sizing caps traces at 50k rows, WAL keeps per append, never materializing trace rows (the pre-P7 code read
the writer unblocked (a-3), and the capture agent's emission rate is `len(get_trace(...))` which was O(trace) per event / O(n²) per session).
human-scale. If profiling shows the count query hot, swap to COUNT(*)
without changing the contract.
""" """
def __init__( def __init__(
@@ -239,6 +237,12 @@ class IngestSession:
self._queue.put_nowait(frame) self._queue.put_nowait(frame)
except asyncio.QueueFull: except asyncio.QueueFull:
# Bounded queue — overflow is a flood, never drop-oldest. # Bounded queue — overflow is a flood, never drop-oldest.
# _trigger_flood closes the socket; fall through to the
# tail so the disconnect sentinel is still enqueued — the
# drainer is never left parked on an empty queue after a
# flood (P7 review: the pre-fix code `return`ed from the
# QueueFull branch WITHOUT the sentinel, leaking the
# session task set — one per flooded trace).
await self._trigger_flood("queue_overflow") await self._trigger_flood("queue_overflow")
return return
except WebSocketDisconnect: except WebSocketDisconnect:
@@ -354,8 +358,10 @@ class IngestSession:
def _flood_breached(self) -> bool: def _flood_breached(self) -> bool:
"""True when this append would exceed the per-trace event budget.""" """True when this append would exceed the per-trace event budget."""
# Durable count (NOT latest_seq+1 — a skipped-ahead seq must not burn # Durable count (NOT latest_seq+1 — a skipped-ahead seq must not burn
# un-sent events' budget) plus this connection's in-flight rows. # un-sent events' budget) via COUNT(*): never materialize the trace
durable = len(self._store.get_trace(self.learner_id, self.task_id)) # per append (P7 review — the old len(get_trace(...)) built every row
# object per event, O(trace) per append / O(n²) per session).
durable = self._store.count(learner_id=self.learner_id, task_id=self.task_id)
return durable >= self._max_events return durable >= self._max_events
async def _check_gap(self, incoming_seq: int) -> None: async def _check_gap(self, incoming_seq: int) -> None:
@@ -68,6 +68,13 @@ class TraceStore(Protocol):
"""Highest stored seq for the trace; -1 when no events exist.""" """Highest stored seq for the trace; -1 when no events exist."""
... ...
def count(self, learner_id: str, task_id: str) -> int:
"""Number of stored events for the trace (COUNT(*), never
materializes rows — the ingest cap consults this per append, so
an O(trace) implementation would make ingest O(n²) per session).
"""
...
def list_tasks(self, learner_id: str) -> list[str]: def list_tasks(self, learner_id: str) -> list[str]:
"""Distinct task_ids with at least one event for the learner.""" """Distinct task_ids with at least one event for the learner."""
... ...
@@ -183,6 +190,19 @@ class SQLiteTraceStore:
latest: Any = session.exec(stmt).one() latest: Any = session.exec(stmt).one()
return -1 if latest is None else int(latest) return -1 if latest is None else int(latest)
def count(self, learner_id: str, task_id: str) -> int:
# COUNT(*) at the DB — no row materialization. The ingest flood cap
# calls this per append (telemetry/ingest._flood_breached); the
# docstring-free body keeps it obvious what the query shape is.
with self._session() as session:
stmt = (
select(sa.func.count(TelemetryEvent.seq))
.where(TelemetryEvent.learner_id == learner_id)
.where(TelemetryEvent.task_id == task_id)
)
total: Any = session.exec(stmt).one()
return int(total or 0)
def list_tasks(self, learner_id: str) -> list[str]: def list_tasks(self, learner_id: str) -> list[str]:
with self._session() as session: with self._session() as session:
stmt = ( stmt = (
@@ -71,11 +71,11 @@ class RubricAnchors(BaseModel):
for this template, so two variants of one template are held to the for this template, so two variants of one template are held to the
same bar regardless of which slot values a learner drew. The a-5 same bar regardless of which slot values a learner drew. The a-5
envelope test (tests/variants/test_generator.py) binds variants to envelope test (tests/variants/test_generator.py) binds variants to
these bands in code. Shipping them into the grader prompt context is these bands in code, and — since Phase 4 (MH#4) — the grading engine
the P4 must-have follow-up tracked for final review: grading is ships this envelope into the grader prompt
variant-blind in the current wiring (engine.py stamps (grading/engine._anchors_context) and stamps the variant seed on the
variant_seed=None), so today the anchors gate variant fairness in GradeRecord, so the anchors gate variant fairness in BOTH tests and
tests only — not yet in the LLM prompt. the live rubric.
""" """
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
+60
View File
@@ -0,0 +1,60 @@
"""CORS policy tests (A-008, P7 review regression).
v0.3 initially shipped `allow_methods` WITHOUT "PUT" while the learner
build surface writes workspace files with PUT (engine-client writeFile)
every cross-origin Save failed preflight. These tests pin the policy so a
future method-list edit fails loudly instead of silently breaking the
headline flow.
Two-layer check:
- preflight (OPTIONS + Access-Control-Request-Method) for every method the
web client actually uses: GET/POST/PUT/DELETE;
- actual cross-origin request echoes the localhost dev origin.
Disallowed origins must NOT be granted (localhost-only, no credentials).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
ALLOWED_ORIGIN = "http://localhost:3000"
ALL_CLIENT_METHODS = ("GET", "POST", "PUT", "DELETE")
def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -> None:
for method in ALL_CLIENT_METHODS:
resp = client.options(
"/v1/sandboxes",
headers={
"Origin": ALLOWED_ORIGIN,
"Access-Control-Request-Method": method,
},
)
assert resp.status_code == 200, f"preflight {method} failed: {resp.status_code}"
assert resp.headers["access-control-allow-origin"] == ALLOWED_ORIGIN
allowed = resp.headers["access-control-allow-methods"].split(", ")
assert method in allowed, f"{method} missing from CORS methods: {allowed}"
def test_cross_origin_get_echoes_allow_origin(client: TestClient) -> None:
resp = client.get("/v1/sandboxes", headers={"Origin": ALLOWED_ORIGIN})
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
def test_unknown_origin_gets_no_cors_grant(client: TestClient) -> None:
resp = client.get("/v1/sandboxes", headers={"Origin": "https://evil.example"})
assert resp.status_code == 200 # non-CORS requests still serve
assert resp.headers.get("access-control-allow-origin") is None
def test_credentials_never_allowed(client: TestClient) -> None:
resp = client.options(
"/v1/sandboxes",
headers={
"Origin": ALLOWED_ORIGIN,
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "Content-Type",
},
)
assert resp.headers.get("access-control-allow-credentials") != "true"
@@ -406,3 +406,44 @@ class TestFilesAndExecRoutes:
"/v1/sandboxes/sbx-nope/exec", json={"cmd": ["echo", "hi"]} "/v1/sandboxes/sbx-nope/exec", json={"cmd": ["echo", "hi"]}
) )
assert resp.status_code == 404 assert resp.status_code == 404
def test_unknown_sandbox_file_routes_404_not_500(self, client):
"""P7: read/write on an unknown sandbox must 404 (SandboxNotFoundError
previously escaped _workspace_dir as an unhandled 500)."""
assert (
client.get("/v1/sandboxes/sbx-nope/files/whatever.py").status_code == 404
)
put = client.put(
"/v1/sandboxes/sbx-nope/files/whatever.py",
json={"path": "whatever.py", "content": "x"},
)
assert put.status_code == 404
def test_symlink_escape_rejected(self, client):
"""P7: an exec-planted symlink in the workspace must not let the
file routes read/write OUTSIDE the bind (lexical traversal checks
cannot see symlinks resolve + containment re-check is the gate)."""
handle = client.post(
"/v1/sandboxes", json={"learner_id": "pilot-learner"}
).json()
sbx = handle["id"]
workspace = Path(handle["workdir"]) / "workspace"
outside = workspace.parent / "secret.txt"
outside.write_text("host secret") # a host file OUTSIDE the bind
try:
(workspace / "leak.txt").symlink_to(outside)
read = client.get(f"/v1/sandboxes/{sbx}/files/leak.txt")
assert read.status_code == 422, (
f"symlink escape read must 422, got {read.status_code}: {read.text}"
)
write = client.put(
f"/v1/sandboxes/{sbx}/files/leak.txt",
json={"path": "leak.txt", "content": "pwned"},
)
assert write.status_code == 422, (
f"symlink escape write must 422, got {write.status_code}: {write.text}"
)
assert outside.read_text() == "host secret" # untouched
finally:
client.delete(f"/v1/sandboxes/{sbx}")
outside.unlink(missing_ok=True)
@@ -239,6 +239,91 @@ def test_queue_overflow_also_floods(
assert app.state.trace_integrity.reason("L-q", "T-q") == "INCOMPLETE_FLOODED" assert app.state.trace_integrity.reason("L-q", "T-q") == "INCOMPLETE_FLOODED"
@pytest.mark.asyncio
async def test_queue_overflow_flood_session_task_terminates(tmp_path, monkeypatch):
"""P7 regression: the queue-overflow flood path must not LEAK the
session coroutine. v0.3's receiver returned from its QueueFull branch
without the disconnect sentinel, so the drainer parked on an empty queue
forever and IngestSession.run() never returned one leaked
(pinger+drainer) task-set per flooded trace, unbounded over a long-lived
process. A REAL uvicorn server (TestClient teardown hides the leak) is
stopped after the flood; the session tasks must be gone shortly after.
"""
import asyncio
import contextlib
import socket as socket_mod
import uvicorn
from ai_service.main import create_app
from ai_service.telemetry.ingest import TraceIntegrityMap
from ai_service.telemetry.store import SQLiteTraceStore
monkeypatch.setattr(ingest_mod, "INBOUND_QUEUE_MAX", 1)
store = SQLiteTraceStore(db_path=tmp_path / "leak.db")
app = create_app(
Settings(
provider="mock",
db_path=tmp_path / "leak.db",
sandbox_dir=tmp_path / "sandboxes",
)
)
app.state.trace_store = store
app.state.trace_integrity = TraceIntegrityMap()
with socket_mod.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
server = uvicorn.Server(
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
)
serve_task = asyncio.get_running_loop().create_task(server.serve())
leaked = True
try:
for _ in range(100):
if server.started:
break
await asyncio.sleep(0.1)
assert server.started
import websockets
uri = (
f"ws://127.0.0.1:{port}/v1/telemetry/ingest"
f"?learner_id=L-leak&task_id=T-leak"
)
async with websockets.connect(uri) as ws:
for seq in range(64): # bound=1 → guaranteed overflow
await ws.send(_frame(seq, sandbox_id=""))
# The flood close (1008) reaches the client.
try:
await asyncio.wait_for(ws.recv(), timeout=10.0)
await asyncio.wait_for(ws.recv(), timeout=10.0)
except (websockets.exceptions.ConnectionClosed, TimeoutError, OSError):
pass
assert app.state.trace_integrity.is_incomplete("L-leak", "T-leak")
# The session's run() must have returned: no lingering nc-* tasks
# holding the socket open. Poll briefly — teardown is async.
deadline = asyncio.get_running_loop().time() + 5.0
while asyncio.get_running_loop().time() < deadline:
names = {
t.get_name()
for t in asyncio.all_tasks()
if t is not asyncio.current_task()
}
if not any("ingest" in n.lower() for n in names):
leaked = False
break
await asyncio.sleep(0.1)
finally:
server.should_exit = True
with contextlib.suppress(Exception):
await asyncio.wait_for(serve_task, timeout=10.0)
store.close()
assert not leaked, "IngestSession task set leaked after queue-overflow flood"
def test_reconnect_after_flood_cannot_resurrect_trace( def test_reconnect_after_flood_cannot_resurrect_trace(
client: TestClient, app, store: SQLiteTraceStore client: TestClient, app, store: SQLiteTraceStore
) -> None: ) -> None:
@@ -309,6 +394,32 @@ def test_missing_identity_query_params_rejected_at_handshake(
assert excinfo.value.code == 1008 assert excinfo.value.code == 1008
def test_browser_origin_not_allowed_for_ingest(client: TestClient) -> None:
"""CORS middleware does not cover WS upgrades (P7): a page loaded in the
learner's browser (any non-localhost Origin) must not be able to open
the ingest socket and poison/flood the trace. The stdlib capture agent
sends no Origin and is unaffected (see the no-origin test below)."""
with pytest.raises(WebSocketDisconnect) as excinfo:
with client.websocket_connect(
_ingest_url(), headers={"Origin": "https://evil.example"}
):
pass
assert excinfo.value.code == 1008
def test_dev_origin_and_no_origin_both_allowed(client: TestClient) -> None:
"""The same-origin dev page (Next.js :3000) opens fine, and so does the
capture-agent path (no Origin header at all)."""
for headers in ({"Origin": "http://localhost:3000"}, {}):
with client.websocket_connect(_ingest_url(), headers=headers) as ws:
ws.send_text(_frame(0))
body = client.get(f"/v1/telemetry/traces/{LEARNER}/{TASK}").json()
assert [e["seq"] for e in body["events"]] == [0]
# Unique trace per iteration would collide on (LEARNER, TASK) PK —
# seq 0 re-sent is deduped, so one row is the invariant either way.
assert len(body["events"]) == 1
# -- keepalive --------------------------------------------------------------------- # -- keepalive ---------------------------------------------------------------------
@@ -499,7 +499,22 @@ class TestReconnectFlush:
test_agent = _make_agent(tmp_path, held_link.url) test_agent = _make_agent(tmp_path, held_link.url)
test_agent.start() test_agent.start()
assert test_agent.wait_connected(5) assert test_agent.wait_connected(5)
test_agent.run_command("echo first") first = test_agent.run_command("echo first")
# P7 de-flake: wait for the pre-kill burst to be OBSERVED at the
# server before severing (the sibling TestSpoolOnDisconnect test
# already had this discipline). Killing mid-burst exercises a
# DIFFERENT, documented limitation — the agent's one-line replay
# margin cannot cover a multi-frame TCP in-flight window (an
# ACK-protocol gap tracked for v0.4) — which made this test
# nondeterministic under load instead of testing what its name
# says: the reconnect flush of OFFLINE-spooled events.
assert _wait_until(
lambda: any(
e["kind"] == "run_result" and e["seq"] == first["seq"]
for e in fake_server.events
),
timeout_s=10.0,
), "pre-kill burst never reached the server"
held_link.kill() # outage begins: no traffic, no reconnect possible held_link.kill() # outage begins: no traffic, no reconnect possible
assert test_agent.wait_disconnected(5) assert test_agent.wait_disconnected(5)
@@ -129,6 +129,21 @@ def test_latest_seq(store: SQLiteTraceStore) -> None:
assert store.latest_seq("learner-1", "task-2") == -1 assert store.latest_seq("learner-1", "task-2") == -1
def test_count_is_durable_row_count_not_latest_seq(store: SQLiteTraceStore) -> None:
"""count() backs the ingest flood cap (P7): it must reflect stored ROWS
(a skipped-ahead seq must not burn un-sent budget) and stay O(1)-ish
(COUNT(*), never materialize the trace per append)."""
assert store.count("learner-1", "task-1") == 0
store.append(make_event(0))
store.append(make_event(2)) # skipped 1 — count is rows, not latest+1
assert store.count("learner-1", "task-1") == 2
# Dedup retries do not inflate the count (at-least-once contract).
store.append(make_event(2))
assert store.count("learner-1", "task-1") == 2
# Scoped to the trace pair.
assert store.count("learner-1", "task-2") == 0
def test_list_tasks(store: SQLiteTraceStore) -> None: def test_list_tasks(store: SQLiteTraceStore) -> None:
assert store.list_tasks("learner-1") == [] assert store.list_tasks("learner-1") == []
+14
View File
@@ -56,9 +56,11 @@ export function useSandboxSession(competencyId: string | null) {
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
setState({ ...DEFAULT_STATE, status: 'starting' }); setState({ ...DEFAULT_STATE, status: 'starting' });
let createdId: string | null = null;
try { try {
const variant = await generateVariant(MOCK_LEARNER_ID, compId, controller.signal); const variant = await generateVariant(MOCK_LEARNER_ID, compId, controller.signal);
const sandbox = await createSandbox(MOCK_LEARNER_ID, variant.task_id, 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. // Materialize the variant's starter files into the sandbox workspace.
for (const [path, content] of Object.entries(variant.starter_files ?? {})) { for (const [path, content] of Object.entries(variant.starter_files ?? {})) {
await writeFile(sandbox.id, path, content, controller.signal); await writeFile(sandbox.id, path, content, controller.signal);
@@ -66,6 +68,11 @@ export function useSandboxSession(competencyId: string | null) {
const files = await listFiles(sandbox.id, controller.signal); const files = await listFiles(sandbox.id, controller.signal);
setState({ status: 'ready', variant, sandboxId: sandbox.id, files, errorMessage: null }); setState({ status: 'ready', variant, sandboxId: sandbox.id, files, errorMessage: null });
} catch (err) { } 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 (controller.signal.aborted) return;
if (err instanceof EngineError) { if (err instanceof EngineError) {
setState({ setState({
@@ -93,6 +100,8 @@ export function useSandboxSession(competencyId: string | null) {
}, [competencyId]); }, [competencyId]);
// Unmount: destroy the sandbox (idempotent; a killed session is fine). // 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); const sandboxIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
sandboxIdRef.current = state.sandboxId; sandboxIdRef.current = state.sandboxId;
@@ -103,6 +112,11 @@ export function useSandboxSession(competencyId: string | null) {
if (id) void destroySandbox(id).catch(() => undefined); if (id) void destroySandbox(id).catch(() => undefined);
}; };
}, []); }, []);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
const run = useCallback( const run = useCallback(
async (cmd: string[]): Promise<ExecResult | null> => { async (cmd: string[]): Promise<ExecResult | null> => {
+1 -1
View File
@@ -69,7 +69,7 @@ export interface GradeRecord {
learner_id: string; learner_id: string;
/** The graded task — joins to `TaskVariant.task_id`. */ /** The graded task — joins to `TaskVariant.task_id`. */
task_id: string; task_id: string;
/** Task-variant seed (D-029); null while grading is variant-blind. */ /** Task-variant seed (D-029); null only for non-variant tasks (P4 stamps it). */
variant_seed: string | null; variant_seed: string | null;
/** Compact trace digest (D-028) that fed the rubric prompt; {} for gate records. */ /** Compact trace digest (D-028) that fed the rubric prompt; {} for gate records. */
digest: Record<string, unknown>; digest: Record<string, unknown>;