merge(P07): final review + audit → milestone/v0.3-credential-engines
---ci--- phase: 7 milestone: v0.3 status: ship ---/ci---
This commit is contained in:
+48
-15
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 **1–5 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 **1–5 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
@@ -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
@@ -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:**
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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") == []
|
||||||
|
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user