Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9709e0f38 | |||
| 62bff575af | |||
| 8a43e95a4d | |||
| ee5be13c94 | |||
| 1da814e790 | |||
| cc7ec5d03f | |||
| 5c2829d9df | |||
| ec4648b37a | |||
| c5369b407b | |||
| 282f5ef150 | |||
| 3010bc4b96 | |||
| c80381c4df |
@@ -106,10 +106,11 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
|
||||
| Component | Description | Boundaries | Depends On |
|
||||
|-----------|-------------|------------|------------|
|
||||
| `ai_service/sandbox/` | `backend.py` (SandboxBackend protocol), `unshare_backend.py` (userns/mount/pid/net spawner, D-024), `manager.py` (lifecycle: create/list/snapshot/destroy + concurrency guard D-032), `workdir.py` (per-sandbox fs layout) | Never imports api/ or agents/; spawns subprocesses only | 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/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), `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/telemetry/` | `models.py` (TelemetryEvent, TraceSpan), `store.py` (TraceStore protocol + SQLite impl D-027), `ingest.py` (WebSocket /v1/telemetry/ingest, seq gap detection D-026; v0.5 REQ-5-007/D-045: emits advisory `seq_ack` frames — highest-contiguous received seq per successful append; capture agent trims its spool to the ack, closing the replay-margin gap) | Persistence; never imports agents/ | config |
|
||||
| `ai_service/grading/` | `features.py` (deterministic trace digest D-028 — kind-agnostic by construction, pinned over design/sim traces in v0.5), `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; v0.5 REQ-5-005/D-044: `environment: Literal[build,design,simulation]` registry + per-kind starter files + harness/test commands, G-15 shlex-roundtrip validation), `generator.py` (seeded LLM instantiation D-029), `store.py` (VariantStore; idempotent `_ensure_v05_columns` backfill for pre-v0.5 DBs) | LLM via structured output | llm, grading |
|
||||
| `ai_service/voice/` | `base.py` (VoiceProvider protocol D-030), `browser.py` (native SR/TTS fallback descriptor), `mock.py` (deterministic), `openai_audio.py` (v0.5 REQ-5-001, D-040: real server STT/TTS against OpenAI-compatible `/audio/transcriptions` + `/audio/speech` on the shared httpx pool — CUT-1/G-7 seam CLOSED), `factory.py` (provider selection by `AI_VOICE_PROVIDER`, G-11 boot-safe fallback to mock), `defense_store.py` (DefenseStore: transcripts + integrity signals, D-027) | Never imports agents/ or api/ | config |
|
||||
| `ai_service/identity/` | **v0.5 NEW (REQ-5-003/004, D-042/43):** `base.py` (IdentityProvider protocol: submit/poll/verify), `mock.py` (deterministic approve-on-policy mock; verdicts carry a `mock` marker, A-304), `store.py` (5th D-027 store: identity_record table — derived `age_band`, document **refs**, PII never stored raw); age-gate dependencies `require_verified_age`/`require_verified_adult` (gate composition D-043: G-5 allowlist → identity verdict → rate caps; mounted on variants/sandbox-create/defense-start + the G-18 marketplace stub), exposed via `api/identity.py` (`/v1/identity/*`) | Never imports agents/; api/ composes it via DI | 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/data/*.db` | SQLite databases (telemetry/grades/variants/defenses) — **v0.3.6: default moved to `~/.nextcraft/data/nextcraft.db` (state out of the repo; AI_DB_PATH overrides, ~ expanded)** | outside repo (home) | — |
|
||||
| `scripts/sandbox-agent.py` | Tiny in-namespace capture process shipped into the sandbox; streams telemetry to ingest | standalone | stdlib only |
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "execute",
|
||||
"milestone": "v0.5",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-13T20:45:00Z"
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Current Milestone: v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease
|
||||
## Milestone v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (COMPLETE, shipped as v0.4.5)
|
||||
|
||||
**Scope (the four seams D-016 deferred out of v0.4, locked at v0.5 Phase 0 SPECIFY):**
|
||||
|
||||
|
||||
+17
-17
@@ -1,33 +1,33 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.5 Requirements (Active — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease)
|
||||
## v0.5 Requirements (Complete — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease; shipped as v0.4.5)
|
||||
|
||||
### Real Server Voice
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-5-001 | `openai-audio` VoiceProvider: server STT (`/audio/transcriptions`) + TTS (`/audio/speech`) against an OpenAI-compatible endpoint via the existing D-030 protocol; provider selection by `AI_VOICE_PROVIDER` (+ base URL/key from env, never committed); deterministic mock stays first-class; browser fallback unchanged | critical | 2 | pending |
|
||||
| REQ-5-002 | Voice defense real path end-to-end: examiner dialogue answers transcribed server-side (audio upload → transcript), examiner questions spoken via server TTS (audio returned to the client); transcripts + integrity signals unchanged | critical | 2 | pending |
|
||||
| REQ-5-001 | `openai-audio` VoiceProvider: server STT (`/audio/transcriptions`) + TTS (`/audio/speech`) against an OpenAI-compatible endpoint via the existing D-030 protocol; provider selection by `AI_VOICE_PROVIDER` (+ base URL/key from env, never committed); deterministic mock stays first-class; browser fallback unchanged | critical | 2 | complete |
|
||||
| REQ-5-002 | Voice defense real path end-to-end: examiner dialogue answers transcribed server-side (audio upload → transcript), examiner questions spoken via server TTS (audio returned to the client); transcripts + integrity signals unchanged | critical | 2 | complete |
|
||||
|
||||
### Identity & Age-Gating
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-5-003 | Identity provider protocol + mock-first backend (REQ-F-017): verify-identity flow (submit → pending → verified/rejected with document refs), provider-agnostic (mock default; a real KYC vendor drops in later), PII stored server-side only, never logged | critical | 3 | pending |
|
||||
| REQ-5-004 | Age-gating enforced by the backend: school floor 16+ verified at enrollment, marketplace 18+ with verified identity — API surfaces reject under-age/unverified callers on gated routes (replaces the v0.1 visual-only flow; G-5 allowlist evolves toward real identity, allowlist remains as pilot guard) | critical | 3 | pending |
|
||||
| REQ-5-003 | Identity provider protocol + mock-first backend (REQ-F-017): verify-identity flow (submit → pending → verified/rejected with document refs), provider-agnostic (mock default; a real KYC vendor drops in later), PII stored server-side only, never logged | critical | 3 | complete |
|
||||
| REQ-5-004 | Age-gating enforced by the backend: school floor 16+ verified at enrollment, marketplace 18+ with verified identity — API surfaces reject under-age/unverified callers on gated routes (replaces the v0.1 visual-only flow; G-5 allowlist evolves toward real identity, allowlist remains as pilot guard) | critical | 3 | complete |
|
||||
|
||||
### Sandbox Environments
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-5-005 | Design + simulation sandbox environment types (REQ-F-021 remainder): extend the namespace fabric with environment kinds beyond the coding IDE (design-tool: canvas/editor surfaces with file artifacts; simulation: run/benchmark harnesses) — one lifecycle, one telemetry path, per-type starter contents + allowed commands | high | 4 | pending |
|
||||
| REQ-5-006 | Environment-typed learner surface: the build/defend flow accepts environment kind, telemetry captures per-kind events, grading digest stays kind-agnostic | high | 4 | pending |
|
||||
| REQ-5-005 | Design + simulation sandbox environment types (REQ-F-021 remainder): extend the namespace fabric with environment kinds beyond the coding IDE (design-tool: canvas/editor surfaces with file artifacts; simulation: run/benchmark harnesses) — one lifecycle, one telemetry path, per-type starter contents + allowed commands | high | 4 | complete |
|
||||
| REQ-5-006 | Environment-typed learner surface: the build/defend flow accepts environment kind, telemetry captures per-kind events, grading digest stays kind-agnostic | high | 4 | complete |
|
||||
|
||||
### Telemetry Durability
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-5-007 | Exec-telemetry seq-lease/replay-margin fix: WS ingest acknowledges received seqs; capture agent resumes from the ack on reconnect (bounded replay margin) — closes the P6-lesson one-line ACK gap with a real-server regression test | high | 1 | pending |
|
||||
| REQ-5-007 | Exec-telemetry seq-lease/replay-margin fix: WS ingest acknowledges received seqs; capture agent resumes from the ack on reconnect (bounded replay margin) — closes the P6-lesson one-line ACK gap with a real-server regression test | high | 1 | complete |
|
||||
|
||||
## v0.4 Requirements (Complete — Distribution & Bootstrap CLI, shipped as v0.3.4)
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
|
||||
| ID | Description | Priority | Milestone | Status |
|
||||
|----|-------------|----------|-----------|--------|
|
||||
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated (REQ-5-003/004) |
|
||||
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated → complete (REQ-5-003/004) |
|
||||
| REQ-F-018 | Payment processing and subscription management | high | v0.3+ | deferred |
|
||||
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
|
||||
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
|
||||
@@ -216,17 +216,17 @@
|
||||
|
||||
## Traceability Matrix
|
||||
|
||||
### v0.5 (current milestone)
|
||||
### v0.5 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-5-007 | 1 | pending |
|
||||
| REQ-5-001 | 2 | pending |
|
||||
| REQ-5-002 | 2 | pending |
|
||||
| REQ-5-003 | 3 | pending |
|
||||
| REQ-5-004 | 3 | pending |
|
||||
| REQ-5-005 | 4 | pending |
|
||||
| REQ-5-006 | 4 | pending |
|
||||
| REQ-5-007 | 1 | complete |
|
||||
| REQ-5-001 | 2 | complete |
|
||||
| REQ-5-002 | 2 | complete |
|
||||
| REQ-5-003 | 3 | complete |
|
||||
| REQ-5-004 | 3 | complete |
|
||||
| REQ-5-005 | 4 | complete |
|
||||
| REQ-5-006 | 4 | complete |
|
||||
|
||||
### v0.4 (complete)
|
||||
|
||||
|
||||
+11
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone v0.5 — IN PROGRESS (started 2026-09-13).** Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease: the four seams D-016 deferred out of v0.4. Server STT/TTS for voice defense (`openai-audio` VoiceProvider), identity verification + backend-enforced age-gating (REQ-F-017), design/simulation sandbox environments (REQ-F-021 remainder), and the exec-telemetry seq-lease/replay-margin fix.
|
||||
**Milestone v0.5 — COMPLETE (shipped as v0.4.5, 2026-09-13).** Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease: the four seams D-016 deferred out of v0.4. Server STT/TTS for voice defense (`openai-audio` VoiceProvider), identity verification + backend-enforced age-gating (REQ-F-017), design/simulation sandbox environments (REQ-F-021 remainder), and the exec-telemetry seq-lease/replay-margin fix.
|
||||
|
||||
**Milestone v0.4 — COMPLETE (shipped as v0.3.4, 2026-09-13; hotfixes v0.3.5 fresh-box, v0.3.6 single-port unattended deploy).** Distribution & Bootstrap CLI: `nextcraft` CLI (doctor/bootstrap/verify/dev) shipped as a linux x64 SEA binary, one-liner install script with checksum + version integrity gates, and binaries published on **every ongoing release** (v0.3.2 onward). v0.3.6 adds the single-port same-origin deploy (static export served by the ai-service on :8420) and unattended ops (`dev -d`/`stop`/`log`), with runtime state moved to `~/.nextcraft/`.
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | in-progress | — | — | Specification, clarify, research, plan, grill complete; .ciagent/ files updated for v0.5 |
|
||||
| 1 | Seq-lease + replay margin | pending | 0 | REQ-5-007 | Ingest acks received seqs (`seq_ack` frame); capture agent trims spool to ack on reconnect (bounded margin); real-server mid-burst regression test closes the P07-documented gap |
|
||||
| 2 | Real server voice | pending | 0 | REQ-5-001, REQ-5-002 | `openai-audio` provider passes STT/TTS contract tests (MockTransport); voice defense runs server-side end-to-end when keys exist; mock/browser paths unchanged; suite green |
|
||||
| 3 | Identity + age-gating | pending | 0 | REQ-5-003, REQ-5-004 | Identity protocol + mock backend + verification flow API; gated routes enforce 16+/18+ (allowlist → identity → rate caps); PII hygiene pinned by caplog test |
|
||||
| 4 | Design/sim environments | pending | 0 | REQ-5-005, REQ-5-006 | Template-layer env registry (build/design/simulation); per-kind starter contents + exec policy; test_command surfaced; grading digest kind-agnostic (pinned) |
|
||||
| 5 | Final review + ship | pending | 1-4 | — | Code review clean; audit passes; milestone tagged (final v0.4.x patch); release with binary assets on Gitea |
|
||||
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan, grill complete; .ciagent/ files updated for v0.5 |
|
||||
| 1 | Seq-lease + replay margin | complete | 0 | REQ-5-007 | Ingest acks received seqs (`seq_ack` frame); capture agent trims spool to ack on reconnect (bounded margin); real-server mid-burst regression test closes the P07-documented gap |
|
||||
| 2 | Real server voice | complete | 0 | REQ-5-001, REQ-5-002 | `openai-audio` provider passes STT/TTS contract tests (MockTransport); voice defense runs server-side end-to-end when keys exist; mock/browser paths unchanged; suite green |
|
||||
| 3 | Identity + age-gating | complete | 0 | REQ-5-003, REQ-5-004 | Identity protocol + mock backend + verification flow API; gated routes enforce 16+/18+ (allowlist → identity → rate caps); PII hygiene pinned by caplog test |
|
||||
| 4 | Design/sim environments | complete | 0 | REQ-5-005, REQ-5-006 | Template-layer env registry (build/design/simulation); per-kind starter contents + exec policy; test_command surfaced; grading digest kind-agnostic (pinned) |
|
||||
| 5 | Final review + ship | complete | 1-4 | — | Code review clean; audit passes; milestone tagged (final v0.4.x patch); release with binary assets on Gitea |
|
||||
|
||||
---
|
||||
|
||||
@@ -95,6 +95,10 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.5 (Complete — Shipped as v0.4.5)
|
||||
|
||||
Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease: real server STT/TTS (`openai-audio` VoiceProvider with boot-safe fallback), the identity module (5th D-027 store, provider protocol + mock, D-043 age-gate composition on variants/sandboxes/defense/marketplace), design/simulation environment kinds at the template layer with per-kind exec policy, and the seq-ack protocol closing the P07 replay-margin gap. 6 phases (P0–P5). All 7 requirements (REQ-5-001..007) complete. Tags v0.4.0–v0.4.4 per phase, milestone release v0.4.5.
|
||||
|
||||
## v0.4 (Complete — Shipped as v0.3.4)
|
||||
|
||||
Distribution & Bootstrap CLI: `nextcraft` CLI (doctor/bootstrap/verify/dev) as a self-contained linux x64 SEA binary, one-liner install with sha256 + version integrity gates, release-asset pipeline attaching binaries to every ongoing release (v0.3.2 onward), install/quickstart docs backed by a fresh-clone E2E test. 5 phases (P0–P4). All 5 requirements (REQ-4-001..005) complete. Tags v0.3.0–v0.3.3 per phase, milestone release v0.3.4.
|
||||
|
||||
@@ -80,7 +80,7 @@ Durable state (SQLite DB, sandbox workdirs, daemon pid/log) lives in `~/.nextcra
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone v0.4** — Distribution & Bootstrap CLI (one-liner install, `nextcraft` binary releases on every ship)
|
||||
**Milestone v0.5** — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (shipped as v0.4.5)
|
||||
|
||||
Prior: v0.3 Credential Engines (shipped v0.2.8) · v0.2 AI Tutor Architecture (v0.2.0) · v0.1 UI/UX Prototype (v0.1.0)
|
||||
|
||||
|
||||
@@ -217,7 +217,18 @@ async def answer_defense(
|
||||
# 'audio/webm;codecs=opus'; the bare extension is the provider
|
||||
# contract ('webm'), else real STT endpoints reject the multipart.
|
||||
fmt = (audio.content_type or "audio/wav").split("/")[-1].split(";")[0].strip()
|
||||
segment = await voice_provider.transcribe(raw, fmt)
|
||||
try:
|
||||
segment = await voice_provider.transcribe(raw, fmt)
|
||||
except RuntimeError as exc:
|
||||
# Provider failure is the 502 house pattern (assessment.py /
|
||||
# proctor.py), not a 500: a real endpoint outage (or the
|
||||
# default mock's unscripted queue — final-review cross-phase
|
||||
# P0) must surface as an honest upstream error. Both providers
|
||||
# raise RuntimeError with sanitized text (mock: MockVoiceFailure;
|
||||
# openai-audio: key-redacted _sanitize).
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"voice transcription failed: {exc}"
|
||||
) from exc
|
||||
stt_ms = int((time.perf_counter() - stt_started) * 1000)
|
||||
text = segment.text
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class SnapshotResponse(BaseModel):
|
||||
# -- abuse control (G-5; middleware layer, not auth) ---------------------------
|
||||
|
||||
|
||||
from ..variants.templates import get_template # noqa: E402
|
||||
from .identity import require_verified_age # noqa: E402 (gate dep, D-043)
|
||||
|
||||
|
||||
@@ -344,10 +345,54 @@ async def write_file(
|
||||
return {"path": body.path, "written": True}
|
||||
|
||||
|
||||
#: G-15 (REQ-5-005): per-kind exec command policy — EXACT argv[0] token
|
||||
#: matching, never prefix/substring (trivially bypassed via flags/-c
|
||||
#: passthrough). 'sh -c' passthrough is DISALLOWED for design/simulation
|
||||
#: kinds: the gaming vector would be faking build-style test cycles into a
|
||||
#: kind-agnostic digest. Build kinds keep v0.3 behavior (any command —
|
||||
#: the CUT-2 surface is Run/Test buttons, not a shell relay).
|
||||
#: python (bare) is deliberately absent — in-ns PATH resolves only python3
|
||||
#: (verifier P1); pip is absent (no network in the namespace).
|
||||
_GENERIC_FIRST_TOKENS = frozenset(
|
||||
{"ls", "cat", "pwd", "echo", "python3", "pytest"}
|
||||
)
|
||||
|
||||
|
||||
def _enforce_exec_policy(
|
||||
cmd: list[str], environment: str | None, allowed: set[str] | None = None
|
||||
) -> None:
|
||||
"""422 with the allowed set when a design/sim command is out of policy.
|
||||
|
||||
`allowed` defaults to the generic set; the exec route unions in the
|
||||
template's DECLARED harness argv[0] (a future non-python harness
|
||||
template must not reject its own Run command)."""
|
||||
if environment not in ("design", "simulation"):
|
||||
return # build kind: unchanged v0.3 semantics
|
||||
allowed = set(allowed) if allowed is not None else set(_GENERIC_FIRST_TOKENS)
|
||||
first = cmd[0] if cmd else ""
|
||||
if first in ("sh", "bash"):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"shell passthrough is not allowed in a {environment} "
|
||||
f"environment; allowed commands: {sorted(allowed)}"
|
||||
),
|
||||
)
|
||||
if first not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"command {first!r} is not allowed in a {environment} "
|
||||
f"environment; allowed commands: {sorted(allowed)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{sandbox_id}/exec", response_model=ExecResponse)
|
||||
async def exec_command(
|
||||
sandbox_id: str,
|
||||
body: ExecRequest,
|
||||
request: Request,
|
||||
manager: SandboxManager = Depends(get_sandbox_manager),
|
||||
) -> ExecResponse:
|
||||
try:
|
||||
@@ -358,5 +403,19 @@ async def exec_command(
|
||||
handle = manager._handles.get(sandbox_id) # noqa: SLF001
|
||||
if handle is None:
|
||||
raise HTTPException(status_code=404, detail=f"no live handle {sandbox_id!r}")
|
||||
# REQ-5-005 (G-15): resolve the sandbox's variant environment by its
|
||||
# task_id (manager side-table) and enforce the per-kind command policy
|
||||
# BEFORE execution.
|
||||
task_id = manager._task_ids.get(sandbox_id) # noqa: SLF001 - composition seam
|
||||
if task_id:
|
||||
variant = request.app.state.variant_store.get_by_task(task_id)
|
||||
if variant is not None:
|
||||
template = get_template(variant.template_id)
|
||||
declared = (
|
||||
{template.run_command.split()[0]} if template is not None else set()
|
||||
)
|
||||
_enforce_exec_policy(
|
||||
body.cmd, variant.environment, _GENERIC_FIRST_TOKENS | declared
|
||||
)
|
||||
result = await backend.exec(handle, body.cmd)
|
||||
return ExecResponse(**result.model_dump())
|
||||
|
||||
@@ -97,6 +97,9 @@ class VariantResponse(BaseModel):
|
||||
params: dict[str, str | int]
|
||||
statement: str
|
||||
starter_files: dict[str, str]
|
||||
#: REQ-5-005 (a-11): REQUIRED on the wire — always emitted.
|
||||
environment: str
|
||||
test_command: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -154,6 +157,9 @@ def _to_response(record: VariantRecord, competency_id: str) -> VariantResponse:
|
||||
params=dict(record.params),
|
||||
statement=record.statement,
|
||||
starter_files=dict(record.starter_files),
|
||||
# a-11: REQUIRED on the wire — the server always emits both (v0.5).
|
||||
environment=record.environment or "build",
|
||||
test_command=record.test_command or "pytest -q",
|
||||
created_at=record.created_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ display mock-verified as production-verified. Never calls the network.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .base import IdentitySubmission, IdentityVerdict
|
||||
@@ -32,15 +34,30 @@ def derive_age_band(date_of_birth: str, today: datetime | None = None) -> str:
|
||||
|
||||
|
||||
class MockIdentityProvider:
|
||||
"""Scriptable, deterministic; no network, no vendor calls."""
|
||||
"""Scriptable, deterministic; no network, no vendor calls.
|
||||
|
||||
Submission ids are minted UNIQUELY per submit() call (a monotonic
|
||||
counter + the per-process seed from `secrets`): the id is the PK of
|
||||
the insert-only IdentityStore, and a deterministic id derived from
|
||||
(learner_id, date_of_birth) collides on any resubmit-after-terminal
|
||||
(e.g. a rejected learner retrying with the same DOB) — the store
|
||||
surfaces IntegrityError and the API would 500 (cross-phase P0,
|
||||
final review). Uniqueness per call is the contract; determinism of
|
||||
VERDICTS (what tests actually pin) is preserved — poll() derives the
|
||||
band purely from the stored submission.
|
||||
"""
|
||||
|
||||
def __init__(self, reject_learners: set[str] | None = None) -> None:
|
||||
self._submissions: dict[str, IdentitySubmission] = {}
|
||||
self._reject_learners = reject_learners or set()
|
||||
# Per-process nonce: ids are opaque handles (A-305) — never
|
||||
# derived from PII. Counter + nonce keeps ids unique within and
|
||||
# across provider instances on one box.
|
||||
self._nonce = secrets.randbits(32)
|
||||
self._counter = itertools.count()
|
||||
|
||||
async def submit(self, submission: IdentitySubmission) -> str:
|
||||
key = hash((submission.learner_id, submission.date_of_birth))
|
||||
submission_id = f"idc-{abs(key) % 10**12:012d}"
|
||||
submission_id = f"idc-{self._nonce:08x}{next(self._counter):08x}"
|
||||
self._submissions[submission_id] = submission
|
||||
return submission_id
|
||||
|
||||
|
||||
@@ -125,6 +125,9 @@ class SandboxManager:
|
||||
self._clock = clock or (lambda: datetime.now(UTC))
|
||||
self._handles: dict[str, SandboxHandle] = {}
|
||||
self._learner_ids: dict[str, str] = {} # sandbox_id -> learner_id
|
||||
#: REQ-5-005 (G-15): sandbox_id -> task_id side-table — the exec
|
||||
#: policy resolves the variant's environment kind by task_id.
|
||||
self._task_ids: dict[str, str | None] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._integrity_events: list[SandboxIntegrityEvent] = []
|
||||
self._started = False
|
||||
@@ -172,6 +175,7 @@ class SandboxManager:
|
||||
handle = await self._backend.spawn(spec)
|
||||
self._handles[handle.id] = handle
|
||||
self._learner_ids[handle.id] = learner_id
|
||||
self._task_ids[handle.id] = task_id
|
||||
self._write_pid_marker(handle, learner_id)
|
||||
logger.info(
|
||||
"sandbox created: id=%s learner=%s task=%s",
|
||||
@@ -246,6 +250,7 @@ class SandboxManager:
|
||||
async with self._lock:
|
||||
handle = self._handles.pop(sandbox_id, None)
|
||||
learner_id = self._learner_ids.pop(sandbox_id, "unknown")
|
||||
self._task_ids.pop(sandbox_id, None)
|
||||
if handle is not None:
|
||||
await self._backend.destroy(handle)
|
||||
logger.info(
|
||||
|
||||
@@ -94,6 +94,8 @@ class VariantGenerator:
|
||||
params=dict(params),
|
||||
statement=statement,
|
||||
starter_files=dict(template.starter_files),
|
||||
environment=template.environment,
|
||||
test_command=template.test_command,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self._store.save(record)
|
||||
|
||||
@@ -41,7 +41,7 @@ from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import JSON, Index, UniqueConstraint
|
||||
from sqlalchemy import JSON, Index, String, UniqueConstraint
|
||||
from sqlalchemy.orm import validates
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
|
||||
@@ -105,6 +105,12 @@ class VariantRecord(SQLModel, table=True):
|
||||
params: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
statement: str
|
||||
starter_files: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
#: REQ-5-005 (D-044): the environment kind rides the record to the API
|
||||
#: and TS client; defaults keep pre-v0.5 rows 'build'.
|
||||
environment: str = Field(default="build", sa_type=String)
|
||||
#: The variant's real test command (was a dead template field — v0.5
|
||||
#: surfaces it so the Run/Test buttons stop hardcoding pytest).
|
||||
test_command: str = Field(default="", sa_type=String)
|
||||
created_at: datetime
|
||||
|
||||
@validates("learner_id", "template_id", "task_id")
|
||||
@@ -215,6 +221,40 @@ class SQLiteVariantStore:
|
||||
self._engine = create_engine(f"sqlite:///{self._db_path}")
|
||||
sa.event.listen(self._engine, "connect", _sqlite_connect)
|
||||
SQLModel.metadata.create_all(self._engine)
|
||||
# v0.5 schema (D-044) added two columns to an existing table.
|
||||
# create_all does NOT ALTER existing tables: on a box with a
|
||||
# pre-v0.5 ~/.nextcraft/data/nextcraft.db, every variant read/write
|
||||
# would raise OperationalError("no such column: variant_record.
|
||||
# environment") — a silent total breakage of the variant path
|
||||
# (final-review P0, verified empirically). Backfill the missing
|
||||
# columns with the model defaults ('build' keeps pre-v0.5 rows
|
||||
# build-kind per the field contract; '' falls back to pytest at
|
||||
# the API seam, api/variants._to_response). Idempotent: the
|
||||
# PRAGMA table_info check makes re-runs no-ops.
|
||||
self._ensure_v05_columns()
|
||||
|
||||
def _ensure_v05_columns(self) -> None:
|
||||
"""Add v0.5 columns to a pre-v0.5 variant_record table (idempotent)."""
|
||||
from sqlalchemy import text
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(variant_record)"))}
|
||||
if "environment" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE variant_record ADD COLUMN environment "
|
||||
"VARCHAR DEFAULT 'build' NOT NULL"
|
||||
)
|
||||
)
|
||||
logger.info("variant store: backfilled 'environment' (pre-v0.5 schema)")
|
||||
if "test_command" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE variant_record ADD COLUMN test_command "
|
||||
"VARCHAR DEFAULT '' NOT NULL"
|
||||
)
|
||||
)
|
||||
logger.info("variant store: backfilled 'test_command' (pre-v0.5 schema)")
|
||||
|
||||
@contextmanager
|
||||
def _session(self) -> Iterator[Session]:
|
||||
|
||||
@@ -20,6 +20,31 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
#: REQ-5-005 environment kinds (D-044): one namespace fabric, typed starter
|
||||
#: contents + command policy. 'build' = the v0.3 coding IDE; 'design' =
|
||||
#: artifact editing with a validator/renderer harness; 'simulation' = a
|
||||
#: parameterized run harness (benchmark scripts + datasets).
|
||||
EnvironmentKind = Literal["build", "design", "simulation"]
|
||||
|
||||
|
||||
def validate_simple_argv(value: str) -> str:
|
||||
"""G-15: command fields must roundtrip shlex.split → join → split.
|
||||
|
||||
No quotes, no shell metachars — the TS client splits on whitespace only
|
||||
(no shlex in browsers), so anything quote-aware would split differently
|
||||
on the two sides. A violation is a template-AUTHORING bug caught here,
|
||||
at definition time, in Python where shlex exists.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
parts = shlex.split(value)
|
||||
if not parts:
|
||||
raise ValueError("command must not be empty")
|
||||
joined = " ".join(parts)
|
||||
if shlex.split(joined) != parts:
|
||||
raise ValueError(f"command is not whitespace-joinable: {value!r}")
|
||||
return joined
|
||||
|
||||
SlotType = Literal["enum", "int_range", "string_set"]
|
||||
|
||||
|
||||
@@ -99,6 +124,12 @@ class TaskTemplate(BaseModel):
|
||||
rubric_anchors: RubricAnchors
|
||||
starter_files: dict[str, str] = Field(default_factory=dict) # path -> content
|
||||
test_command: str
|
||||
#: REQ-5-005 (D-044): the environment kind rides the variant through
|
||||
#: the API to the TS client; 'build' default keeps v0.3 behavior.
|
||||
environment: EnvironmentKind = "build"
|
||||
#: The kind's Run harness (design: validator/renderer; simulation:
|
||||
#: benchmark script). Defaults to the test_command for build kinds.
|
||||
harness_command: str = ""
|
||||
|
||||
@field_validator("statement_skeleton")
|
||||
@classmethod
|
||||
@@ -107,6 +138,16 @@ class TaskTemplate(BaseModel):
|
||||
raise ValueError("statement_skeleton needs at least one {slot}")
|
||||
return v
|
||||
|
||||
@field_validator("test_command", "harness_command")
|
||||
@classmethod
|
||||
def _simple_argv(cls, v: str) -> str:
|
||||
return validate_simple_argv(v) if v else v
|
||||
|
||||
@property
|
||||
def run_command(self) -> str:
|
||||
"""The Run button's command: kind harness when declared, else tests."""
|
||||
return self.harness_command or self.test_command
|
||||
|
||||
def render(self, params: dict[str, str | int]) -> str:
|
||||
"""Fill the skeleton with validated params."""
|
||||
for slot in self.slots:
|
||||
@@ -261,6 +302,134 @@ TEMPLATES: dict[str, TaskTemplate] = {
|
||||
},
|
||||
test_command="pytest -q",
|
||||
),
|
||||
# -- REQ-5-005 design environment (D-044): artifact editing with a
|
||||
# -- validator/renderer harness - same fabric, typed starter contents.
|
||||
"tpl-conversation-flow-design": TaskTemplate(
|
||||
id="tpl-conversation-flow-design",
|
||||
competency_id="stack-designer-c001",
|
||||
title="Conversational Flow Artifact",
|
||||
statement_skeleton=(
|
||||
"Design a conversational flow for a {persona} assistant helping "
|
||||
"users accomplish {goal}. Author the flow as a structured artifact "
|
||||
"with at least {turn_count} conversation turns, explicit fallback "
|
||||
"paths for misunderstandings, and an AI-transparency disclosure "
|
||||
"pattern. The flow must render validly (the harness validates "
|
||||
"structure) and read naturally end to end."
|
||||
),
|
||||
slots=[
|
||||
ParameterSlot(
|
||||
name="persona",
|
||||
type="enum",
|
||||
values=["travel-planner", "homework-tutor", "fitness-coach", "recipe-guide"],
|
||||
),
|
||||
ParameterSlot(
|
||||
name="goal",
|
||||
type="enum",
|
||||
values=["book-a-trip", "master-a-concept", "start-a-routine", "cook-a-meal"],
|
||||
),
|
||||
ParameterSlot(name="turn_count", type="int_range", lo=6, hi=12),
|
||||
],
|
||||
rubric_anchors=RubricAnchors(
|
||||
expected_edit_count_band=(3, 20),
|
||||
expected_min_test_runs=1,
|
||||
expected_error_fix_cycles_band=(0, 3),
|
||||
notes="Design kind: artifact quality + iteration cadence, not code depth.",
|
||||
),
|
||||
starter_files={
|
||||
"README.md": (
|
||||
"# Conversational Flow Design\n\n"
|
||||
"Edit flow.md - the structured flow artifact. python3 "
|
||||
"validate_flow.py checks structure (turn headings, fallback "
|
||||
"sections, a transparency disclosure) and reports issues.\n"
|
||||
),
|
||||
"flow.md": (
|
||||
"# Flow: your persona here\n\n"
|
||||
"## Turn 1\n- **AI:** (opening)\n- **User (expected):** ...\n\n"
|
||||
"## Fallback\n- (misunderstanding handling)\n\n"
|
||||
"## AI Transparency Disclosure\n- (disclosure pattern)\n"
|
||||
),
|
||||
"validate_flow.py": (
|
||||
"import re, sys\n"
|
||||
"text = open('flow.md').read()\n"
|
||||
"issues = []\n"
|
||||
"turns = len(re.findall(r'^## Turn', text, re.M))\n"
|
||||
"if turns < 3:\n"
|
||||
" issues.append(f'expected at least 3 turn sections, found {turns}')\n"
|
||||
"if not re.search(r'^## Fallback', text, re.M):\n"
|
||||
" issues.append('missing Fallback section')\n"
|
||||
"if not re.search(r'^## AI Transparency', text, re.M):\n"
|
||||
" issues.append('missing AI Transparency Disclosure')\n"
|
||||
"print('VALID' if not issues else 'ISSUES: ' + '; '.join(issues))\n"
|
||||
"sys.exit(0 if not issues else 1)\n"
|
||||
),
|
||||
},
|
||||
test_command="python3 validate_flow.py",
|
||||
environment="design",
|
||||
harness_command="python3 validate_flow.py",
|
||||
),
|
||||
# -- REQ-5-005 simulation environment (D-044): parameterized benchmark
|
||||
# -- harness with dataset generation.
|
||||
"tpl-sensor-benchmark": TaskTemplate(
|
||||
id="tpl-sensor-benchmark",
|
||||
competency_id="stack-orchestration-c011",
|
||||
title="Sensor Data Simulation Harness",
|
||||
statement_skeleton=(
|
||||
"Build a simulation harness for {sensor} readings over {duration_min} "
|
||||
"minutes at {sample_hz} Hz. Generate a synthetic dataset with a "
|
||||
"realistic noise profile, run the analysis pipeline, and print a "
|
||||
"metrics summary (mean, p95, anomaly count at {anomaly_sigma} sigma). "
|
||||
"The harness must be reproducible from the committed seed."
|
||||
),
|
||||
slots=[
|
||||
ParameterSlot(
|
||||
name="sensor",
|
||||
type="enum",
|
||||
values=["temperature", "vibration", "luminosity", "pressure"],
|
||||
),
|
||||
ParameterSlot(name="duration_min", type="int_range", lo=5, hi=60),
|
||||
ParameterSlot(name="sample_hz", type="int_range", lo=1, hi=10),
|
||||
ParameterSlot(name="anomaly_sigma", type="int_range", lo=2, hi=4),
|
||||
],
|
||||
rubric_anchors=RubricAnchors(
|
||||
expected_edit_count_band=(3, 25),
|
||||
expected_min_test_runs=2,
|
||||
expected_error_fix_cycles_band=(0, 3),
|
||||
notes="Simulation kind: pipeline correctness + reproducibility.",
|
||||
),
|
||||
starter_files={
|
||||
"README.md": (
|
||||
"# Sensor Simulation Harness\n\n"
|
||||
"Edit simulate.py - python3 simulate.py runs the full pipeline: "
|
||||
"generate, analyze, print metrics. pytest covers the analysis "
|
||||
"functions.\n"
|
||||
),
|
||||
"simulate.py": (
|
||||
"import random, statistics\n\n"
|
||||
"def generate(n=300, seed=42):\n"
|
||||
" rng = random.Random(seed)\n"
|
||||
" return [rng.gauss(20.0, 1.5) for _ in range(n)]\n\n"
|
||||
"def analyze(samples, sigma=3):\n"
|
||||
" mean = statistics.fmean(samples)\n"
|
||||
" stdev = statistics.pstdev(samples)\n"
|
||||
" anomalies = [s for s in samples if abs(s - mean) > sigma * stdev]\n"
|
||||
" p95 = sorted(samples)[int(0.95 * len(samples))]\n"
|
||||
" return {'mean': mean, 'p95': p95, 'anomalies': len(anomalies)}\n\n"
|
||||
"if __name__ == '__main__':\n"
|
||||
" print(analyze(generate()))\n"
|
||||
),
|
||||
"test_simulate.py": (
|
||||
"from simulate import generate, analyze\n\n"
|
||||
"def test_reproducible():\n"
|
||||
" assert generate() == generate()\n\n"
|
||||
"def test_metrics_shape():\n"
|
||||
" m = analyze(generate())\n"
|
||||
" assert set(m) == {'mean', 'p95', 'anomalies'}\n"
|
||||
),
|
||||
},
|
||||
test_command="pytest -q",
|
||||
environment="simulation",
|
||||
harness_command="python3 simulate.py",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -340,3 +340,24 @@ class TestServerVoiceRouteFixes:
|
||||
assert resp.headers["content-type"].startswith("audio/opus"), (
|
||||
resp.headers["content-type"]
|
||||
)
|
||||
|
||||
def test_stt_provider_failure_is_502_never_500(self, client, app) -> None:
|
||||
"""Cross-phase P0 regression (final review): a provider failure on
|
||||
the audio-answer path (real endpoint outage — or the DEFAULT mock
|
||||
provider's unscripted queue, which every default-configured
|
||||
deployment hits on its first audio answer) must surface as an
|
||||
honest 502 per the assessment/proctor house pattern — never an
|
||||
unhandled 500. The transcript stays unaffected (typed answers
|
||||
still work)."""
|
||||
# Unscripted mock = exactly what create_app wires on default settings.
|
||||
app.state.voice_provider = MockVoiceProvider()
|
||||
defense_id = _start(client)["defense_id"]
|
||||
resp = client.post(
|
||||
f"/v1/defense/{defense_id}/answer",
|
||||
files={"audio": ("answer.webm", b"\x1a\x45\xa3\xdf" * 64, "audio/webm")},
|
||||
)
|
||||
assert resp.status_code == 502, resp.text
|
||||
assert "transcription failed" in resp.json()["detail"]
|
||||
# the defense is still alive for typed answers (no poisoned state)
|
||||
typed = client.post(f"/v1/defense/{defense_id}/answer", data={"text": "typed"})
|
||||
assert typed.status_code == 200, typed.text
|
||||
|
||||
@@ -203,6 +203,7 @@ def gated_client(tmp_path: Path, identity_store: SQLiteIdentityStore) -> TestCli
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
identity_submits_per_min=10,
|
||||
db_path=tmp_path / "gated-app.db",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store
|
||||
@@ -246,7 +247,12 @@ class TestVerificationFlow:
|
||||
def test_g13_rate_cap_429(self, tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
app = create_app(
|
||||
Settings(provider="mock", learner_allowlist=["rl"], identity_submits_per_min=1)
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["rl"],
|
||||
identity_submits_per_min=1,
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = store
|
||||
with TestClient(app) as c:
|
||||
@@ -261,13 +267,64 @@ class TestVerificationFlow:
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
def test_resubmit_after_terminal_never_500s(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Cross-phase P0 regression (final review): the mock provider once
|
||||
minted the submission id from hash((learner_id, dob)) — a resubmit
|
||||
after a TERMINAL verdict (rejected learner retrying, or any
|
||||
re-verification with the same DOB) collided with the insert-only
|
||||
store's PK and 500'd forever. Ids must be unique per submit(); a
|
||||
terminal-then-resubmit (rate cap permitting) is a fresh pending
|
||||
submission, never a duplicate-PK crash."""
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "re-i.db")
|
||||
app = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["retry-learner"],
|
||||
identity_submits_per_min=10,
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = store
|
||||
with TestClient(app) as c:
|
||||
# terminal REJECTED record first (under-16 path)
|
||||
sub = c.post(
|
||||
"/v1/identity/submit",
|
||||
json={"learner_id": "retry-learner", "date_of_birth": "2015-01-01"},
|
||||
).json()
|
||||
v = c.post(f"/v1/identity/verify/{sub['submission_id']}").json()
|
||||
assert v["status"] == "rejected"
|
||||
# same learner + same DOB resubmits: fresh pending, NOT a 500
|
||||
second = c.post(
|
||||
"/v1/identity/submit",
|
||||
json={"learner_id": "retry-learner", "date_of_birth": "2015-01-01"},
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["status"] == "pending"
|
||||
assert second.json()["submission_id"] != sub["submission_id"]
|
||||
# while the second is still PENDING, G-13 caps resubmits at 409
|
||||
# (one active pending per learner) — a policy 4xx, never the
|
||||
# duplicate-PK 500 the deterministic-id bug produced.
|
||||
third = c.post(
|
||||
"/v1/identity/submit",
|
||||
json={"learner_id": "retry-learner", "date_of_birth": "2015-01-01"},
|
||||
)
|
||||
assert third.status_code == 409
|
||||
|
||||
def test_pii_sentinel_never_stored_or_logged(
|
||||
self, tmp_path: Path, caplog
|
||||
) -> None:
|
||||
"""MH-3c (A-305): sentinel PII in submissions appears in NO log
|
||||
record and NO stored raw form."""
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "piii.db")
|
||||
app = create_app(Settings(provider="mock", learner_allowlist=["pii-learner"]))
|
||||
app = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["pii-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = store
|
||||
sentinel_dob = "1999-12-31"
|
||||
sentinel_doc = "SENTINEL-DOC-CONTENTS-XYZZY"
|
||||
@@ -316,7 +373,11 @@ class TestGateComposition:
|
||||
) -> None:
|
||||
# allowlisted but NEVER identity-verified (not in the seed roster)
|
||||
app = create_app(
|
||||
Settings(provider="mock", learner_allowlist=SUITE_LEARNERS + ["fresh-learner"])
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS + ["fresh-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = identity_store
|
||||
with TestClient(app) as gated_client:
|
||||
@@ -356,7 +417,11 @@ class TestGateComposition:
|
||||
)
|
||||
)
|
||||
app = create_app(
|
||||
Settings(provider="mock", learner_allowlist=SUITE_LEARNERS + ["minor-learner"])
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS + ["minor-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = identity_store
|
||||
with TestClient(app) as c:
|
||||
@@ -390,10 +455,16 @@ class TestGateComposition:
|
||||
assert body["mock"] is True
|
||||
|
||||
def test_mh3e_flow_unverified_then_enrolled(
|
||||
self, identity_store: SQLiteIdentityStore
|
||||
self, identity_store: SQLiteIdentityStore, tmp_path: Path
|
||||
) -> None:
|
||||
"""MH-3e: unverified → 403 verify-CTA → submit+verify → 200."""
|
||||
app = create_app(Settings(provider="mock", learner_allowlist=["flow-learner"]))
|
||||
app = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["flow-learner"],
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = identity_store
|
||||
with TestClient(app) as c:
|
||||
blocked = c.post(
|
||||
@@ -428,7 +499,13 @@ class TestVerifierHardening:
|
||||
never a 500 whose traceback echoes the raw value into logs (A-305)
|
||||
nor a poisoned pending record that G-13 turns into a lockout."""
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "d1.db")
|
||||
app = create_app(Settings(provider="mock", learner_allowlist=["victim"]))
|
||||
app = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=["victim"],
|
||||
db_path=tmp_path / "app.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = store
|
||||
poison = "1975-06-15XX"
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
@@ -450,7 +527,7 @@ class TestVerifierHardening:
|
||||
assert poison not in logged, "raw DOB must never reach logs"
|
||||
|
||||
def test_d2_gate_fails_closed_on_non_canonical_bands(
|
||||
self, identity_store: SQLiteIdentityStore
|
||||
self, identity_store: SQLiteIdentityStore, tmp_path: Path
|
||||
) -> None:
|
||||
"""D2: None / unknown / under-16 bands can NEVER pass the 18+ gate
|
||||
(nor the school gate for non-canonical values). Non-canonical rows
|
||||
@@ -477,7 +554,13 @@ class TestVerifierHardening:
|
||||
),
|
||||
{"id": f"raw-{lid}", "lid": lid, "band": band},
|
||||
)
|
||||
app = create_app(Settings(provider="mock", learner_allowlist=[lid]))
|
||||
app = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=[lid],
|
||||
db_path=tmp_path / f"app-{lid}.db",
|
||||
)
|
||||
)
|
||||
app.state.identity_store = identity_store
|
||||
with TestClient(app) as c:
|
||||
school = c.post(
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Design/simulation environment tests (REQ-5-005/006, D-044, G-15).
|
||||
|
||||
MH-4a: templates generate kind-tagged variants with correct starter files +
|
||||
commands; command fields roundtrip the shlex validator; wire response
|
||||
carries both fields (required); TS types match Python field-for-field
|
||||
(the dual-schema rule — checked in review by the TS typecheck + here by
|
||||
the response shape).
|
||||
MH-4b: exec policy — design/sim kinds reject out-of-policy argv[0] (422
|
||||
naming the allowed set); sh -c passthrough rejected; build kind unchanged.
|
||||
MH-4d: design-kind E2E in the real-server harness with concrete
|
||||
assertions (stored seqs contiguous; digest computes over a design-kind
|
||||
trace — kind-agnostic by construction, now pinned).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.grading.features import TraceDigest, compute_digest
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
from ai_service.llm.mock import MockProvider
|
||||
from ai_service.main import create_app
|
||||
from ai_service.telemetry.models import TelemetryEvent
|
||||
from ai_service.telemetry.store import SQLiteTraceStore
|
||||
from ai_service.variants.generator import VariantGenerator
|
||||
from ai_service.variants.store import SQLiteVariantStore
|
||||
from ai_service.variants.templates import TEMPLATES, validate_simple_argv
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
DESIGN_LEARNER = "pilot-learner" # verified 18+ via the suite seed
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env_client(tmp_path):
|
||||
store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
seed_verified_identity(identity)
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "env-app.db",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.variant_store = store
|
||||
app.state.variant_generator = VariantGenerator(store, MockProvider(), model="gemma4:31b")
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestTemplateRegistry:
|
||||
"""MH-4a: registry + generator + wire."""
|
||||
|
||||
def test_all_kinds_present(self) -> None:
|
||||
kinds = {t.environment for t in TEMPLATES.values()}
|
||||
assert kinds == {"build", "design", "simulation"}
|
||||
|
||||
def test_g15_command_roundtrip_validator(self) -> None:
|
||||
assert validate_simple_argv("python simulate.py") == "python simulate.py"
|
||||
with pytest.raises(ValueError, match="whitespace-joinable"):
|
||||
validate_simple_argv('sh -c "echo hi"')
|
||||
with pytest.raises(ValueError, match="not be empty"):
|
||||
validate_simple_argv(" ")
|
||||
|
||||
def test_design_variant_generates_with_kind_and_files(self, env_client) -> None:
|
||||
resp = env_client.post(
|
||||
"/v1/variants",
|
||||
json={
|
||||
"learner_id": DESIGN_LEARNER,
|
||||
"template_id": "tpl-conversation-flow-design",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["environment"] == "design"
|
||||
assert body["test_command"] == "python3 validate_flow.py"
|
||||
assert "flow.md" in body["starter_files"]
|
||||
assert "validate_flow.py" in body["starter_files"]
|
||||
|
||||
def test_simulation_variant_generates_with_kind(self, env_client) -> None:
|
||||
resp = env_client.post(
|
||||
"/v1/variants",
|
||||
json={
|
||||
"learner_id": DESIGN_LEARNER,
|
||||
"template_id": "tpl-sensor-benchmark",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["environment"] == "simulation"
|
||||
assert body["test_command"] == "pytest -q"
|
||||
assert "simulate.py" in body["starter_files"]
|
||||
|
||||
def test_build_variants_default_kind(self, env_client) -> None:
|
||||
resp = env_client.post(
|
||||
"/v1/variants",
|
||||
json={"learner_id": DESIGN_LEARNER, "template_id": "tpl-llm-judge"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["environment"] == "build"
|
||||
|
||||
|
||||
class TestExecPolicy:
|
||||
"""MH-4b: exact-token, per-kind; sh -c disallowed for design/sim."""
|
||||
|
||||
@pytest.fixture()
|
||||
def exec_client(self, tmp_path):
|
||||
"""App + a STUB backend sandbox bound to a DESIGN-kind variant
|
||||
(policy check happens before execution — no real namespace needed)."""
|
||||
from ai_service.sandbox.backend import ExecResult
|
||||
from ai_service.sandbox.manager import SandboxManager
|
||||
from tests.api.test_sandboxes import StubBackend
|
||||
|
||||
class ExecStubBackend(StubBackend):
|
||||
"""StubBackend + a working exec (policy fires BEFORE exec)."""
|
||||
|
||||
async def exec(self, handle, cmd): # type: ignore[override]
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return ExecResult(
|
||||
cmd=list(cmd),
|
||||
returncode=0,
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
duration_s=0.0,
|
||||
ts=datetime.now(UTC),
|
||||
)
|
||||
|
||||
vstore = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
seed_verified_identity(identity)
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
db_path=tmp_path / "exec-app.db",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.variant_store = vstore
|
||||
app.state.variant_generator = VariantGenerator(
|
||||
vstore, MockProvider(), model="gemma4:31b"
|
||||
)
|
||||
stub = ExecStubBackend()
|
||||
manager = SandboxManager(backend=stub, settings=settings)
|
||||
app.state.sandbox_manager = manager
|
||||
with TestClient(app) as c:
|
||||
# create a design variant + a sandbox for its task
|
||||
var = c.post(
|
||||
"/v1/variants",
|
||||
json={
|
||||
"learner_id": DESIGN_LEARNER,
|
||||
"template_id": "tpl-conversation-flow-design",
|
||||
},
|
||||
).json()
|
||||
sbx = c.post(
|
||||
"/v1/sandboxes",
|
||||
json={"learner_id": DESIGN_LEARNER, "task_id": var["task_id"]},
|
||||
).json()
|
||||
c._sandbox_id = sbx["id"] # type: ignore[attr-defined]
|
||||
yield c
|
||||
|
||||
def test_design_kind_rejects_out_of_policy_command(self, exec_client) -> None:
|
||||
sbx = exec_client._sandbox_id # type: ignore[attr-defined]
|
||||
resp = exec_client.post(f"/v1/sandboxes/{sbx}/exec", json={"cmd": ["rm", "-rf", "/"]})
|
||||
assert resp.status_code == 422
|
||||
assert "'rm'" in resp.json()["detail"]
|
||||
assert "allowed" in resp.json()["detail"]
|
||||
|
||||
def test_design_kind_rejects_shell_passthrough(self, exec_client) -> None:
|
||||
sbx = exec_client._sandbox_id # type: ignore[attr-defined]
|
||||
resp = exec_client.post(
|
||||
f"/v1/sandboxes/{sbx}/exec", json={"cmd": ["sh", "-c", "anything"]}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "passthrough" in resp.json()["detail"]
|
||||
|
||||
def test_design_kind_allows_declared_harness(self, exec_client) -> None:
|
||||
"""The policy passes the declared harness (StubBackend.exec raises
|
||||
NotImplementedError by design — any status EXCEPT 422 proves the
|
||||
policy allowed the command through)."""
|
||||
sbx = exec_client._sandbox_id # type: ignore[attr-defined]
|
||||
resp = exec_client.post(
|
||||
f"/v1/sandboxes/{sbx}/exec",
|
||||
json={"cmd": ["python3", "validate_flow.py"]},
|
||||
)
|
||||
assert resp.status_code != 422, resp.text
|
||||
|
||||
def test_build_kind_policy_unchanged(self, tmp_path) -> None:
|
||||
from ai_service.api.sandboxes import _enforce_exec_policy
|
||||
|
||||
_enforce_exec_policy(["whatever", "anywhere"], "build") # no raise
|
||||
_enforce_exec_policy(["sh", "-c", "x"], None) # unknown env: no raise
|
||||
|
||||
|
||||
class TestDigestKindAgnostic:
|
||||
"""MH-4d (part): compute_digest over a synthetic DESIGN-kind trace —
|
||||
the digest derives from event kinds, never environment types."""
|
||||
|
||||
def test_design_trace_digests_like_build_traces(self) -> None:
|
||||
"""compute_digest(trace) over a synthetic design-kind event stream —
|
||||
same feature classes as a build trace: command counts, run results,
|
||||
edit cadence. The environment kind never enters the computation."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
ts = datetime(2026, 9, 13, 12, 0, 0, tzinfo=UTC)
|
||||
base = {
|
||||
"learner_id": "digest-learner",
|
||||
"task_id": "task-design-1",
|
||||
"sandbox_id": "sbx-design",
|
||||
}
|
||||
events = [
|
||||
TelemetryEvent(
|
||||
seq=0,
|
||||
kind="command",
|
||||
payload={"cmd": "python validate_flow.py"},
|
||||
ts=ts,
|
||||
**base,
|
||||
),
|
||||
TelemetryEvent(
|
||||
seq=1,
|
||||
kind="file_diff",
|
||||
payload={"path": "flow.md", "diff": "+## Turn 2"},
|
||||
ts=ts,
|
||||
**base,
|
||||
),
|
||||
TelemetryEvent(
|
||||
seq=2,
|
||||
kind="run_result",
|
||||
payload={
|
||||
"cmd": "python validate_flow.py",
|
||||
"exit_code": 0,
|
||||
"stdout": "VALID",
|
||||
},
|
||||
ts=ts,
|
||||
**base,
|
||||
),
|
||||
]
|
||||
digest = compute_digest(events)
|
||||
assert digest.command_count == 1
|
||||
assert digest.run_count == 1
|
||||
# The digest model has NO environment/kind field — kind-agnostic by
|
||||
# construction; assert it stays that way.
|
||||
assert "environment" not in TraceDigest.model_fields
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_design_kind_e2e_real_server(tmp_path, sandbox_dir) -> None:
|
||||
"""MH-4d (G-17 — concrete assertions, no hope-shaped must-haves):
|
||||
a design-kind variant → REAL namespace sandbox → starter files →
|
||||
harness exec in-ns → telemetry flows → contiguous seq chain stored.
|
||||
The ack/trim coverage is the P1 suite's (real agent); here the REAL
|
||||
agent runs too — the spool assertion rides the stored contiguity."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket as sock_lib
|
||||
|
||||
import uvicorn
|
||||
|
||||
from ai_service.sandbox import SandboxManager
|
||||
from ai_service.sandbox.unshare_backend import UnshareBackend
|
||||
from ai_service.telemetry.ingest import TraceIntegrityMap
|
||||
from tests.sandbox.test_isolation import USERSNS_AVAILABLE
|
||||
|
||||
if not USERSNS_AVAILABLE:
|
||||
pytest.skip("user namespaces unavailable on this host (probe)")
|
||||
|
||||
def _free_port() -> int:
|
||||
with sock_lib.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
identity = SQLiteIdentityStore(db_path=tmp_path / "i.db")
|
||||
seed_verified_identity(identity)
|
||||
|
||||
port = _free_port()
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "e2e-app.db",
|
||||
sandbox_dir=sandbox_dir,
|
||||
port=port,
|
||||
telemetry_ingest_host="127.0.0.1",
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity
|
||||
app.state.variant_store = store
|
||||
app.state.variant_generator = VariantGenerator(
|
||||
store, MockProvider(), model="gemma4:31b"
|
||||
)
|
||||
app.state.trace_store = trace_store
|
||||
app.state.trace_integrity = TraceIntegrityMap()
|
||||
manager = SandboxManager(backend=UnshareBackend(), settings=settings)
|
||||
app.state.sandbox_manager = manager
|
||||
|
||||
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())
|
||||
try:
|
||||
for _ in range(100):
|
||||
if server.started:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
assert server.started
|
||||
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(base_url=f"http://127.0.0.1:{port}", timeout=60.0) as client:
|
||||
# 1. design variant (kind-tagged, python3 harness — in-ns PATH)
|
||||
var = (
|
||||
await client.post(
|
||||
"/v1/variants",
|
||||
json={
|
||||
"learner_id": "pilot-learner",
|
||||
"template_id": "tpl-conversation-flow-design",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert var["environment"] == "design"
|
||||
|
||||
# 2. sandbox for the design task
|
||||
sbx = (
|
||||
await client.post(
|
||||
"/v1/sandboxes",
|
||||
json={"learner_id": "pilot-learner", "task_id": var["task_id"]},
|
||||
)
|
||||
).json()
|
||||
|
||||
# 3. materialize starter files (the client's job — mirror it)
|
||||
for path, content in var["starter_files"].items():
|
||||
resp = await client.put(
|
||||
f"/v1/sandboxes/{sbx['id']}/files/{path}",
|
||||
json={"path": path, "content": content},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# 4. run the design harness IN THE NAMESPACE (python3 resolves)
|
||||
run = (
|
||||
await client.post(
|
||||
f"/v1/sandboxes/{sbx['id']}/exec",
|
||||
json={"cmd": ["python3", "validate_flow.py"]},
|
||||
)
|
||||
).json()
|
||||
# starter flow.md fails validation on purpose (needs learner edits)
|
||||
assert "ISSUES" in run.get("stdout", "") or run.get("returncode") in (0, 1)
|
||||
|
||||
# 5. out-of-policy command is 422 at the exec route (G-15)
|
||||
rejected = await client.post(
|
||||
f"/v1/sandboxes/{sbx['id']}/exec",
|
||||
json={"cmd": ["nmap", "-p", "1-1000", "localhost"]},
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
|
||||
# 6. telemetry flowed: contiguous seq chain, kind-agnostic.
|
||||
# The in-ns exec + file writes stream through the capture
|
||||
# agent (watcher ~250ms + command events + heartbeats).
|
||||
import time as _time
|
||||
|
||||
deadline = _time.monotonic() + 15.0
|
||||
events = trace_store.get_trace("pilot-learner", var["task_id"])
|
||||
while _time.monotonic() < deadline and len(events) < 2:
|
||||
await asyncio.sleep(0.5)
|
||||
events = trace_store.get_trace("pilot-learner", var["task_id"])
|
||||
seqs = [e.seq for e in events]
|
||||
assert len(seqs) >= 2, f"no telemetry flowed: {seqs}"
|
||||
assert seqs == sorted(seqs), f"out of order: {seqs}"
|
||||
assert len(set(seqs)) == len(seqs), f"duplicates: {seqs}"
|
||||
assert seqs == list(range(seqs[0], seqs[-1] + 1)), f"gaps: {seqs}"
|
||||
|
||||
await client.delete(f"/v1/sandboxes/{sbx['id']}")
|
||||
finally:
|
||||
server.should_exit = True
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(serve_task, timeout=10.0)
|
||||
trace_store.close()
|
||||
@@ -356,3 +356,58 @@ def test_concurrent_writer_and_reader_no_database_is_locked(tmp_path: Path) -> N
|
||||
finally:
|
||||
reader.close()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_v04_schema_backfilled_on_open(tmp_path: Path) -> None:
|
||||
"""Cross-phase P0 regression (final review): a pre-v0.5 database has a
|
||||
variant_record table WITHOUT the v0.5 environment/test_command columns.
|
||||
create_all does not ALTER existing tables, so opening the old DB with
|
||||
the v0.5 store used to fail every read/write with OperationalError
|
||||
("no such column: variant_record.environment"). The store now
|
||||
backfills the missing columns (idempotently) with the model defaults;
|
||||
pre-v0.5 rows read as build-kind, test_command falls back at the API
|
||||
seam."""
|
||||
import sqlite3
|
||||
|
||||
db_path = tmp_path / "v04-legacy.db"
|
||||
con = sqlite3.connect(db_path)
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE variant_record (
|
||||
learner_id VARCHAR NOT NULL,
|
||||
template_id VARCHAR NOT NULL,
|
||||
task_id VARCHAR NOT NULL,
|
||||
seed VARCHAR NOT NULL,
|
||||
params JSON,
|
||||
statement VARCHAR NOT NULL,
|
||||
starter_files JSON,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (learner_id, template_id),
|
||||
CONSTRAINT uq_variant_record_task_id UNIQUE (task_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO variant_record VALUES "
|
||||
"('legacy-learner','tpl-llm-judge','task-legacy','seed','{}','stmt','{}',"
|
||||
"'2026-01-01 00:00:00')"
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
store = SQLiteVariantStore(db_path=db_path)
|
||||
try:
|
||||
legacy = store.get_by_task("task-legacy")
|
||||
assert legacy is not None, "legacy row unreadable — schema backfill failed"
|
||||
assert legacy.environment == "build" # v0.5 default for pre-v0.5 rows
|
||||
assert legacy.test_command == ""
|
||||
# writes against the migrated table also work
|
||||
new = make_variant(learner_id="legacy-learner", template_id="tpl-new")
|
||||
store.save(new)
|
||||
got = store.get("legacy-learner", "tpl-new")
|
||||
assert got is not None and got.environment == "build"
|
||||
# reopening is idempotent (backfill re-runs harmlessly)
|
||||
again = SQLiteVariantStore(db_path=db_path)
|
||||
again.close()
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
@@ -137,7 +137,7 @@ export function BuildSurface({
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-600 dark:text-primary-400">
|
||||
{stackTitle} · build
|
||||
{stackTitle} · {session.variant?.environment ?? 'build'}
|
||||
</p>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{session.variant?.statement ?? 'Your task'}
|
||||
@@ -149,11 +149,11 @@ export function BuildSurface({
|
||||
</header>
|
||||
|
||||
<RunControls
|
||||
command="python -m pytest -q"
|
||||
command={session.variant?.test_command ?? 'python3 -m pytest -q'}
|
||||
running={running}
|
||||
busy={false}
|
||||
onRun={() => void run(['python', '-m', 'pytest', '-q'])}
|
||||
onTest={() => void run(['pytest', '-q'])}
|
||||
onRun={() => void run((session.variant?.test_command ?? 'python3 -m pytest -q').trim().split(/\s+/))}
|
||||
onTest={() => void session.test()}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
|
||||
@@ -149,8 +149,11 @@ export function useSandboxSession(competencyId: string | null) {
|
||||
|
||||
const test = useCallback(async (): Promise<ExecResult | null> => {
|
||||
if (!state.variant || !state.sandboxId) return null;
|
||||
// All v0.3 templates ship pytest-based starter tests (PLAN Task 6-3-01).
|
||||
return run(['pytest', '-q']);
|
||||
// REQ-5-006: the variant's REAL test command (v0.3 hardcoded pytest;
|
||||
// design/sim kinds have their own). G-15: whitespace-only split —
|
||||
// templates validate quote-free at authoring (no shlex in browsers).
|
||||
const cmd = state.variant.test_command || 'pytest -q';
|
||||
return run(cmd.trim().split(/\s+/));
|
||||
}, [run, state.variant, state.sandboxId]);
|
||||
|
||||
const saveFile = useCallback(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* MH-4c (REQ-5-006): the session hook + Run/Test commands come from the
|
||||
* VARIANT (environment kind + test_command), not hardcoded pytest.
|
||||
*
|
||||
* The hook is a client React component — the pure command-derivation
|
||||
* logic is pinned directly; the wire field contract is pinned via the TS
|
||||
* type (required fields, a-11) in typecheck.
|
||||
*/
|
||||
|
||||
test("variant test_command: whitespace-split argv (G-15 — no shlex in browsers)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync("hooks/use-sandbox-session.ts", "utf8");
|
||||
assert.match(src, /const cmd = state\.variant\.test_command/, "test() derives from variant.test_command");
|
||||
assert.match(src, /\.trim\(\)\.split\(\/\\s\+\/\)/, "whitespace-only split (G-15)");
|
||||
// No shlex IMPORT in code (comments may mention the constraint).
|
||||
assert.doesNotMatch(src, /import[^\n]*shlex/, "no shlex import in the client hook");
|
||||
});
|
||||
|
||||
test("TaskVariant type carries REQUIRED environment + test_command (a-11)", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync(
|
||||
"../../packages/types/variants.ts",
|
||||
"utf8",
|
||||
);
|
||||
assert.match(src, /environment:\s*'build' \| 'design' \| 'simulation';/);
|
||||
assert.match(src, /test_command:\s*string;/);
|
||||
// Required = no `?` optional marker on either field
|
||||
const envLine = src.split("\n").find((l: string) => l.includes("environment: 'build'"));
|
||||
assert.ok(envLine && !envLine.includes("?"), "environment must be required");
|
||||
const cmdLine = src.split("\n").find((l: string) => l.trim().startsWith("test_command"));
|
||||
assert.ok(cmdLine && !cmdLine.includes("?"), "test_command must be required");
|
||||
});
|
||||
@@ -89,6 +89,19 @@ rm -rf apps/ai-service/ai_service/data apps/ai-service/sandboxes
|
||||
Orphaned sandbox workdirs under the NEW `~/.nextcraft/sandboxes/` are
|
||||
reaped automatically on service startup (a-1 startup reaper).
|
||||
|
||||
## v0.5 note: pre-v0.5 databases (fresh start per the deploy directive)
|
||||
|
||||
The deployed box runs a FRESH `~/.nextcraft` state (v0.3.6 directive), so
|
||||
nothing below applies there. For any other box carrying a pre-v0.5
|
||||
`~/.nextcraft/data/nextcraft.db`: v0.5 adds two `variant_record` columns
|
||||
(`environment`, `test_command`). The variant store backfills them
|
||||
automatically on first open (idempotent `ALTER TABLE`; pre-v0.5 rows read
|
||||
as build-kind, test commands fall back to `pytest` at the API seam), so
|
||||
an in-place upgrade is safe — no schema migration step is required.
|
||||
Alternatively, follow the fresh-start convention and move the old DB
|
||||
aside (`mv ~/.nextcraft/data/nextcraft.db{,.pre-v05.bak}`); it is
|
||||
regenerated on boot.
|
||||
|
||||
## Optional: survive reboots (systemd)
|
||||
|
||||
`nextcraft dev -d` is self-managing but not boot-persistent. For pilot
|
||||
|
||||
@@ -45,6 +45,17 @@ export interface TaskVariant {
|
||||
statement: string;
|
||||
/** Workspace scaffold: filename → file content. */
|
||||
starter_files: Record<string, string>;
|
||||
/**
|
||||
* REQ-5-005 (D-044, a-11): REQUIRED on the wire — the server always emits
|
||||
* it. 'build' | 'design' | 'simulation': same namespace fabric, typed
|
||||
* starter contents + command policy.
|
||||
*/
|
||||
environment: 'build' | 'design' | 'simulation';
|
||||
/**
|
||||
* The variant's real test command (whitespace-split on the client — G-15:
|
||||
* templates validate quote-free at authoring; no shlex in browsers).
|
||||
*/
|
||||
test_command: string;
|
||||
/** ISO 8601 UTC generation timestamp. */
|
||||
created_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user