Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfbd88202e | |||
| 9bd64f0f9a | |||
| 042892c257 | |||
| 4bb41659eb | |||
| 135f604894 | |||
| d35695e5fa | |||
| ac65f3ab92 | |||
| 1ba8a11189 | |||
| 4a6856711f | |||
| 9d120450da | |||
| 9c8837eb45 | |||
| dc00a4f8f5 | |||
| 06f049ba37 | |||
| c242808137 | |||
| 99da159866 | |||
| 85be77188f | |||
| 93502f9daf | |||
| 4e662f1d03 | |||
| 57c65d7a12 | |||
| cc968d93ff | |||
| 6adf0fd20c | |||
| 59b21b484d | |||
| d27706abeb | |||
| 02d324b896 | |||
| 9692712e63 | |||
| 660fb471cc | |||
| ae12f01998 | |||
| 63f9a0fc59 | |||
| 69811f953b | |||
| ba0afa757a | |||
| bbcb84ec6c | |||
| cda6ab1936 | |||
| efeaece465 | |||
| e2f3dbdc11 | |||
| d72cea1786 | |||
| 192fa6a655 | |||
| 4193552460 | |||
| 0324067fa7 | |||
| 75bde88e0d | |||
| e14e1fa3d2 | |||
| 843f16d737 | |||
| 3291914eaa | |||
| 4465e6325d | |||
| c9709e0f38 | |||
| 62bff575af | |||
| 8a43e95a4d | |||
| ee5be13c94 | |||
| 1da814e790 | |||
| cc7ec5d03f | |||
| 5c2829d9df | |||
| ec4648b37a | |||
| c5369b407b | |||
| 282f5ef150 | |||
| 3010bc4b96 | |||
| 35c4c386b5 | |||
| d8d2cebfc5 | |||
| b2a2a4023b | |||
| de431852c4 | |||
| 64e4842976 | |||
| a64733a262 | |||
| 0072689e4d | |||
| 3110be2f15 | |||
| bd5b0fee95 | |||
| 2e6d92dfa1 | |||
| e3c8cc7145 | |||
| 1e5ba6568a | |||
| f3f3746da7 | |||
| dd9bda27c9 | |||
| c80381c4df | |||
| 4238a06bca | |||
| 873d22069f | |||
| 5441e10a0e | |||
| 9fedeea767 | |||
| 6873ce6777 | |||
| c90bc7e618 | |||
| ff183b8fca | |||
| 08badd5ed6 | |||
| 9d8be6f466 | |||
| 6d6639b268 | |||
| 83246ed65b | |||
| 33f4c2d61d | |||
| 2c2f68e00e | |||
| 135ea21a61 | |||
| 1648467828 | |||
| 1a68d808fb | |||
| 2c68b44c1a |
+101
-13
@@ -6,6 +6,59 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
|
||||
|
||||
**v0.3 additions (Credential Engines):** real credential engines replace v0.2 mock inputs — a sandbox fabric (isolated per-learner coding environments via Linux user/mount/pid/net namespaces), a live build-telemetry pipeline (WebSocket ingest + SQLite-ordered event log), a process-trace grading engine, seeded per-learner variant task generation, and a voice-based oral defense (STT/TTS via a new provider-agnostic voice layer). **First real persistence introduced: SQLite** (`ai_service/telemetry/`, grading, variant, defense stores). Lab/Assessor/Proctor agents are re-grounded onto real telemetry/traces. **Identity/age-gating (KYC) deferred per founder directive** — no security engineer persona; secrets-hygiene checklist only.
|
||||
|
||||
**v0.4 additions (Distribution & Bootstrap CLI, founder directive D-016):** a new `apps/cli` package — the `nextcraft` bootstrap CLI (`doctor`/`bootstrap`/`verify`/`dev`) compiled to a self-contained linux x64 binary via **Node SEA** (probe-verified: Go/Rust absent, node v24.15.0 SEA-capable), installed by a repo-served one-liner script that resolves the latest Gitea release, downloads binary + sha256 sidecar, verifies, and installs to `~/.local/bin`. Every release from v0.4 onward attaches the binary + checksum as release assets (the "ongoing binaries" requirement). The CLI is a thin wrapper: all orchestration logic stays in `apps/ai-service/scripts/` (bootstrap.sh/dev.sh) — the CLI composes them via subprocess (A-202), duplicating nothing. Previously-planned v0.4 seams (real STT/TTS, KYC, design/sim envs, seq-lease) move to v0.5.
|
||||
|
||||
### v0.6 Research Conclusions (Phase 0 RESEARCH)
|
||||
|
||||
32. **D-048 Marketplace module layout mirrors D-031** — one new engine module `ai_service/marketplace/` inside apps/ai-service (no new apps, no new ports): `base.py` (pydantic contracts: `Posting`, `Employer`, `PostingTags`, `IngestPosting`), `store.py` (6th D-027 store family, D-049), `ingest.py` (PostingSource adapters, D-050), `tagging.py` (D-052), `embedding/` (protocol + mock + openai_compat + factory, D-051), `search.py` (hybrid facet+cosine ranking, A-405), `enrichment.py` (D-053), `resume.py` (D-054), with the HTTP surface in `ai_service/api/marketplace.py` (`/v1/marketplace/*` router) + `api/marketplace_admin.py` (`/v1/marketplace/admin/*`, D-057) composed via `deps.py` app.state DI (`get_marketplace_store`, `get_embedding_provider`). Boundary rules identical to identity/voice: `marketplace/` never imports `agents/` or `api/`; settings in `config.py` (backend-engineer territory); the G-18 stub MOVES from `api/identity.py` into the new router and becomes real (D-058) — `identity/` keeps only the `require_verified_adult` gate dependency it exports today.
|
||||
33. **D-049 Sixth store = one SQLite DB, five tables, DefenseStore conventions** — the marketplace store joins the shared `~/.nextcraft/data/nextcraft.db` (all 5 prior stores already share it; `Settings().db_path`). Tables: `posting` (id PK, source, external_id, employer_id, title, description, skills/seniority/category/location/remote/salary JSON+portable columns, status `draft|published|closed`, posted_at, unique constraint on (source, external_id) for idempotent upsert per A-402); `employer` (id PK, ingested raw fields, unique per source); `embedding` (posting_id PK, `vector` BLOB of struct-packed little-endian float32, `model`, `dim` — A-403/A-404 per-row provenance so lazy re-embed on provider change works); `application` (id PK, posting_id, learner_id, status `submitted`, created_at, unique (posting_id, learner_id) for A-408 idempotent apply); `employer_enrichment` (append-only: id PK, employer_id, field, value, source `llm|manual`, model, created_at — A-409). **[AMENDED by grill G-104: SIX tables — the `learner_profile` table (learner_id PK, profile JSON, confirmed_at, model) replaces the filesystem sidecar the plan draft proposed; the D-027 store family is the only persistence mechanism. Shipped six-table in P1.]** All tables mirror the probe-verified identity/store.py mechanics: WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas, `sa_type=String/JSON` portable columns, `_validate()` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz normalization. `VectorStore` is a protocol method-group on the marketplace store (pgvector-swap-ready, D-019/D-027 pattern).
|
||||
34. **D-050 PostingSource = protocol + two adapters, no scheduler** — `PostingSource` protocol: `fetch() -> Iterable[IngestPosting]` (pydantic, A-402 shape: source, external_id, employer + posting fields). Two v0.6 implementations: `seeded_corpus` (in-repo JSON seed ported ONCE from `packages/mock-data/jobs.ts` (20 jobs) + `employers.ts` (10 employers) per D-021 convention — shared IDs, mock `matchScore` DROPPED since D-051 computes real ones) and `admin_api` (the founder-authored path; the admin router calls ingest directly rather than an HTTP hop). Ingest upserts idempotent on (source, external_id); a `POST /v1/marketplace/admin/ingest` route triggers a source run explicitly (founder-invoked — no cron/crawler, A-401/A-402). The 3M+ external corpus lands later as a third adapter with zero store/route changes.
|
||||
35. **D-051 EmbeddingProvider = D-030 mirror with a corrected real path** — protocol `embed(texts: list[str]) -> list[EmbeddingVector]` (frozen pydantic: `values: list[float]`, `model`, `dim`) in `marketplace/embedding/base.py`. Deterministic mock: 256-dim feature-hash of normalized tokens (pure stdlib, similarity deterministic — tests never call the cloud). Real: `OpenAIEmbedProvider` on the shared httpx pool posting `{"model", "input": [texts]}` to `{AI_EMBED_BASE_URL}/embeddings` (OpenAI-compatible; `_sanitize` key redaction mirroring `openai_audio.py`). **Probe correction to A-404:** ollama-cloud does NOT expose `/v1/embeddings` (404 path-not-found; chat/completions auth-gates 401) — so `AI_EMBED_BASE_URL` must point at a different OpenAI-compatible endpoint (local ollama `:11434/v1` supports it, or any embeddings cloud); provider selection `AI_EMBED_PROVIDER=mock|openai-embed` (default mock, G-11 boot-safe fallback with loud log — exact `voice/factory.py` pattern, `AI_EMBED_MODEL`/`AI_EMBED_API_KEY` env-only). Storage per A-403: struct-packed `<{dim}f` float32 BLOB + model + dim per row; cosine in pure Python (sum of products / norm product, ≤10⁴ postings); candidate side embeds competency-profile text on demand, never stored. Confidence in A-404's parenthetical drops it to ~0.5 but the protocol design is unaffected (endpoint-agnostic by config is the D-014 pattern anyway).
|
||||
36. **D-052 Tagging = D-020 structured_completion, no new agent** — `marketplace/tagging.py` composes the EXISTING `structured_completion(provider, messages, model, schema, schema_hint)` (agents/structured.py, all 4 layers) with a `PostingTags` pydantic schema (skills: list[str] ≤10, seniority enum, category enum) + a versioned prompt constant in `prompts/` (or module-local, matching variants' pattern); no BaseAgent needed — tagging is a one-shot extraction like grading's rubric call. Mock-first for free: `settings.provider` defaults to `mock` and `structured_completion` runs against `MockLLMProvider.chat`, so tests stay cloud-free. Tags persisted as a JSON column on the posting row (D-049); tag facets feed the search filter sidebar (A-405 hard filters); re-tagging on posting update overwrites the tags column (single source of truth, no append-only need — unlike enrichment D-053, tags are derived data).
|
||||
37. **D-053 Enrichment = append-only provenance table + merge-on-read** — per A-409: `employer_enrichment` rows are append-only `(employer_id, field, value, source: llm|manual, model, created_at)`; read precedence **manual > llm > ingested-raw** computed at API-response time (a small `merge_employer()` over rows, raw fields last); every enriched field carries `{value, source}` provenance markers in the API response (A-304 honesty — LLM copy visibly marked). Trigger: a new employer's first published posting fires a one-shot LLM structured extraction (D-020, same layering as D-052, schema: description, social_links, logo_ref) — founder override = upsert a `manual`-source row, never a mutation of `llm` rows. No employer self-serve anywhere (A-407).
|
||||
38. **D-054 Resume parse = pypdf (probe-verified) → D-020 draft extraction** — pypdf 6.19.0 pip-reachable through the existing venv channel (probe 1; the ONLY new dependency, pinned `pypdf>=6.19,<7` in pyproject.toml). Pipeline: upload guard (PDF/TXT/MD only, ≤2 MB, A-410) → text extraction (`pypdf.PdfReader` page-text join for PDFs; raw decode for TXT/MD) → empty-extract on a PDF = honest 422 "text layer required" (no OCR) → cap ~100K chars → D-020 structured extraction into a `ParsedProfile` pydantic schema mapped to the competency-profile shape (competencies, skills, experience bands) → response returns as **DRAFT** (`status: "draft"`) — nothing persists to the learner profile until an explicit confirm call (REQ-6-005 review gate). Raw resume bytes never stored, never logged (A-305 pattern; the existing `_scrub_pii_validation` handler precedent applies). Tests use small generated PDFs or TXT fixtures — cloud-free.
|
||||
39. **D-055 Web migration = client shells + engine-client extension; remove 2 generateStaticParams** — board/detail/employer-profile become one static client shell each fetching at runtime per A-406 (the proven build/defend pattern): `marketplace/page.tsx` keeps its server shell but `JobFilters` receives fetch-backed data (it already owns all filter logic client-side — the facet contract survives unchanged); `jobs/[jobId]` and `employers/[employerId]` drop `generateStaticParams` (the ONLY two removals — learner catalog/learn/build/defend keep theirs, their data stays mock) and render a client shell that fetches then renders (or `notFound()` on 404). `engine-client.ts` gains a marketplace section: `listPostings(filters)`, `getPosting(id)`, `getEmployer(id)`, `searchPostings(query)` → typed against new `packages/types` marketplace wire shapes (data-engineer dual-schema sync); 403 discrimination (verify-CTA vs allowlist) already works. `packages/mock-data` jobs.ts/employers.ts go dormant for the marketplace path (still imported by employer/admin mockups — NOT deleted, A-406/A-407); the seed port (D-050) copies values once. No build-time DB reads — D-039 export build stays repo-only.
|
||||
40. **D-056 SEO = build-time metadata on static set + client JSON-LD on runtime set + ai-service sitemap/robots** — per A-411, split: (a) build-time-static pages (landing, catalog/stack detail, marketplace board, pricing) get `export const metadata` (probe 7: the static-export-compatible API the app already uses in layout.tsx/enroll) + OG tags + server-rendered JSON-LD (`Organization` on employer-adjacent static, `ItemList` on the board) rendered into the export; (b) runtime-data pages (job detail, employer profile) inject `JobPosting`/`Organization` JSON-LD + title client-side after fetch — accepted JS-crawler limitation, recorded; (c) `/sitemap.xml` + `/robots.txt` as plain FastAPI GET routes on the api app enumerating the static page list + all `published` postings — **probe 6 verified mount order: API routes beat the `StaticFiles("/")` mount**, so they win over the export even in single-port production; do NOT use Next `app/sitemap.ts` (it enumerates at build time and cannot see runtime postings under `output: 'export'`). robots.txt points at `/sitemap.xml`; sitemap URLs use the deployed origin (env-configured `AI_PUBLIC_BASE_URL`-style setting, backend-engineer territory).
|
||||
41. **D-057 Admin guard = AI_ADMIN_TOKEN header check, env-only, fail-closed** — all `/v1/marketplace/admin/*` routes (postings create + lifecycle transitions draft→published→closed, ingest trigger, enrichment manual-override, applications review) take a FastAPI dependency `require_admin` comparing `X-Admin-Token` (or `Authorization: Bearer`) against `Settings().admin_token` (`AI_ADMIN_TOKEN`, env-only per D-014, probe: no admin token exists in config today — greenfield). Unset token = admin routes 403-fail-closed (never open; a loud boot warning tells the founder to set it, G-11 doctrine). This is the A-401 operator surface; D-043 learner gate composition is untouched — admin routes do NOT pass the learner allowlist/identity gates (different principal class).
|
||||
42. **D-058 Apply route goes real, composition unchanged** — `POST /v1/marketplace/apply` (moved from api/identity.py into api/marketplace.py, D-048) keeps the exact D-043 order (G-5 allowlist → `require_verified_adult` → rate caps), then: posting must exist and be `published` (404/409 otherwise), insert an `application` row (status `submitted`, unique (posting_id, learner_id) → duplicate apply is idempotent 200-with-existing, A-408), return an honest confirmation payload `{application_id, posting_id, status: "submitted"}` (the `stub/mock` markers die). Founder reviews via `GET /v1/marketplace/admin/applications` (D-057-guarded); no notification infra. Body becomes `{posting_id}` + the learner identity from the verified record rather than a client-asserted `job_id`.
|
||||
43. **D-059 v0.6 wave order = ROADMAP order confirmed with two couplings** — P1 postings backend must ship WITH the web client shells in the same phase (a live store behind mock-reading pages proves nothing — REQ-6-001's success criterion is surfaces reading real data); P2 tagging before P3 search because facets drive the sidebar filters AND the posting text embedded in P3 should include the normalized tags; P4's two workstreams (enrichment REQ-6-004, resume REQ-6-005) are independent of each other — parallelizable within the phase; P5 SEO depends only on P1 (routes exist to enumerate) but stays last-before-final per ROADMAP since sitemap needs `published` postings and the board metadata should reflect final facets; P6 final review + ship. Tagging/enrichment/resume all route through the same D-020 call site — one prompt-review pass covers all three.
|
||||
|
||||
### v0.5 Research Conclusions (Phase 0 RESEARCH)
|
||||
|
||||
25. **D-040 Voice real path = `OpenAIAudioProvider` on the shared httpx pool (D-R01)** — implements the D-030 protocol: `transcribe()` = multipart `POST {AI_VOICE_BASE_URL}/audio/transcriptions` (`file` + `model`, response_format=json → `TranscriptSegment`), `synthesize()` = streaming `POST /audio/speech` (JSON body, raw byte stream, `input` ≤4096 chars). Carries `descriptor = VoiceDescriptor(mode="server", ...)` which defense.py:143 already prefers — mode selection drops in with zero API changes. Constructor takes the lifespan `httpx.AsyncClient` (D-017; read=300s already tolerates multi-minute clips). Factory signature becomes `voice_provider_from_settings(settings, http_client)`. Errors sanitized with key redaction (mirror `llm/openai_compat.py:_sanitize`).
|
||||
26. **D-041 Voice route fixes (D-R02)** — defense.py:189 fmt derivation must strip codec params (`"webm;codecs=opus"` → `webm`, else real STT 400s); ~10 MB audio guard (422/413) before provider call; TTS route media_type becomes format-aware (or force `response_format=wav`). Web: defense-session POSTs the recorded blob (FormData), engine-client gains the audio-answer variant.
|
||||
27. **D-042 Identity = 5th D-027 store + provider protocol (D-R03)** — `ai_service/identity/` (D-031): `IdentityProvider` protocol (submit/poll/verify), deterministic mock, SQLite store modeled on DefenseStore (WAL, FK on, portable columns). Stores **derived age_band** (`16-17`/`18+`, never raw DOB) + document **refs** (never raw docs); mock verdicts carry a `mock` marker so downstream never displays them as production-verified (A-304). PII hygiene pinned by caplog sentinel test. **Negative finding:** no age-gate UI or enrollment API exists today — the flow is built, not swapped (D-010's "visual flow" is vestigial: one mock field).
|
||||
28. **D-043 Gate composition (D-R04)** — api/-layer dependencies in order: G-5 allowlist (403 pilot guard, retained) → identity verdict (403 + verify-CTA payload) → rate caps (429). School 16+ gates variant generation + sandbox create + defense start; marketplace 18+ via reusable `require_verified_adult` demonstrated on one minimal gated route (marketplace has no backend today — the thin route proves the contract end-to-end). All middleware-layer, never in the manager.
|
||||
29. **D-044 Environment registry at the template/variant layer (D-R05)** — `TaskTemplate.environment: Literal["build","design","simulation"]` → carried through VariantRecord → API → TS `TaskVariant`; surfaces the existing-but-dead `test_command` field (Run/Test buttons stop hardcoding pytest). Manager/backend/workdir unchanged (D-024 untouched — A-307: an environment is a typing over starter files + command policy). Per-kind exec command policy at the api/ exec route (template-declared harness + generic file/nav commands, 422 on violation). Grading digest stays kind-agnostic by construction (features derive from event kinds — pinned with a digest test over a synthetic design-kind trace). Starter contents: design = SVG/HTML/schematic artifacts + validate/render harness (stack-designer c001/c002 already sanctioned); simulation = parameterized benchmark script + dataset files (stack-science, stack-operator).
|
||||
30. **D-045 Seq-ack protocol (D-R06/D-R07)** — new WS frame `{"type":"seq_ack","seq":N}` emitted per successful append from `IngestSession._append` (durable `latest_seq`; O(1); advisory — gap detection stays authoritative, G-3/G-4 unchanged). The capture agent's supervisor loop (which today discards all non-close frames) parses text frames and trims spool+pending to `seq > ack` under `_emit_lock` via atomic `Spool.rewrite` — closing the one-line replay-margin gap (`_flush_locked` pops N in-flight frames but `replay_margin()` requeues only `_last_sent`). An explicit spool bound is added (A-309's "spool cap exists" premise was factually wrong on disk). Regression test: mid-burst kill in the test_durability.py real-server harness (uvicorn + KillableProxy) — the exact scenario the P07 de-flake documented as uncovered.
|
||||
31. **D-046 v0.5 wave order (D-R08)** — P1 seq-lease (smallest, protocol-only, fixes transport before env phases add reconnecting producers), P2 voice, P3 identity, P4 environments, P5 final. Phase numbers renumbered accordingly (was 1=voice..4=seq-lease in the initial ROADMAP draft).
|
||||
|
||||
### v0.7 Research Conclusions (Phase 0 RESEARCH — Test Harness Review, NFR)
|
||||
|
||||
44. **Harness baseline (probe-verified, 2026-09-26)** — 785 tests all green: pytest 671 (64 files, ~15.4K LOC), web node:test 53 (7 files, 1,379 LOC), CLI node:test 61 (11 files, 1,284 LOC). Zero coverage tooling, zero framework timeouts (ad-hoc 20-30s polling deadlines + a 600s CLI bootstrap), zero markers despite a real three-tier split (fast-unit / real-uvicorn / real-userns), zero `filterwarnings` policy. Web tests are OUTSIDE the turbo pipeline (no `@nextcraft/web#test` task; no root test/web:test script). PLAN.md verification counts stale (488/22/61 vs actual 671/53/61). `packages/{types,mock-data,ui}` have zero tests (cross-package contracts pinned only via other apps' source-read tests).
|
||||
45. **Warning census (probe-verified)** — 1,716 warnings per pytest run: ~99% from 14 `session.query()` sites (SQLModel deprecation: `marketplace/store.py` ×12 lines 490-952, `identity/store.py` ×2 lines 172/189); 14 ResourceWarning sites (unclosed `UploadFile` multipart ×10 from `api/defense.py:214` + `api/marketplace.py:446`; unclosed sockets ×3 `scripts/sandbox-agent.py:559`; unclosed file ×1 `tests/grading/test_engine.py:428`); 2 upstream import-time deprecations (fastapi/starlette TestClient httpx, anyio BlockingPortal). First-party deprecated-API audit CLEAN: no `datetime.utcnow`, no distutils/pkg_resources, no pydantic-v1 idioms, no legacy Next APIs. `next build` warning-free; no `ignoreBuildErrors`/`ignoreDuringBuilds` flags; ruff passes clean; all 5 tsc projects pass.
|
||||
46. **Suppression census** — 81 Python `# noqa` ALL inert (RUF100: suppressed codes not in the enabled select-set — SLF001 ×57, ANN001 ×12, E402 ×5 stale-positions, rest scattered); 5 orphaned `eslint-disable` comments (no ESLint exists — turbo lint is a no-op for 5 of 6 packages; `next lint` deprecated); 27 Python `# type: ignore` (11 src, 16 tests) with no type checker; zero `@ts-ignore`/`any` in TS. Dead exports: `packages/mock-data/ai-scenarios.ts` module, `NavItem`/`ThemeConfig` types, `Spawn` type, unused `MicControl` primitive. `FastAPI(version="0.3.0")` hardcoded vs pyproject "0.2.0" vs shipped v0.6 — version drift.
|
||||
47. **Duplicated harness infrastructure** — `KillableProxy` ×2 (`tests/sandbox/test_sandbox_agent.py:275`, `tests/telemetry/test_durability.py:45`); `_free_port()` ×4; userns probe ×4; real-uvicorn serve-loop boilerplate ×5; full-app state-injection boilerplate ×6; `wirePosting()` ×2 (web). 18 one-off MockProvider scripted subclasses (some consolidation possible, low priority). Flakiness already documented in-tree: `test_sandbox_agent.py:542` "8s was flaky" — timeout-widening is the current mitigation strategy, replaced by D-054's bounds + deterministic waits where possible.
|
||||
48. **Low-value test census** — web: 21/53 are source-text regex pins (`readFileSync` + literal-string asserts, e.g. "openGraph exists in a file"), triaged per D-057 (keep iff documented regression lesson or invisible cross-boundary wiring; cut tautologies); `test_budget_constant_defined` asserts a literal defined in its own file; `tests/llm/test_mock.py` + `identity/test_identity.py::TestMockProvider` are mock-meta tests (defensible: the mock is the suite-wide engine — keep). Silent skips: `apps/cli/tests/path-invocation.test.ts:14` returns early (passes silently when binary missing); `doctor.integration.test.ts` accepts either exit code. Env leak: `tests/conftest.py:80` mutates `os.environ["AI_PROVIDER"]` without restore; `seed_verified_identity` swallows all exceptions (`conftest.py:74-75`). Web source-pin tests depend on CWD = `apps/web` (breaks from monorepo root).
|
||||
49. **D-052 tooling conclusions** — coverage: pytest-cov (Python) + c8 wrapped around existing `tsx --test` (no runner swap, no vitest); markers `fast`/`real_server`/`userns` under `--strict-markers`, unmarked defaults to fast, fast run = `-m "not real_server and not userns"`; pytest-timeout `signal` method, 60s global / 180s real-server+userns via collection hook (D-054); CLI fresh-clone E2E env-gated (`CLI_E2E=1`) into `cli:test:e2e` with a 900s budget (D-055); `filterwarnings = ["error", <narrow module+message-scoped upstream ignores>]` (D-056); mypy relaxed-first baseline (`check_untyped_defs`, `warn_unused_ignores`, no full strict) (D-058); recharts 2→3 verified by tsc + static build + existing tests (D-059); shared Python helpers in `tests/helpers/` package, fixtures stay in conftest (D-060); pyproject.toml is the single version source, `FastAPI(version=)` reads package metadata (D-061).
|
||||
50. **D-062 v0.7 wave order = dependency-ordered: wiring before composition before elimination before hardening** — P1 harness wiring (coverage/timeout/markers/turbo tasks/leak fixes) MUST precede P2 suite composition because the cut/consolidate decisions are evidence-based (P1's coverage data tells P2 what to cut); P3 warning elimination is independent of P1/P2 but ordered after them to avoid rebasing `session.exec()` conversions over moved test files; P4 lint hygiene (ESLint + noqa RUF100) after P3 so the eslint-disable resolutions happen under a warning-clean tree; P5 type+dependency hardening last (mypy adoption and recharts 3 are the riskiest changes — they run against a fully-stabilized harness, and recharts gets the fresh coverage/ESLint net as its safety net); P6 final review. ESLint adoption (D-048) rides P4 because the orphaned eslint-disable comments only become meaningful once a config exists.
|
||||
|
||||
### v0.8 Distribution & Release Architecture (Phase 0 RESEARCH — Ship Anywhere, D-063..D-072 binding)
|
||||
|
||||
**Runner fleet topology (CT 113, 10.0.0.113):** three org-level gitea-runner containers (official image `docker.io/gitea/runner:3` — the Docker Hub `gitea/act_runner` repo is deprecated, renamed `gitea/runner`) in **sibling-container mode**: each mounts the host `/var/run/docker.sock`, has its OWN `/data` volume holding the `.runner` registration file (identity + credentials; shared state = re-registration), registers once against `https://git.coreci.dev` (Gitea 1.26.1, probe-verified) with the founder-provided org-level registration token (chat-only, reset after fleet green). Hybrid labels via `GITEA_RUNNER_LABELS`: `node` → `docker://node:24-bookworm`, `python` → `docker://python:3.11-bookworm`, `release` → `docker://node:24-bookworm`, `ubuntu-latest` → `docker://docker.gitea.com/runner-images:ubuntu-latest` (label format `name[:schema[:args]]`; a job's `runs-on` matches label names, the `docker://` image selects the job container). Per-runner `runner.capacity: 1` (default; also caps per-job docker subnet consumption) → fleet concurrency 3. Cache: each runner's built-in cache server must be pinned to a job-reachable `cache.host` + fixed published port (dockerized runners otherwise create per-job networks the cache address is unreachable in — `actions/cache` fails ETIMEDOUT); 32 GB disk guarded by `cache.repo_size_limit`, `health_check.min_free_disk_space_mb`, and a host-side `docker system prune` cron. Workspace-path alignment inside/outside the runner container is NOT needed for our jobs (no `container.bind_workdir`; `docker build` TARS the context over the API) — documented as the known sibling-mode gotcha if a future workflow bind-mounts.
|
||||
|
||||
**Workflow lanes (`.gitea/workflows/`):** push lane on `on: push` (all branches) — node lane (label `node`): corepack pnpm 12.3.4 → `pnpm install --frozen-lockfile` → eslint + tsc via `pnpm --filter` over the 5 TS workspaces (never wholesale `turbo lint`, which pulls the venv-dependent ai-service task) → `pnpm web:test` + `pnpm cli:test`; python lane (label `python`): `scripts/bootstrap.sh` venv → `scripts/lint.sh` (ruff) → `scripts/typecheck.sh` (mypy) → `FAST=1 scripts/test.sh` (fast subset `-m "not real_server and not userns"` per D-063 — real_server/userns NEVER in job containers; docker's default seccomp blocks nested `unshare --user` there anyway); ubuntu-latest lane: workflow_dispatch hello-world only (fleet verification, REQ-8-001). Tag lane on `on: push: tags: ['v*']` is the EXCLUSIVE owner of release assets + registry push (D-064): builds from the tag checkout (jobs never have `dist/`), stamps `build:binary` with `gitea.ref_name`, attaches binaries + debs + checksums via the same REST API scripts/release-assets.sh uses (`GET /releases/tags/{tag}` → `POST /releases/{id}/assets`), never `actions/upload-artifact`. Secrets: `REGISTRY_TOKEN` + `GITEA_TOKEN` as org/repo Actions secrets (self-serve `PUT /api/v1/repos/…/actions/secrets/{name}` first, founder UI fallback); the automatic per-job `GITEA_TOKEN` CANNOT publish packages (upstream-confirmed) — the PAT secret is the only viable registry credential. pnpm/pip caches via `actions/cache@v4` (runner 3.x serves cache v2 + self-patches stock actions ≥4.2/4.4) once cache-host pinning is live; deterministic fallback = volume-mounted stores via `container.options` + `valid_volumes`.
|
||||
|
||||
**Image layers (multi-stage Dockerfile, `git.coreci.dev/coreci/nextcraft`, amd64 only per D-069):** deps/build stages — web export stage (`node:24-bookworm`, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build` → `apps/web/out`, ~16 MB; the URL is build-time-only, inlined by engine-base-url.ts) + venv stage (`python:3.11-bookworm`, `pip install` prod deps from pyproject — no dev extras); runtime stage `python:3.11-slim-bookworm` (~400–500 MB total with the venv ~130 MB + web export + the D-066 CLI-binary embed at `/usr/local/bin/nextcraft`, ~117 MB): non-root `USER nextcraft`, `AI_WEB_STATIC_DIR=/opt/nextcraft/web` (single-port StaticFiles mount, D-039), `NC_CONTAINER=1` entrypoint marker, `HEALTHCHECK CMD` urllib `GET /health` (slim has no curl), `VOLUME /home/nextcraft/.nextcraft` (state root `~/.nextcraft` = DB `data/nextcraft.db` + `sandboxes/`, env-overridable via `AI_DB_PATH`/`AI_SANDBOX_DIR` with `~` expansion — config.py:39/77/86-93), `CMD uvicorn ai_service.main:app --host 0.0.0.0 --port 8420`. Zero AI_* env needed at build; all real-provider keys stay optional at runtime (G-11 fallbacks). Unprivileged by default; the sandbox fabric honestly degrades inside docker (below).
|
||||
|
||||
**Deb/binary asset matrix (every release, tag-workflow-built):** `nextcraft-linux-x64` + `.sha256` (existing pipeline, build-binary.mjs SEA), `nextcraft-linux-arm64` + `.sha256` (cross-build: esbuild bundle is arch-agnostic; the ONLY arch-specific step is `copyFileSync(process.execPath, out)` — replaced by a copy from the official `node-v<ver>-linux-arm64.tar.xz` base, probe-verified 200/30 MB; postject adds an ELF note, pure file surgery, never runs the target — QEMU-free per D-69, optional binfmt `--version` smoke if binfmt is registered on CT 113), `nextcraft_<ver>_amd64.deb` / `nextcraft_<ver>_arm64.deb` + `.sha256` (`dpkg-deb --root-owner-group -Zxz`, never executes payload → cross-arch safe; control fields: Package nextcraft, Version = tag minus `v` (policy-clean), Architecture per build, empty Depends, `Recommends: nodejs (>= 18), python3 (>= 3.11), git, util-linux` — soft deps, D-067), binary at `/usr/bin/nextcraft`, postinst = advisory `nextcraft doctor` (never fails dpkg) + next-steps, NO systemd unit (`deploy --service` owns unit generation) + NO proxy fragments. install.sh arch-gate becomes arch-resolve (`x86_64`→x64, `aarch64|arm64`→arm64; sha256 + version integrity gates unchanged). Image tags per D-065: semver `<tag>` + `latest` on newest release, no per-push tags.
|
||||
|
||||
**Channel upgrade semantics (D-071):** docker = `docker pull git.coreci.dev/coreci/nextcraft:<tag>` + recreate the container (state persists in the named `nc-state` volume); `deploy`/`bootstrap`/`dev` refuse in-container (D-066 — container check before the repo-root checks, pointer to the docker paths; `doctor`/`verify`/`guide` remain allowed, `verify` gains a container-mode branch checking the mounted web dir + `/health` instead of a repo clone). deb = `dpkg -i` the new deb + `nextcraft deploy --service` (idempotent v0.4.6 rail). binary = re-run install.sh + `nextcraft deploy --service`. doctor detects the channel (NC_CONTAINER/`/.dockerenv` for docker; `dpkg -s`/`/usr/bin/nextcraft` for deb) and describes its upgrade path; `nextcraft guide` serves offline-first embedded man-page text channel-appropriately (D-072 — TS constants bundled by esbuild into the SEA binary, zero runtime fetches).
|
||||
|
||||
**Sandbox degrade in containers (D-068):** capability probe promoted verbatim from tests/helpers/userns.py into `ai_service/sandbox/capability.py` (`userns_available()` = `shutil.which("unshare")` guard + real `subprocess.run(["unshare","--user","--map-root-user","true"])`) — the truth source at boot + per-create; container markers (`/.dockerenv`, `NC_CONTAINER=1`, cgroup string) only flavor remediation text. Degrade: boot notice (the main.py:404 mount-less / main.py:202 admin-token G-11 warning pattern), learner sandbox create rejects honestly with 503 + remediation pointer (a new `except SandboxUnavailableError` arm beside the existing `PoolFullError→503` in api/sandboxes.py:157-160 — today it would 500), doctor surfaces the degraded capability via a real userns attempt (upgrading the `which unshare` check, doctor.ts:138-150); chat/agents/grading untouched. Supersedes A-101's cwd-jail fallback for deployed schools (never implemented — no code removal needed).
|
||||
|
||||
**Pre-commit rails (D-070):** ONE root `.pre-commit-config.yaml`, local hooks only (ruff on Python paths; eslint/tsc via workspace-scoped invocations), no network at hook run; missing toolchain = loud failure, never silent skip; CI is the authoritative backstop.
|
||||
|
||||
### Confirmed Technology Stack (v0.2)
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
@@ -47,6 +100,16 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
|
||||
7. **D-022 Monorepo integration** — zero-dependency shim `package.json` in apps/ai-service + `ai#*` turbo passthrough tasks (`cache:false, outputs:[]`) + root `ai:dev`/`ai:test` scripts + idempotent venv bootstrap.
|
||||
8. **D-023 Testing** — pytest-asyncio auto mode; TestClient `client.stream()` for SSE; httpx MockTransport for byte-exact provider parser tests; scripted mock provider incl. failure modes. Tests never call the cloud.
|
||||
|
||||
### v0.4 Architecture Decisions (from Research — Distribution & Bootstrap CLI)
|
||||
|
||||
18. **D-033 Binary toolchain = Node SEA (probe-verified)** — Go and Rust are absent from this box; node v24.15.0 ships SEA support (`--experimental-sea-config`, postject-free on linux via `cp node nextcraft && node sea-config` … blob injection with the system `dd`/`npx postject` if needed). CLI source lives in `apps/cli` (TypeScript, compiled to a single CJS bundle by esbuild, then SEA-injected into a copy of the node binary → `nextcraft-linux-x64`). Fallback if SEA breaks: python3 `zipapp` (3.11.2 available). No new toolchain deps beyond dev-scoped esbuild.
|
||||
19. **D-034 CLI = thin wrapper, orchestration stays in scripts/** — `nextcraft` composes `apps/ai-service/scripts/bootstrap.sh` and `scripts/dev.sh` equivalents via `spawn` with inherited stdio and timeout guards (A-202/A-209). doctor/bootstrap/verify implement only *checking* logic (prereqs, env template, health) — never re-implement installs. This keeps one source of truth for bootstrap semantics.
|
||||
20. **D-035 Install path = repo raw `install.sh` + Gitea latest-release API** — the one-liner `curl -fsSL <forge>/coreci/nextcraft/raw/main/scripts/install.sh | bash` resolves `GET /api/v1/repos/coreci/nextcraft/releases/latest`, downloads the `nextcraft-linux-x64` + `nextcraft-linux-x64.sha256` assets, verifies sha256 (`shasum -a 256`), installs to `~/.local/bin` (PATH hint), and degrades to printed source-bootstrap instructions when no binary asset exists or the platform mismatches (A-203/A-204/A-206).
|
||||
21. **D-036 Ongoing binaries = ship-workflow asset step** — the release pipeline (v0.3's `ShipWorkflow.createRelease` equivalent, executed as the ship step's asset stage) builds the binary + checksum and attaches both to every Gitea release from v0.4 onward (A-205). Token resolution stays `.env*`-only (D-006/D-014); binaries are linux x64 only for v0.4 (macOS arm64 deferred — unverifiable on this box).
|
||||
22. **D-037 CLI package layout** — `apps/cli` is a pnpm workspace package (`@nextcraft/cli`): `src/` (entry, commands/, checks/, lib/), `scripts/build-binary.mjs` (esbuild bundle → SEA inject), unit tests runnable via `pnpm --filter @nextcraft/cli test` (node:test, no new test framework). Root `package.json` gains `cli:*` passthrough scripts mirroring the `ai:*` pattern (D-022).
|
||||
23. **D-038 Network mode (v0.3.5)** — dev binds 0.0.0.0 (`AI_HOST`, default 0.0.0.0, revert via 127.0.0.1); CORS + WS-origin gates read `AI_CORS_ORIGINS` (default `*` — any origin, safe only because credentials are never enabled; explicit comma list restricts); the web client derives the API base URL from the browser hostname at runtime (`engine-base-url.ts`: `NEXT_PUBLIC_AI_SERVICE_URL` override → `http://${window.location.hostname}:8420` → `localhost` server-side). Hotfix also fixes: SEA direct-run detection (`require("node:sea").isSea()` — argv shape differs by invocation style), installer honesty gate (silent `--version` = hard fail), bootstrap venv recovery (poisoned partial `.venv` removal + distro-specific `apt install python3.XX-venv` hint), and doctor venv-capability probe with bootstrap preflight.
|
||||
24. **D-039 Single-port deploy + unattended dev (v0.3.6)** — only :8420 is reachable behind HAProxy, so the web app ships as a **static export** (`output: 'export'`, `NEXT_PUBLIC_AI_SERVICE_URL=self` → relative same-origin fetches) served by the ai-service itself (`AI_WEB_STATIC_DIR` StaticFiles mount, default off; `nextcraft dev` auto-wires it when `apps/web/out` exists). Daemon surface: `dev -d` (detached, `~/.nextcraft/run/<clone-hash>/dev.{pid,log}`), `stop`, `log [-n N|-f]`. Durable state (DB `~/.nextcraft/data/`, sandbox workdirs `~/.nextcraft/sandboxes/`) moves **out of the repo** (founder directive; `expanduser` validator makes `AI_DB_PATH=~/...` env overrides work). API routes beat the static mount; unknown paths serve the export's 404.html.
|
||||
|
||||
### v0.3 Architecture Decisions (from Research — Credential Engines)
|
||||
|
||||
9. **D-024 Sandbox isolation = Linux namespaces via `unshare`** — per-learner sandbox runs as a subprocess entered into fresh user+mount+pid+network namespaces (`unshare --user --map-root-user --mount --pid --fork --net`). Probe-verified on this box: in-namespace uid=0, **network fully isolated** (0 interfaces), learner writes land in a per-sandbox directory; proc-remount not permitted here but not required. Chosen because no container runtime (docker/podman/bwrap/firejail) exists on the box and there is no sudo. A `SandboxBackend` protocol abstracts the spawner so a future containerd/runc backend can replace namespace-spawning without touching callers.
|
||||
@@ -67,7 +130,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
|
||||
|
||||
| Component | Description | Boundaries | Depends On |
|
||||
|-----------|-------------|------------|------------|
|
||||
| `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/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; v0.3.6: optional StaticFiles mount of the exported web app when `AI_WEB_STATIC_DIR` is set (single-port deploy, D-039) | 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/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 |
|
||||
@@ -84,16 +147,32 @@ 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/marketplace/` | **v0.6 NEW (REQ-6-001..006, D-048..D-056):** `base.py` (pydantic contracts: Posting/Employer/PostingTags/IngestPosting), `store.py` (6th D-027 store, SIX tables per G-104: posting/employer/embedding/application/employer_enrichment/learner_profile), `ingest.py` (PostingSource protocol + seeded corpus, idempotent upserts G-102), `tagging.py` (D-020 one-shot tag extraction, overwrite semantics D-052), `embedding/` (EmbeddingProvider protocol + deterministic feature-hash mock + OpenAI-compatible real path + G-11 factory, D-051), `search.py` (hybrid facet+cosine ranking, embed-on-publish, skill_breakdown), `enrichment.py` (append-only provenance rows + merge-on-read manual>llm>raw, D-053), `resume.py` (pypdf text extraction + bounds + D-020 draft extraction, D-054); HTTP surface in `api/marketplace.py` (public + apply + resume), `api/marketplace_admin.py` (fail-closed D-057), `api/marketplace_shells.py` (G-101 catch-all), `api/sitemap.py` (dynamic sitemap/robots, D-056) | Never imports agents/ or api/; api/ composes 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) | gitignored | — |
|
||||
| `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 |
|
||||
|
||||
**Boundary additions:** `sandbox/`, `telemetry/`, `grading/`, `variants/`, `voice/` are engine modules — they never import `api/` (which composes them via DI) and never import `agents/` (agents call engines through narrow interfaces, not vice versa).
|
||||
|
||||
### apps/cli — Nextcraft Bootstrap CLI (v0.4 NEW)
|
||||
|
||||
| Component | Description | Boundaries | Depends On |
|
||||
|-----------|-------------|------------|------------|
|
||||
| `src/index.ts` | Entry: arg parsing (no deps beyond node stdlib at runtime), command dispatch, `--help`/`--version`, exit-code contract (0 ok / 1 failure / 2 usage) | CLI surface only | commands/ |
|
||||
| `src/commands/` | `doctor.ts` (prereq checks + actionable errors), `bootstrap.ts` (pnpm install + scripts/bootstrap.sh wrapper + env template copy + key validation), `verify.ts` (health: venv imports, ports, env, build readiness), `dev.ts` (thin passthrough to scripts/dev.sh) | Compose checks/ + lib/; spawn scripts — never re-implement them | checks/, lib/ |
|
||||
| `src/checks/` | Pure check functions: `check-command.ts` (binary-on-PATH + version compare), `check-env.ts` (template diff, required/optional key classification) | Pure logic, unit-testable, no fs side effects at import | None |
|
||||
| `src/lib/` | `spawn.ts` (subprocess with timeout + inherited stdio), `log.ts` (✓/✗/warn output formatter) | Shared utilities | None |
|
||||
| `scripts/build-binary.mjs` | esbuild → CJS bundle → Node SEA injection → `dist/nextcraft-linux-x64` + sha256 sidecar | Build-time only | esbuild (dev dep) |
|
||||
| `scripts/install.sh` | The one-liner install script served from repo raw: Gitea latest-release resolve → download + checksum verify → ~/.local/bin; source-bootstrap fallback | Standalone POSIX sh | forge API |
|
||||
| `tests/` | node:test unit tests: command dispatch, check logic, env template diff, install-script shellcheck-style assertions | Fixtures only — never mutate repo state | src/ |
|
||||
|
||||
**Boundary rules:** the CLI never imports from `apps/web`, `packages/*`, or `ai_service` Python modules — it orchestrates them exclusively via subprocess/filesystem. Runtime deps: node stdlib only (no runtime npm deps; esbuild is dev-only). The binary embeds the bundle; `scripts/bootstrap.sh` remains the single source of bootstrap truth (D-034).
|
||||
|
||||
### apps/web — Next.js Application
|
||||
|
||||
| Component | Description | Boundaries | Depends On |
|
||||
@@ -105,7 +184,7 @@ Deliberately **not** used: openai-python SDK (the `LLMProvider` protocol is the
|
||||
| `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); 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; 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, 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 |
|
||||
| `lib/` | sse.ts (shared SSE frame parser — CRLF normalization + `: ping` immunity, v0.2 G-1), breadcrumbs.ts, format.ts, engine-base-url.ts (v0.3.5: runtime API base — env override → browser hostname → localhost), 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
|
||||
|
||||
@@ -193,7 +272,13 @@ Composite/layout/theme components (navigation shell, tables, chat panels, graph
|
||||
|
||||
---
|
||||
|
||||
## Build Order (v0.3)
|
||||
## Build Order (v0.4)
|
||||
|
||||
1. **Bootstrap CLI core** — apps/cli package: doctor checks (node/pnpm/python3/git/unshare), bootstrap wrapper (pnpm install + scripts/bootstrap.sh + .env template + key validation), verify health check, dev passthrough; unit tests
|
||||
2. **Binary build + release pipeline** — esbuild bundle → Node SEA binary (`nextcraft-linux-x64`) + sha256 sidecar; install.sh one-liner (Gitea latest-release resolve + checksum verify + PATH install); release-asset upload wired into the ship flow (ongoing binaries from v0.4 onward)
|
||||
3. **Install docs + fresh-clone E2E** — README quickstart (one-liner → doctor → bootstrap → dev), CLI reference, fresh-clone end-to-end test proving a clean clone reaches a running stack
|
||||
|
||||
## Build Order (v0.3 — complete)
|
||||
|
||||
1. **Sandbox fabric** — SandboxBackend protocol + unshare namespace spawner + lifecycle manager (create/list/snapshot/destroy) + concurrency guard + per-sandbox workdir; isolation + resource-limit probes
|
||||
2. **Live build telemetry** — TelemetryEvent models + SQLite TraceStore + WebSocket ingest endpoint + seq gap detection + in-sandbox capture agent
|
||||
@@ -217,16 +302,19 @@ The v0.1 build order (monorepo → types → mock data → tokens → primitives
|
||||
|
||||
---
|
||||
|
||||
## Future Architecture (Post-v0.3, for reference)
|
||||
## Future Architecture (Post-v0.4, for reference)
|
||||
|
||||
v0.3 delivers the real credential engines; later milestones fill in the remaining platform:
|
||||
v0.4 delivers distribution (CLI + binary releases); later milestones fill in the remaining platform:
|
||||
|
||||
- **In-memory sessions → PostgreSQL + Drizzle/SQLModel** — SessionStore + v0.3 TraceStore/GradeStore/VariantStore/DefenseStore protocols swap SQLite→Postgres with no API changes
|
||||
- **userns subprocess sandboxes → containerd/runc backend** — D-024 `SandboxBackend` protocol swap; same lifecycle API
|
||||
- **Coding-IDE sandbox → design tool + simulation environments** — REQ-F-021 full scope (v0.4)
|
||||
- **Coding-IDE sandbox → design tool + simulation environments** — REQ-F-021 full scope (v0.5)
|
||||
- **Mock provider → per-agent model routing** — provider factory already selects by config; per-agent `AI_<AGENT>_MODEL` overrides
|
||||
- **No auth → real KYC + sessions** — **deferred per founder directive**; REQ-F-017 identity/age-gating lands post-v0.3 (v0.4+). Age-gating remains the v0.1 visual flow mockup
|
||||
- **No auth → real KYC + sessions** — **deferred per founder directive; moved to v0.5 with D-016**; REQ-F-017 identity/age-gating lands post-v0.4. Age-gating remains the v0.1 visual flow mockup
|
||||
- **Mock voice → real server STT/TTS (openai-audio provider)** — CUT-1/G-7 seam moved to v0.5 per D-016; VoiceProvider protocol is the drop-in point
|
||||
- **linux x64 binary → macOS arm64 + auto-update** — D-036 defers non-linux targets (unverifiable on this box); `nextcraft upgrade` (self-replace from latest release) is the natural v0.5+ follow-up
|
||||
- **Exec-telemetry seq-lease / replay-margin fix** — the P6-lesson one-line ACK gap moves to v0.5 per D-016
|
||||
- **No search → Semantic vector search (pgvector)** — Filter UI replaced with vector similarity search
|
||||
- **No payments → Payment processing** — Pricing page replaced with real subscription/payment flows
|
||||
|
||||
The monorepo structure (apps/web + apps/ai-service + packages/*) accommodates further apps without restructuring.
|
||||
The monorepo structure (apps/web + apps/ai-service + apps/cli + packages/*) accommodates further apps without restructuring.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"stage": "ship",
|
||||
"milestone": "v0.8",
|
||||
"milestone_name": "ship-anywhere",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-29T14:50:00Z"
|
||||
}
|
||||
@@ -1,3 +1,51 @@
|
||||
# Nextcraft v0.6 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS · **Confidence:** 0.72 · **Date:** 2026-09-21
|
||||
|
||||
## Summary
|
||||
|
||||
The v0.6 Marketplace Engine plan (46 tasks, 6 phases) is well-researched and tightly scoped, but its central P1 web-migration mechanism — removing `generateStaticParams` from the two marketplace dynamic routes — is **provably build-breaking**: Next 15.5.25 (installed `node_modules/next/dist/build/index.js:1244`) hard-throws error E87 for any dynamic route without `generateStaticParams` under `output: 'export'`, and even a function returning `[]` throws (`length > 0` check). The claimed "proven build/defend pattern" precedent does not exist as described — build/defend routes KEPT their `generateStaticParams`. Four quieter gaps (ingest status semantics, pagination-vs-client-filters, learner-profile sidecar persistence, sitemap origin) each ship a broken happy path or an audit flag. All fixed via binding decisions G-101..G-110 + 8 plan edits applied by the orchestrator before execute.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| Axis | Verdict | Rationale |
|
||||
|------|---------|-----------|
|
||||
| Feasibility | PASS | Personas/territories match proven patterns (voice factory, identity store, D-020 layer); 46 tasks comparable to shipped v0.5. |
|
||||
| Scope discipline | PASS | Pricing/employer-auth/scheduler/crawler all absent (MH-M6, A-407/A-412, D-050); only borderline add is admin posting-edit, defensible as the re-tag trigger within A-401. |
|
||||
| Vertical-slice integrity | FAIL → fixed | P1 backend+shells coupling is right, but the shell mechanism threw E87 at build and runtime-created postings would get no exported page — fixed by G-101 (placeholder param + FastAPI catch-all shell routes). |
|
||||
| Testability | AT-RISK → fixed | MH-1e pinned a build that could not emit; MH-3d's "matchScore exists nowhere" overclaimed (mock-data retains it for employer/admin mockups) — scoped by G-107/G-110. |
|
||||
| Security posture | PASS | Fail-closed admin, D-043 gate order, upload bounds, PII sentinel tests verified against existing code; hardening added by G-105/G-108. |
|
||||
| Performance/capacity | PASS | Brute-force cosine ≤10⁴ honest; SQLite WAL/busy_timeout verified in the identity store; sitemap fine to 50k URLs. |
|
||||
| Operational readiness | AT-RISK → fixed | D-039 deploy fabric intact (mount order verified), pypdf via bootstrap channel sound; founder workflow breaks at ingest-status (G-102) and sitemap-origin (G-105) — now defined. |
|
||||
| Dependency risk | PASS | pypdf verified as the only new dep; JSON-LD/sitemap hand-rolled; embedding mock stdlib-only. A-404's stale "ollama-cloud exposes /embeddings" parenthetical annotated as superseded by D-051. |
|
||||
| Adoption/UX | AT-RISK → fixed | Happy-path steps 2-4 contradicted until ingest status defined (G-102); verify-CTA/404/409 states well-specified; sitemap URL-resolvability pinned (G-109). |
|
||||
|
||||
## Binding Decisions (applied to PLAN.md before execute)
|
||||
|
||||
- **G-101** — Do NOT remove `generateStaticParams` (E87 build break under `output:'export'`). Keep it returning one placeholder param per route; export one client shell; add FastAPI catch-all `GET /marketplace/jobs/{id}` + `/employers/{id}` (trailing-slash variants) serving the shell — same routes-beat-the-mount mechanism as sitemap. [Vertical-slice integrity]
|
||||
- **G-102** — Ingest-status contract: seed-port postings enter `published` with original `postedAt`; admin-authored start `draft`. [Adoption/UX]
|
||||
- **G-103** — Board fetch = paged fetch-all (≤10⁴ documented); API returns `total_published`; "Showing X of Y" over the fetched set with honest truncation notice. [Performance]
|
||||
- **G-104** — Confirmed learner profiles persist in the marketplace store as a 6th table `learner_profile` (learner_id PK, profile JSON, confirmed_at, model) — NOT a filesystem sidecar. D-049 amended five→six tables. [Scope discipline]
|
||||
- **G-105** — Unset `AI_PUBLIC_BASE_URL`: loud G-11 boot warning; sitemap serves empty static set + warning (never malformed/relative URLs); robots omits the Sitemap directive; boot never crashes. [Security]
|
||||
- **G-106** — Match scores appear only in search context; cards without an active query show no score; per-learner profile-driven card scores post-v0.6; REQ-6-003 "scores on job cards" satisfied conditionally — recorded. [Adoption/UX]
|
||||
- **G-107** — "Mock matchScore absent" scoped to marketplace-path API payloads + marketplace surfaces; grep excludes packages/mock-data and employer/admin route groups. [Testability]
|
||||
- **G-108** — `admin_token` as `SecretStr`; compare via `secrets.compare_digest`. [Security]
|
||||
- **G-109** — Every sitemap job/employer URL must serve 200 on the single-port app (G-101 shells); board ItemList JSON-LD enumerates static structure only — never runtime postings. [Operability]
|
||||
- **G-110** — P1 web tests pin the E87 regression class itself (shells emitted; catch-all serves arbitrary ids; `notFound()` at non-placeholder id via full page load); the honest-501 stub pin at tests/identity/test_identity.py:444 rewritten onto the marketplace router in the same phase. [Testability]
|
||||
|
||||
## Plan Edits Applied
|
||||
|
||||
1. Task 1-3-04 + MH-1e rewritten per G-101 (placeholder shells + catch-all routes; E87 test class; stub-pin rewrite)
|
||||
2. Task 1-2-01 + MH-1f ingest-status contract (G-102)
|
||||
3. Task 1-3-02 paged fetch-all + total_published (G-103)
|
||||
4. Task 4-B-03 learner_profile table replaces the JSON sidecar (G-104; D-049 → six tables)
|
||||
5. Task 5-2-01 + MH-5a/5b unset-origin behavior + URL-resolvability (G-105/G-109)
|
||||
6. Task 3-3-01 + MH-3d matchScore scope + no-query-no-score (G-106/G-107)
|
||||
7. Task 1-2-03 SecretStr + compare_digest (G-108)
|
||||
8. PROJECT.md A-404 stale parenthetical annotated as superseded by D-051 probe
|
||||
|
||||
---
|
||||
|
||||
# Nextcraft v0.3 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-CHANGES · **Confidence:** 0.72
|
||||
@@ -46,3 +94,127 @@ The credential-pipeline architecture (telemetry → trace → grade → defense)
|
||||
## Outcome
|
||||
|
||||
**GO** — all six binding decisions and both scope cuts applied to PLAN.md / REQUIREMENTS.md / ROADMAP.md / PROJECT.md before Phase 1 execution. No axis requires escalation (all resolvable at confidence ≥ 0.85). The milestone no longer claims resource enforcement it cannot deliver, and the no-auth abuse vector is closed at MVP scale.
|
||||
|
||||
---
|
||||
# Nextcraft v0.4 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-CHANGES · **Confidence:** 0.83
|
||||
|
||||
## Summary
|
||||
|
||||
The distribution milestone is small, founder-directed (D-016, confidence 0.99), and additive (zero changes to the running credential pipeline). The plan's central risk: **Node SEA was probe-verified as a flag, not as a working build** — the v0.3 lesson (A-101: probe the mechanism, not the existence) applies. Second gap: a binary whose `--version` lies (stale package.json) would poison the "ongoing binaries" contract. Third: sed-based JSON parsing in install.sh is a fragility + integrity risk. Fourth: "ongoing binaries" has no enforcement mechanism beyond prose. All four closed by binding decisions G-101..G-104 below. No scope cuts required — the milestone is already minimal.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| Axis | Verdict | Rationale |
|
||||
|------|---------|-----------|
|
||||
| Business case | PASS | Founder directive explicit + recorded (D-016). Evidence of need: live Gitea probe shows latest release v0.2.8 with ZERO assets; bootstrap requires repo archaeology (scripts found only via package.json spelunking). |
|
||||
| Scope | PASS | 5 REQs, 3 execution phases, one focused surface (apps/cli + scripts). Smallest milestone yet. macOS arm64 already cut (D-036, unverifiable here). |
|
||||
| Feasibility | CONCERN (fixed) | SEA flag exists on node v24.15.0, but no end-to-end SEA binary was built during RESEARCH. postject availability assumed (`npx postject` — needs npm registry reachability, unproven). Zipapp fallback requires python3 on target — an honest-degradation ladder, not a silent downgrade. → G-101. |
|
||||
| Honest versioning | CONCERN (fixed) | `--version` from package.json would print a stale hardcoded version inside a per-release binary — breaks upgrade detection + the one-liner's re-run-to-upgrade promise. → G-102. |
|
||||
| Install integrity | CONCERN (fixed) | sed/grep JSON parsing is brittle; a parse failure must never fall through to installing an unverified artifact. Exact asset-name matching + hard-degrade to source instructions. → G-103. |
|
||||
| Sequencing | PASS | P1 CLI (source-runnable) → P2 binary+pipeline → P3 docs+E2E matches dependency order; each phase ships independently. |
|
||||
| Cost/quota | PASS | Zero new paid infra; binaries built on-box; Gitea releases free. Dev-only esbuild dep. |
|
||||
| Risks | CONCERN (fixed) | Top 3: SEA end-to-end (→ G-101 live probe FIRST in P2), npm registry reachability for esbuild (→ proven by P1's pnpm install must-have), Gitea asset-upload token scope (→ live-proven at the v0.3.2 ship itself). |
|
||||
| Adoption/operability | PASS | Consumer = founder + future pilots; one command replaces README archaeology. Rollback trivial (rm ~/.local/bin/nextcraft). No server changes. |
|
||||
|
||||
## Binding Decisions (applied to PLAN.md)
|
||||
|
||||
- **G-101 (BINDING) — SEA live-build probe is the FIRST P2 action.** Task 2-1-01 builds a real binary before anything depends on it; the build script encodes the fallback ladder explicitly (SEA → zipapp with "requires python3" honesty). If SEA fails on this box, zipapp becomes primary with the docs stating the requirement — no silent claim of node-less operation.
|
||||
- **G-102 (BINDING) — Version stamping at build time.** `build-binary` accepts the shipping tag and stamps it into the bundle (`NEXTCRAFT_VERSION` replace); `--version` prints it; install E2E asserts the installed binary reports the tag it was downloaded from. A binary may never report a version it was not built as.
|
||||
- **G-103 (BINDING) — Install-script integrity hard-degrade.** install.sh matches assets by EXACT name (`nextcraft-linux-x64`, `nextcraft-linux-x64.sha256`); any parse/lookup/download failure degrades to source-bootstrap instructions (exit 0) — never installs unverified or name-approximate artifacts. Checksum mismatch = hard stop, exit 1, explicit do-not-run message. dash-safe POSIX sh, no jq.
|
||||
- **G-104 (BINDING) — Ongoing-binaries enforcement.** Every ship from v0.3.2 onward MUST run `scripts/release-assets.sh <tag>` after tag+merge (best-effort, non-blocking, `release_pending` escalation on failure — but attempted + logged every release). The final-phase audit gate includes "milestone release carries both assets" as a check. This makes the founder's "ongoing binaries" directive a pipeline property, not prose.
|
||||
|
||||
## Escalations
|
||||
|
||||
None. All four concerns resolved at confidence ≥ 0.85. No axis requires founder escalation (directive already explicit).
|
||||
|
||||
## Outcome
|
||||
|
||||
**GO** — G-101..G-104 applied to PLAN.md before Phase 1 execution. The milestone claims only what its probes prove, and the ongoing-binaries contract has an enforcement mechanism.
|
||||
|
||||
---
|
||||
|
||||
# v0.5 GRILL (Phase 0, 2026-09-13) — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease
|
||||
|
||||
**Verdict: GO-WITH-CHANGES · Confidence: 0.78** — CUT-3 + G-9..G-18 binding; applied to PLAN.md before P1 execution.
|
||||
|
||||
**Evidence:** every load-bearing plan claim verified against ground-truth code and held (fmt bug defense.py:189, descriptor flow defense.py:143, supervisor frame discard sandbox-agent.py:487-496, no spool cap, dead test_command field, one-line replay margin sandbox-agent.py:416-431, factory rejection). The research layer (D-040..D-046) sold no vapor; the misses were all *interaction* surface.
|
||||
|
||||
## Binding items (all applied to PLAN.md)
|
||||
|
||||
- **CUT-3**: MH-3e identity E2E downgraded from real-app uvicorn harness to `TestClient(create_app(...))` — identity is plain JSON HTTP; the uvicorn harness stays where transport matters (P1 durability, P4 in-ns exec).
|
||||
- **G-9**: `verified_pilot` conftest fixture MUST land with the identity gates (Wave 3-2) — variants/defense are ungated today; without the fixture the existing 413-test suite 403s en masse. MH-M6 reworded: regressions zero *for verified allowlisted pilot learners*.
|
||||
- **G-10**: engine-client must discriminate 403 reasons (`verify_cta` → new `VerifyRequiredError`; allowlist detail → existing `NotAllowlistedError`) — today every 403 collapses into NotAllowlistedError, which would render a verify-CTA as an allowlist lie.
|
||||
- **G-11**: misconfigured `openai-audio` must never crash the boot — lifespan catches the factory error, logs loudly, falls back to mock (descriptor honestly reads mock). Unattended deploy (D-039) survival rule.
|
||||
- **G-12**: client recording bound — auto-stop at 180s default + visible timer + timeslice; 413 renders "re-record" honestly (never silent answer loss).
|
||||
- **G-13**: identity submit caps — one active pending per learner (409 on resubmit) + per-learner rate cap (429); each submission becomes vendor money later.
|
||||
- **G-14**: spool-bound overflow is by-design gap creation — pinned: dropped-counter > 0 → replayed trace gapped → ungradable (never silently-truncated-but-gradable); worst-case spool ≈256MB documented against the 512MB G-2 sweep.
|
||||
- **G-15**: argv contract — templates validate shlex-roundtrip at definition (no quotes/globs); TS splits whitespace-only (no shlex in browsers); exec policy matches EXACT argv tokens (never prefix); `sh -c` passthrough disallowed for design/sim kinds (the digest-gaming vector).
|
||||
- **G-16**: `voice_tts_format` is a `Literal["mp3","wav","opus"]` enum, not a free string — it feeds a Content-Type.
|
||||
- **G-17**: MH-4e "seq-ack intact" was hope-shaped; merged into MH-4d as concrete assertions (stored seqs contiguous 0..N exactly once; spool ≤ ack margin; or cite the P1 suite where a fake agent runs).
|
||||
- **G-18**: the marketplace gated stub proves the gate then returns 501 + `stub: true` + mock markers — never a fabricated "applied" outcome (A-304 honesty house rule).
|
||||
|
||||
## Advisories (recorded)
|
||||
|
||||
a-6 ack on dedup'd appends too (tight margin); a-7 MH-1d 3× runs = one-time ship validation, not per-CI; a-8 manual voice probe must be executable-by-anyone-with-keys (fixture wav + fixed phrase, falsifiable asserts); a-9 413 Content-Length fast path before buffering; a-10 ROADMAP P4 "Depends On: 4" self-typo → fix to 0; a-11 TS `TaskVariant` fields required on the wire; a-12 identity insert-only growth fine at pilot scale; a-13 `recorder.start(1000)`; a-14 do NOT build ack batching unless a flood test shows drainer stall; a-15 provider must always carry `descriptor` (missing it would badge server as mock).
|
||||
|
||||
## Escalations
|
||||
|
||||
None. All four seams remain founder-locked via D-016 / REQ-5-001..007; every finding resolved at confidence ≥ 0.65.
|
||||
|
||||
---
|
||||
|
||||
# v0.8 GRILL (Phase 0, 2026-09-27) — Ship Anywhere
|
||||
|
||||
**Verdict: GO-WITH-BINDINGS · Confidence: 0.74** — G-201..G-210 binding (3×P0, 7×P1); applied to PLAN.md before P1 execution. (Inline orchestrator grill — three delegated griller runs returned empty; logged per Step 4 error recovery at full autonomy.)
|
||||
|
||||
**Evidence:** every load-bearing plan claim re-verified against RESEARCH.md's probe-backed census (R-1..R-32), the existing v0.4 pipeline code (build-binary.mjs, release-assets.sh, install.sh), and Gitea 1.26.1 live state. The research layer held; findings are interaction-surface and lifecycle gaps.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| Axis | Verdict | Rationale |
|
||||
|------|---------|-----------|
|
||||
| Goal coverage | PASS | All four founder options map to REQ-8-001..005 with falsifiable MHs; docker-per-phase-upload honored by the release workflow design. |
|
||||
| Feasibility | AT-RISK → fixed | QEMU-free arm64 SEA (R-25/26) probe-first is honest, but the plan pinned no failure exit; G-201 forces a decided fallback ladder. Runner fleet on 4GB is bounded by capacity=1 + label routing (R-4). |
|
||||
| Vertical-slice integrity | PASS | P1 ships a green fleet + live pipelines; P2 ships a pullable image; P3 ships installable assets; P4 ships channel-aware CLI; P5 ships the runbook. Each independently real. |
|
||||
| Wave ordering | AT-RISK → fixed | P2→P3 ordering claimed "forced" but both only extend P1's workflow; G-202 makes the true dependency explicit (asset-matrix ownership) and permits P2/P3 work within the same release lane without re-ordering phases. |
|
||||
| Testability | AT-RISK → fixed | MH-1d asserted workflow-built release assets but the release job runs on the tag push — which exists only at ship time; G-203 defines the within-phase proof (temporary tag on the phase branch). MH-2b smoke gate had no in-container test harness; G-204 pins the concrete assertions. |
|
||||
| Security posture | AT-RISK → fixed | Secrets chain mostly sound (R-6/R-13), but the REGISTRY_TOKEN self-serve PUT needs a value the .env token cannot mint — G-205 forces founder-side PAT creation with scoped package-write, closing a plan-time fantasy. CT root password rotation was "ask founder"; G-206 makes it a recorded P0 completion condition. |
|
||||
| Performance/capacity | PASS | Image budget ~400-500MB (R-18) asserted in smoke gate; 32GB disk bounded by prune cron + cache caps + health_check floor (R-5). |
|
||||
| Operational readiness | AT-RISK → fixed | The runbook (P5) would document a fleet whose bring-up lives only on CT 113; G-207 requires the compose/config/cron committed as fleet-as-code in-repo. Degrade remediation text must name the privileged variant precisely (G-208). |
|
||||
| Dependency risk | PASS | No new product deps; docker.io engine on release lane accepted-risk with documented future fix; corepack fallback decided (R-11). |
|
||||
| Adoption/UX | PASS | Three channels each end at doctor/verify green; guide offline-first with parity test; refusals honest. |
|
||||
|
||||
## Binding Decisions (applied to PLAN.md)
|
||||
|
||||
- **G-201 (P0)** — arm64 build has a decided fallback ladder: probe QEMU-free cross-arch SEA on the runner first; if the blob fails to run under emulation, fall back to binfmt+qemu for FULL arm64 builds (not just blob generation); if the runner can't install binfmt, ship v0.8 with x64-only debs/binaries and record the arm64 deferral — never leave the release lane red or blocked.
|
||||
- **G-202 (P1)** — dependency truth: P2 and P3 both consume P1's release workflow as their extension point; P2 precedes P3 for founder-priority (docker is the headline channel), not technical force. PLAN wave-order paragraph corrected; no phase reorder.
|
||||
- **G-203 (P1)** — release-workflow within-phase proof: MH-1d/D-3d prove via a temporary `v0.7.1-rc1`-style tag pushed to the phase branch head at verify time, workflow observed building + attaching to the (pre-release/draft or immediately-deleted) release, tag then deleted. The phase ship tag remains the real artifact.
|
||||
- **G-204 (P1)** — smoke gate pinned: container starts → /health 200 within timeout → `GET /` content-type text/html AND contains a known marker (not just non-JSON-404) → degrade boot notice present in logs → fabric create returns 503 with remediation → clean stop. All five assertions in the workflow step; failure = red release job.
|
||||
- **G-205 (P0)** — REGISTRY_TOKEN is a founder-minted PAT with package-write scope (the .env GITEA_TOKEN may lack it): P1's self-serve attempt becomes "PUT the secret IF the existing token can mint it — else escalate to founder with the exact scope needed (write:package + write:repository)". The plan must not assume the PUT succeeds; escalation path has a founder question, not a silent wait.
|
||||
- **G-206 (P0)** — CT 113 root password rotation is a P0 completion condition of Wave 1-1: after deploy-key install, the founder is asked to rotate; if not confirmed by Wave 1-2, an `escalation: ct_root_password_still_live` commit lands and the ship notes carry it. Never silently carried.
|
||||
- **G-207 (P1)** — fleet-as-code: the compose file, per-runner configs, and prune cron are committed in-repo (e.g. `deploy/ci/`) as the reproducible fleet definition; CT 113 deployment is a copy + `docker compose up -d` — the runbook's fleet recipe references the committed files, not box memory.
|
||||
- **G-208 (P1)** — degrade remediation text must be exact: name the unprivileged default, the seccomp constraint, the documented privileged/seccomp-override variant (docker run flags), and the host-side alternative (run the stack binary-side); the boot notice and 503 payload carry the same text (single remediation constant, tested).
|
||||
- **G-209 (P1)** — pre-commit hooks must not break contributor boxes lacking the toolchain silently in the other direction either: hooks fail-loud (D-070) but the README dev section documents the exact skip path (`pre-commit run --no-skip` semantics never needed; SKIP=env documented) — a contributor with no ruff gets a loud pointer, not a broken commit flow.
|
||||
- **G-210 (P1)** — the guide parity test (MH-5b) needs a defined source of truth: the runbook's channel sections are the canonical text; `nextcraft guide` renders them via an extracted shared snippet (single source file, generated or duplicated-with-test); divergence = test failure. PLAN Task 5-1-02 mechanism named.
|
||||
|
||||
## Plan Edits Applied
|
||||
|
||||
1. G-201 → Task 3-1-01 fallback ladder + MH-3a reworded (probe → binfmt-full → deferral, never red lane)
|
||||
2. G-202 → Overview wave-order paragraph corrected (P2 precedes P3 for founder priority, not technical force)
|
||||
3. G-203 → Task 1-2-02 + Task 3-2-03 verification mechanism (temporary rc tag, deleted after proof)
|
||||
4. G-204 → Task 2-2-02 five-assertion smoke gate pinned
|
||||
5. G-205 → Task 1-2-04 reworded (founder-minted PAT with package-write; escalation question not silent wait)
|
||||
6. G-206 → Task 1-1-01/1-1-03 rotation condition + ship-notes carry
|
||||
7. G-207 → new P1 Wave 1-1 task (fleet-as-code committed in deploy/ci/)
|
||||
8. G-208 → Task 2-1-02 single remediation constant, tested
|
||||
9. G-209 → Task 1-2-05 README dev-section skip path documented
|
||||
10. G-210 → Task 5-1-02 parity mechanism named (single source, divergence = test failure)
|
||||
|
||||
## Advisories (recorded, no action)
|
||||
|
||||
- a-201: docker.io rate limits on the release lane — bookworm main is fine; watch anonymous pull quotas on job-image pulls (runner-images:ubuntu-latest is docker.gitea.com, not docker.io).
|
||||
- a-202: `GITEA_TOKEN` naming collision (automatic job token vs repo secret of the same name) — Gitea precedent lowest-level-wins; keep the repo secret name `GITEA_TOKEN` per R-13 but document the collision in the workflow comments.
|
||||
- a-203: cache.host on CT 113 IP — if the LXC ever changes IP, cache breaks; fleet-as-code (G-207) documents the IP as a bring-up variable.
|
||||
- a-204: image `latest` tag vs immutable release tags — `latest` is convenience-only; runbook always pins the release tag.
|
||||
|
||||
+194
-142
@@ -2,19 +2,18 @@
|
||||
|
||||
## 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 (read-only exec-output terminal frame, CUT-2/G-8 — interactive xterm relay is v0.4), live-telemetry, and live-defense surfaces.
|
||||
|
||||
> **v0.8 update (RESEARCH, lead-developer assessment):** milestone = Ship Anywhere (Feature — release/distribution rails: runner fleet, docker image, deb/arm64 packaging, CLI onboarding glue, admin guides). **Activated:** devops-engineer (NEW custom persona — the runner fleet on CT 113, docker-compose + labels + cache hygiene, .gitea/workflows push + tag lanes, Dockerfile + registry push, pre-commit config; the whole release-consistency surface REQ-8-001/002), cli-engineer (re-activated from its v0.4/v0.4.6 territory — build-binary.mjs arch parameter + arm64 node base, install.sh arch-resolve, deb packaging scripts, doctor container/deb-channel awareness, `guide` embedded text, bootstrap/deploy/dev container refusal — REQ-8-003/004), sandbox-engineer (re-activated for the D-068 capability probe promotion into sandbox/capability.py + the 503 degrade arm in api/sandboxes.py — its unshare_backend/manager/api-surface territory), lead-developer (coordination + wave order + the P5 admin-guide assembly), security-auditor (phase-specific: release secrets handling REGISTRY_TOKEN/GITEA_TOKEN/registration token, image non-root + healthcheck, sibling-mode blast radius, deb postinst). **Deactivated:** backend-engineer (no config/api surface work beyond what sandbox-engineer owns — the D-068 wiring lives in sandbox+main.py boot notice, owned by sandbox-engineer this milestone), frontend-engineer (no web-app changes; apps/web is only a BUILD INPUT to the image), ai-engineer, data-engineer, identity-engineer, voice-engineer, design-system-engineer.
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates task decomposition across web, AI service, engine, and sandbox territories; resolves conflicts between frontend, backend, AI, sandbox, and voice personas
|
||||
reason: Coordinates the v0.8 Ship Anywhere milestone (fleet → container → debian → onboarding → guides waves); owns the admin-guide assembly (P5) as the single cross-channel runbook; owns wave-order + the release-asset matrix consistency check (every release: x64/arm64 binaries + debs + image tag per D-064/D-065)
|
||||
domain: coordination
|
||||
frameworks:
|
||||
- next.js
|
||||
- turborepo
|
||||
- pnpm
|
||||
- fastapi
|
||||
- gitea-actions
|
||||
- docker
|
||||
constraints:
|
||||
- pragmatic
|
||||
- battle-tested defaults
|
||||
@@ -22,102 +21,163 @@ constraints:
|
||||
territory:
|
||||
- "**/package.json"
|
||||
- "**/turbo.json"
|
||||
- "**/pnpm-workspace.yaml"
|
||||
- "**/tsconfig.json"
|
||||
- "deploy/README.md"
|
||||
- "README.md"
|
||||
```
|
||||
|
||||
### devops-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: NEW v0.8 custom persona — owns the entire release-consistency surface: 3 org-level gitea/runner:3 containers on CT 113 (sibling mode, host docker.sock, per-runner /data, GITEA_RUNNER_LABELS, capacity 1, cache.host pinning + 32 GB prune cron — REQ-8-001), .gitea/workflows push lane (node/python lanes with the repo's own scripts, D-063 fast-only test lanes) + tag lane (exclusive release owner D-064), multi-stage Dockerfile + git.coreci.dev/coreci/nextcraft push (semver + latest per D-065 — REQ-8-002), root .pre-commit-config.yaml (D-070)
|
||||
domain: devops
|
||||
frameworks:
|
||||
- docker
|
||||
- docker-compose
|
||||
- gitea-runner
|
||||
- github-actions-syntax (gitea-compatible subset)
|
||||
- bash
|
||||
constraints:
|
||||
- sibling-mode-socket-trust (org-scoped private repos only; accepted-risk documented)
|
||||
- secrets-via-actions-secrets-or-founder (registration token never in files; REGISTRY_TOKEN self-serve PUT first, founder UI fallback)
|
||||
- job-token-cannot-publish-packages (upstream-confirmed; registry push needs the PAT secret)
|
||||
- cache-host-pinning (dockerized runner cache server must be job-reachable or actions/cache ETIMEDOUTs)
|
||||
- fast-lanes-only-in-ci (real_server/userns never in job containers, D-063)
|
||||
territory:
|
||||
- ".gitea/workflows/**"
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
- ".pre-commit-config.yaml"
|
||||
- "deploy/runner/**" # compose + config for CT 113 (admin-guide artifacts, not CT state)
|
||||
```
|
||||
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 re-activation — owns the distribution artifacts of its own binary: build-binary.mjs arch parameter (--arch x64|arm64; arm64 node-base download replacing process.execPath copy, postject unchanged — D-069 QEMU-free cross-build), scripts/build-deb.sh + postinst advisory doctor (D-067), install.sh arch-gate→arch-resolve (x86_64/aarch64, sha256+version gates byte-identical), doctor container/deb-channel awareness (real userns probe + NC_CONTAINER//.dockerenv//usr/bin/nextcraft detection — REQ-8-004/D-066), `nextcraft guide` offline-first embedded text (D-072), bootstrap/deploy/dev in-container refusal (D-066/D-071), verify container-mode branch
|
||||
domain: cli
|
||||
frameworks:
|
||||
- node
|
||||
- typescript
|
||||
- node-sea
|
||||
- esbuild
|
||||
- postject
|
||||
- dpkg-deb
|
||||
- posix-sh
|
||||
constraints:
|
||||
- stdlib-only-runtime
|
||||
- thin-wrapper
|
||||
- fail-loud-exit-codes
|
||||
- postinst-never-fails-dpkg (advisory doctor, D-067)
|
||||
- deb-no-systemd-unit (deploy --service owns units, D-067)
|
||||
- guide-offline-first (zero runtime fetches, D-072)
|
||||
- deb-version-policy (tag minus leading v)
|
||||
territory:
|
||||
- "apps/cli/**"
|
||||
- "scripts/install.sh"
|
||||
- "scripts/release-assets.sh"
|
||||
- "scripts/build-deb.sh"
|
||||
```
|
||||
|
||||
### sandbox-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 re-activation — owns the D-068 degrade detection in its sandbox fabric: promote tests/helpers/userns.py's probe into ai_service/sandbox/capability.py (verbatim semantics), the boot notice in main.py lifespan (the G-11 mount-less-warning pattern), the per-create 503 + remediation arm (SandboxUnavailableError mapping beside PoolFullError in api/sandboxes.py) — the honest container-degrade path for REQ-8-002; real_server/userns test tiers stay OUT of CI lanes per D-063
|
||||
domain: infra
|
||||
frameworks:
|
||||
- python
|
||||
- linux-namespaces
|
||||
- docker (degrade semantics only — no docker backend)
|
||||
- pytest
|
||||
constraints:
|
||||
- capability-probe-is-truth-source (markers only flavor remediation, D-068)
|
||||
- reject-not-fallback (unisolated learner code unacceptable in deployed schools; supersedes A-101)
|
||||
- degrade-keeps-core-features (chat/agents/grading untouched)
|
||||
- probe-semantics-verbatim (unshare --user --map-root-user true, 10s timeout)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/sandbox/**"
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/api/sandboxes.py"
|
||||
- "apps/ai-service/tests/sandbox/**"
|
||||
- "apps/ai-service/tests/helpers/userns.py"
|
||||
```
|
||||
|
||||
### security-auditor
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.8 re-activated (phase-specific) — P1 release secrets review (registration token flow, REGISTRY_TOKEN/GITEA_TOKEN Actions secrets, the .env-only resolution of release-assets.sh preserved as manual fallback), P2 image review (non-root user, healthcheck without curl, state volume ownership, sibling-mode socket blast-radius row), P3 deb review (postinst never fails dpkg, empty Depends + honest Recommends), P6 final review (channel consistency: every recipe backed by a test or live-box evidence)
|
||||
domain: security
|
||||
frameworks:
|
||||
- pytest
|
||||
- httpx
|
||||
constraints:
|
||||
- STRIDE-classified
|
||||
- secrets-never-in-workflow-env (Actions secrets only; never files)
|
||||
- secrets-never-logged
|
||||
- registration-token-reset-after-fleet-green (founder directive)
|
||||
- fail-closed-degrades (unprivileged default; privileged variant documented opt-in)
|
||||
territory:
|
||||
- ".gitea/workflows/**"
|
||||
- "Dockerfile"
|
||||
- "scripts/build-deb.sh"
|
||||
- "scripts/release-assets.sh"
|
||||
- "apps/cli/src/commands/deploy.ts"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — the only backend-adjacent work (D-068 probe wiring + boot notice) is owned by sandbox-engineer whose fabric it extends; config.py/pyproject need zero changes (NC_CONTAINER is a CLI-layer marker; the AI_* env surface is untouched). Roster retained; the v0.7 pytest/mypy territory is stable.
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
- pydantic-settings
|
||||
- bash
|
||||
- turborepo
|
||||
- pnpm
|
||||
constraints:
|
||||
- secrets-via-env-only (D-014)
|
||||
- idempotent-scripts
|
||||
- fail-closed-admin
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/api/**"
|
||||
- "apps/ai-service/scripts/**"
|
||||
- "apps/ai-service/.env.example"
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
- "package.json"
|
||||
- "turbo.json"
|
||||
```
|
||||
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
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.
|
||||
reason: v0.8 DEACTIVATED — no apps/web changes; the web app is only a BUILD INPUT to the docker image (NEXT_PUBLIC_AI_SERVICE_URL=self export, unchanged since D-039). ESLint/c8/pins territory stable from v0.7.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- react
|
||||
- next.js
|
||||
- tailwindcss
|
||||
- lucide-react
|
||||
- recharts
|
||||
- react-flow
|
||||
constraints:
|
||||
- component-first
|
||||
- server-components-default
|
||||
- minimal-client-js
|
||||
- sse-client-buffering (buffer bytes, split frames on \n\n, join data: lines)
|
||||
- abortcontroller-cleanup (idempotent abort in effect cleanup)
|
||||
- fetch-lifecycle (typed engine-client calls, retry/teardown, cleanup)
|
||||
- mediarecorder-permission-ux (mic consent, graceful no-mic fallback)
|
||||
- responsive-all-breakpoints
|
||||
- dark-mode-support
|
||||
- no-build-time-db-reads (D-039 export build stays repo-only)
|
||||
territory:
|
||||
- "apps/web/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/mock-data/**"
|
||||
- "packages/types/**"
|
||||
```
|
||||
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns TS mock data layer schema and typed definitions + TS types for telemetry/trace/grade/variant/defense shapes the web surfaces consume. Does NOT own the Python corpus or engine stores — aligned by convention (D-021).
|
||||
domain: data
|
||||
frameworks:
|
||||
- typescript
|
||||
constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- migration-ready
|
||||
- mock-data-only
|
||||
territory:
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Owns apps/ai-service app shell, config, API endpoints (incl. WebSocket telemetry ingest), engine persistence (SQLite stores), scripts, and test harness. Extended for v0.3 engine modules.
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- pydantic
|
||||
- pydantic-settings
|
||||
- httpx
|
||||
- pytest
|
||||
- sqlmodel
|
||||
- sqlalchemy
|
||||
- websockets
|
||||
- aiofiles
|
||||
constraints:
|
||||
- provider-agnostic-boundaries (engine modules import nothing from agents/ or api/)
|
||||
- streaming-first
|
||||
- sqlite-first-persistence (protocol-wrapped stores, Postgres-ready, D-027)
|
||||
- secrets-via-env-only
|
||||
- mock-provider-in-tests
|
||||
- websocket-contract (typed envelopes, seq gap detection, D-026)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/api/**"
|
||||
- "apps/ai-service/ai_service/telemetry/store.py"
|
||||
- "apps/ai-service/ai_service/telemetry/ingest.py"
|
||||
- "apps/ai-service/ai_service/grading/store.py"
|
||||
- "apps/ai-service/ai_service/variants/store.py"
|
||||
- "apps/ai-service/scripts/**"
|
||||
- "apps/ai-service/package.json"
|
||||
- "apps/ai-service/tests/api/**"
|
||||
- "turbo.json"
|
||||
```
|
||||
|
||||
### ai-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Owns the LLM provider layer, agent framework, prompt library, structured outputs, and the model-facing logic of v0.3 engines — trace-digest→rubric grading prompts (grading/features.py+engine.py), variant instantiation (variants/templates.py+generator.py), and the Examiner agent. Owns the deterministic-mock corpora.
|
||||
reason: v0.8 DEACTIVATED — no LLM-facing work; the provider layers ship unchanged inside the docker image (G-11 fallbacks are the only runtime-config behavior).
|
||||
domain: ai
|
||||
frameworks:
|
||||
- pydantic
|
||||
@@ -125,116 +185,108 @@ frameworks:
|
||||
- pytest
|
||||
constraints:
|
||||
- provider-agnostic-protocol
|
||||
- prompts-are-code
|
||||
- json-defensive-parsing
|
||||
- never-call-cloud-in-tests
|
||||
- delta-passthrough
|
||||
- llm-sees-digest-not-raw-trace (D-028)
|
||||
- seeded-variant-reproducibility (D-029)
|
||||
- mock-first
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/llm/**"
|
||||
- "apps/ai-service/ai_service/agents/**"
|
||||
- "apps/ai-service/ai_service/marketplace/**"
|
||||
- "apps/ai-service/ai_service/prompts/**"
|
||||
- "apps/ai-service/ai_service/corpus/**"
|
||||
- "apps/ai-service/ai_service/grading/features.py"
|
||||
- "apps/ai-service/ai_service/grading/engine.py"
|
||||
- "apps/ai-service/ai_service/variants/templates.py"
|
||||
- "apps/ai-service/ai_service/variants/generator.py"
|
||||
- "apps/ai-service/tests/llm/**"
|
||||
- "apps/ai-service/tests/agents/**"
|
||||
```
|
||||
|
||||
### sandbox-engineer
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.3 custom persona (RESEARCH) — owns the sandbox fabric: SandboxBackend protocol, unshare-based Linux user/mount/pid/net namespace spawner, per-sandbox workdir, resource limits, lifecycle manager, concurrency guard, and the in-sandbox capture agent. Probe-verified isolation on this box (D-024).
|
||||
domain: infra
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — zero schema/store changes; the ~/.nextcraft/data/nextcraft.db path rides into the docker volume via config.py's existing home-dir default (no code change).
|
||||
domain: data
|
||||
frameworks:
|
||||
- python
|
||||
- linux-namespaces
|
||||
- asyncio
|
||||
- sqlmodel
|
||||
- pydantic
|
||||
- typescript
|
||||
constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- dual-schema-sync
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/marketplace/base.py"
|
||||
- "apps/ai-service/ai_service/marketplace/store.py"
|
||||
- "apps/ai-service/ai_service/marketplace/ingest.py"
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
### identity-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 custom persona — module shipped complete; no v0.8 identity-domain work.
|
||||
domain: identity
|
||||
frameworks:
|
||||
- fastapi
|
||||
- sqlmodel
|
||||
- pytest
|
||||
constraints:
|
||||
- isolation-verified (probe must show in-ns uid=0, network isolated, writes to workdir only)
|
||||
- backend-protocol-swap (no containerd assumption; D-024)
|
||||
- resource-limits-enforced (cpu/mem/time quotas observable)
|
||||
- no-daemon (subprocess-only; no docker/containerd service)
|
||||
- capacity-guard (1-5 concurrent; 503 when full, D-032)
|
||||
- pii-never-stored-raw
|
||||
- pii-never-logged
|
||||
- gate-composition-order
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/sandbox/**"
|
||||
- "apps/ai-service/scripts/sandbox-agent.py"
|
||||
- "apps/ai-service/tests/sandbox/**"
|
||||
- "apps/ai-service/ai_service/identity/**"
|
||||
- "apps/ai-service/tests/identity/**"
|
||||
```
|
||||
|
||||
### voice-engineer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.3 custom persona (RESEARCH) — owns the voice layer: VoiceProvider protocol, STT/TTS against a compatible endpoint, deterministic mock (tests never call a voice API), browser-native fallback, and the media-path wiring consumed by the Examiner agent and assessment UI.
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 territory — voice shipped complete; no v0.8 voice work.
|
||||
domain: ai-media
|
||||
frameworks:
|
||||
- pydantic
|
||||
- httpx
|
||||
- fastapi
|
||||
- pytest
|
||||
- web-mediarecorder
|
||||
- react
|
||||
constraints:
|
||||
- provider-agnostic-protocol (D-030)
|
||||
- never-call-voice-api-in-tests
|
||||
- browser-native-fallback (no-key path still functions)
|
||||
- bounded-turn-latency (conversational feel budget)
|
||||
- provider-agnostic-protocol
|
||||
- never-call-cloud-in-tests
|
||||
- key-redaction
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/voice/**"
|
||||
- "apps/ai-service/tests/voice/**"
|
||||
- "apps/web/components/learner/defense-session.tsx"
|
||||
```
|
||||
|
||||
### design-system-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Owns the shared component library, design tokens, and visual consistency. v0.3 duty: new primitives for the real build/assessment surfaces (terminal frame, telemetry status indicator, mic/record control, grade badge, defense transcript viewer).
|
||||
reason: No design-token or primitive work in v0.8; roster retained.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- tailwindcss
|
||||
- storybook
|
||||
- lucide-react
|
||||
constraints:
|
||||
- design-token-driven
|
||||
- wcag-aa-contrast
|
||||
- dark-mode-required
|
||||
- consistent-across-surfaces
|
||||
territory:
|
||||
- "packages/ui/**"
|
||||
```
|
||||
|
||||
### security-auditor
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: Identity/age-gating (KYC) deferred beyond v0.3 per founder directive (A-110) — no real auth or PII backend lands this milestone. Security coverage remains: verifier's STRIDE layer + Phase 7 secrets-hygiene checklist (keys absent from code/logs/commits/errors, localhost-only CORS, no PII in prompts). Sandbox isolation safety is owned by sandbox-engineer's probe-verified constraint.
|
||||
domain: security
|
||||
frameworks: []
|
||||
constraints: []
|
||||
territory: []
|
||||
```
|
||||
|
||||
## Phase-Specific Personas
|
||||
|
||||
| Persona | Phases | Removed After |
|
||||
|---------|--------|---------------|
|
||||
| sandbox-engineer | 1 (primary), 2, 6 | persists while sandbox fabric exists |
|
||||
| voice-engineer | 5 (primary), 6 | persists while voice defense exists |
|
||||
| security-auditor | 1 (runner + secrets), 2 (image non-root/volumes), 3 (deb postinst), 6 (final channel-consistency review) | milestone complete |
|
||||
|
||||
All other active personas span the entire milestone. data-engineer and design-system-engineer are light-touch outside their phases.
|
||||
All other personas span the milestone. Deactivated personas receive no tasks.
|
||||
|
||||
## Territory Conflict Resolution
|
||||
|
||||
| Conflict | Resolution |
|
||||
|----------|------------|
|
||||
| frontend-engineer vs data-engineer (packages/types, packages/mock-data) | data-engineer owns type definitions and mock data schema (incl. new telemetry/grade/variant/defense TS types); frontend-engineer consumes them. |
|
||||
| frontend-engineer vs design-system-engineer (packages/ui) | design-system-engineer owns design tokens and primitive components (terminal frame, mic control, grade badge); frontend-engineer owns composite components and page-level UI. |
|
||||
| ai-engineer vs backend-engineer (grading/variants) | ai-engineer owns the model-facing files (features/engine/templates/generator = LLM logic + prompts); backend-engineer owns the persistence stores + API endpoints. Boundary: stores are pure SQLite; engine logic is pure compute. |
|
||||
| sandbox-engineer vs backend-engineer (sandbox/) | sandbox-engineer owns `ai_service/sandbox/**` + capture agent; backend-engineer owns the API route that composes `sandbox/manager.py` via DI. manager.py has a narrow typed interface consumed by api/. |
|
||||
| voice-engineer vs ai-engineer (Examiner agent) | ai-engineer owns `agents/examiner.py` + its prompt; voice-engineer owns `voice/**` (audio in/out). Examiner calls `voice/` through the `VoiceProvider` protocol — never imports concrete providers. |
|
||||
| ai-engineer vs data-engineer (mock duplication) | ai-engineer owns `ai_service/corpus/` (Python); data-engineer owns `packages/mock-data` (TS). Shared IDs/shapes aligned by convention (D-021). |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files. |
|
||||
| devops-engineer vs cli-engineer (build-binary.mjs invocation) | devops-engineer owns the WORKFLOW steps that invoke `pnpm --filter @nextcraft/cli build:binary`; cli-engineer owns the SCRIPT itself (arch parameter, arm64 base, sidecars) — the workflow never patches build scripts |
|
||||
| devops-engineer vs sandbox-engineer (D-068 in Dockerfile) | devops-engineer owns the Dockerfile layers (base, user, healthcheck, volume); sandbox-engineer owns the runtime capability probe + degrade semantics the image merely runs; NC_CONTAINER marker name is a shared contract (devops sets it in the entrypoint, cli-engineer + sandbox-engineer read it) |
|
||||
| cli-engineer vs devops-engineer (doctor container facts) | cli-engineer owns doctor/verify/guide implementations; devops-engineer supplies the documented facts (volume path, privileged variant recipe) via deploy/README.md |
|
||||
| sandbox-engineer vs backend-engineer (main.py boot notice) | sandbox-engineer owns the D-068 boot notice + api/sandboxes.py degrade arm this milestone; backend-engineer is deactivated but its config.py territory is NOT modified (NC_CONTAINER lives in the CLI layer, not Settings) |
|
||||
| cli-engineer vs security-auditor (postinst/secrets) | cli-engineer implements; security-auditor reviews fail-never-postinst + token resolution and may patch security defects directly in scripts/build-deb.sh + release-assets.sh (its territory) |
|
||||
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
|
||||
|
||||
+167
-366
@@ -1,438 +1,239 @@
|
||||
# Nextcraft v0.3 — PLAN.md
|
||||
# Nextcraft — PLAN.md (v0.8 Ship Anywhere)
|
||||
|
||||
## Overview
|
||||
|
||||
This plan covers execution phases 1-6 of milestone v0.3 (Credential Engines): the real engines that replace v0.2's mock inputs — a namespace-isolated sandbox fabric, live build telemetry over WebSocket + SQLite, a process-trace grading engine, seeded per-learner variant task generation, and an oral/voice defense with a seventh Examiner agent — plus re-grounding the Lab/Assessor/Proctor agents onto real engine inputs and wiring the v0.1 learner surfaces to the real build/defense/grading paths. Phases are strictly sequential (P1→P6); within each phase, Wave 1 tasks are parallelizable (no cross-file dependencies) and later waves depend on earlier ones.
|
||||
**Milestone:** v0.8 — Ship Anywhere (Feature): nextcraft installable on any Debian-based Linux server via every mainstream channel — docker image from the Gitea registry, .deb packages, one-liner binary (x64 + arm64) — with release consistency enforced by pre-commit hooks + Gitea Actions pipelines, and the CLI carrying the full suite for zero → running onboarding. Admin guides document all of it.
|
||||
**Tag line:** phase tags advance the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release), continuing from the v0.6.6 milestone release
|
||||
**Branch:** milestone/v0.8-ship-anywhere
|
||||
|
||||
**Environment facts (apply throughout):** Python 3.11.2 via `python3 -m venv` (no uv, no system pip); pnpm 12.3.4 via corepack; turborepo; ai-service port **8420**; default model `gemma4:31b` (config via `AI_TUTOR_MODEL`); ollama-cloud base `https://ollama.com/v1` (OpenAI-compatible, Bearer auth); keys live only in gitignored `.ciagent/.env.secrets` (exported by `scripts/dev.sh`) — never in code, commits, or logs; all automated tests use the deterministic mock LLM and mock voice providers and **never call cloud or voice APIs**. New ai-service deps this milestone: `sqlmodel`, `sqlalchemy`, `websockets`, `aiofiles` (all PyPI-verified; added in Task 1-1-02). SQLite data (`apps/ai-service/ai_service/data/`) and sandbox dirs (`apps/ai-service/sandboxes/`) are already gitignored. **KYC/age-gating is deferred per founder directive (A-110)** — no identity work; age-gating stays the v0.1 visual flow mockup. **GRILL scope decisions (this revision):** real server STT/TTS (`OpenAIAudioProvider`) deferred to v0.4 — voice defense is mock+browser-first (CUT-1/G-7); the interactive xterm.js shell relay is deferred to v0.4 — the build panel is run/test-buttons + read-only exec output (CUT-2/G-8), so `@xterm/*` is NOT a v0.3 dependency; sandbox abuse control (per-learner caps + allowlist, G-5) ships even though KYC is deferred; sandbox resource limits are partially enforced (memory/CPU/wall-clock + workdir-size sweep; per-sandbox pids and hard disk quota are accepted gaps, G-1/G-2).
|
||||
**Phase list (requirement mapping):**
|
||||
|
||||
| Phase | Name | Requirements | Waves | Personas |
|
||||
|-------|------|-------------|-------|----------|
|
||||
| 1 | Sandbox fabric | REQ-3-001, 002 | 3 | sandbox-engineer, backend-engineer, ai-engineer (W1 lint only) |
|
||||
| 2 | Live build telemetry | REQ-3-003 | 4 | sandbox-engineer, backend-engineer, data-engineer |
|
||||
| 3 | Process-trace grading engine | REQ-3-004 | 3 | ai-engineer, backend-engineer |
|
||||
| 4 | Variant task generation | REQ-3-005 | 3 | ai-engineer, backend-engineer, data-engineer |
|
||||
| 5 | Oral / voice defense | REQ-3-006 | 4 | voice-engineer, ai-engineer, backend-engineer |
|
||||
| 6 | Agent re-grounding + learner surface integration | REQ-3-007, 008 | 5 | ai-engineer, frontend-engineer, design-system-engineer, data-engineer, backend-engineer, lead-developer |
|
||||
| Phase | Requirement | Focus |
|
||||
|-------|-------------|-------|
|
||||
| P1 | REQ-8-001 | Release-consistency rails: runner fleet on CT 113, push/tag workflows, pre-commit, hello-world verification |
|
||||
| P2 | REQ-8-002 | Docker image: multi-stage build, unprivileged runtime + honest sandbox degrade, registry push on every release |
|
||||
| P3 | REQ-8-003 | Debian-native: arm64 cross-build, .deb packages (x64+arm64), install.sh arch-resolve, release assets extended |
|
||||
| P4 | REQ-8-004 | CLI onboarding: doctor channel awareness, `nextcraft guide`, in-container refusals, per-channel E2E |
|
||||
| P5 | REQ-8-005 | Admin guides: the full deploy runbook (three channels × install/upgrade), README quickstart |
|
||||
| P6 | — | Final review + audit + milestone ship (v0.7.6 = milestone release) |
|
||||
|
||||
**Wave order (binding): rails before artifacts before onboarding before guides.** P1 builds the rails everything else ships through — P2/P3 extend the release workflow P1 creates; P4 onboarding needs all artifact channels to exist; P5 guides document the final shape. Within P1, the runner fleet gates the workflow verification (workflows are inert config until a runner registers). P2 precedes P3 for founder priority (docker is the headline channel), not technical force — both consume P1's release workflow as their extension point (G-202).
|
||||
|
||||
**Research decisions R-1..R-32 (RESEARCH.md) and clarify decisions D-063..D-072 (PROJECT.md) are binding design contracts.** This plan operationalizes them; it does not re-litigate them.
|
||||
|
||||
**Environment facts (probe-verified):** Gitea 1.26.1 at git.coreci.dev; OCI registry `/v2/` live (401 unauth); Actions enabled, zero runs to date; CT 113 gitea-runner LXC (10.0.0.113, 4c/4GB/32GB, root SSH, founder-provided credentials — never committed); registration token founder-provided in chat, reset after fleet green; latest release v0.6.6 (id 909) carries nextcraft-linux-x64 + .sha256; runner image is `docker.io/gitea/runner:3` (act_runner renamed, R-1); jobs run as siblings via host docker.sock; v0.7 toolchain (eslint flat config, ruff + RUF100, mypy relaxed-first, pytest markers fast/real_server/userns with `ai:test:fast`, c8, turbo pipeline) is the CI surface.
|
||||
|
||||
**Secrets policy (binding, v1.8 lesson):** tokens never in shell env, never committed, never echoed. Registration token = compose env at bring-up only (R-6). REGISTRY_TOKEN = Gitea Actions secret (self-serve `PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` attempt with the `.ciagent/.env.secrets` GITEA_TOKEN first; founder UI fallback — R-13). The automatic job `GITEA_TOKEN` cannot publish packages (R-13, upstream-confirmed). CT 113 root credentials: use once at P1 bring-up, install repo-owned access thereafter, founder rotates the password.
|
||||
|
||||
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to the four channels + guides.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Sandbox Fabric
|
||||
## Phase 1: Release-Consistency Rails (REQ-8-001)
|
||||
|
||||
**Requirements:** REQ-3-001, REQ-3-002
|
||||
**Goal:** `SandboxBackend` protocol + `unshare`-based namespace spawner (D-024) + lifecycle manager with concurrency guard (D-032) + per-sandbox workdir; isolation and resources probe-verified on this box; `/v1/sandboxes` API live; deps + gitignore landed
|
||||
**Goal:** Three org-level runner containers green on CT 113; push-lane workflows (node/python lanes per D-063) and a tag-triggered release workflow live; pre-commit hooks enforce local hygiene; the fleet proven by hello-world dispatch across all three labels.
|
||||
|
||||
### Wave 1: Foundations (parallel — no shared files)
|
||||
### Wave 1-1: Runner fleet bring-up on CT 113 (devops-engineer)
|
||||
|
||||
#### Task 1-1-01: SandboxBackend protocol + unshare spawner + probe test
|
||||
- **Persona:** sandbox-engineer — **REQ:** REQ-3-001, REQ-3-002
|
||||
- **Files:** `apps/ai-service/ai_service/sandbox/__init__.py`, `apps/ai-service/ai_service/sandbox/backend.py`, `apps/ai-service/ai_service/sandbox/workdir.py`, `apps/ai-service/ai_service/sandbox/unshare_backend.py`, `apps/ai-service/tests/sandbox/__init__.py`, `apps/ai-service/tests/sandbox/test_isolation.py`
|
||||
- **Action:** `backend.py`: `SandboxBackend` protocol + `SandboxSpec` (sandbox_id, learner_id, workdir, resource limits) + `SandboxHandle` (id, pid, workdir, created_at); `spawn(spec)`, `exec(handle, cmd)`, `snapshot(handle) -> Path`, `destroy(handle)`. `workdir.py`: per-sandbox layout under `apps/ai-service/sandboxes/<id>/` (workspace/ writable, snapshot() = recursive copy to `snapshots/<ts>/`) — no symlinks as the snapshot mechanism. `unshare_backend.py`: subprocess spawner — `unshare --user --map-root-user --mount --pid --fork --net` with the per-sandbox dir bind-mounted (`--bind <dir> /work`) and `chdir /work` (D-024); pipes for stdout/stderr; async wrappers. `test_isolation.py` — **re-verify box isolation properties (runs on this box, guarded by probe skip):** (a) `id -u` inside namespace prints `0`; (b) `ip link` inside namespace shows 0 usable interfaces (loopback-only/no carrier) — network isolated; (c) file written to `/work/inside.txt` lands at `sandboxes/<id>/workspace/inside.txt` on the host; (d) attempt to write outside the mount (e.g. host tmp path via bind) does not escape the per-sandbox dir; (e) `/proc` visibility degraded (proc-remount not permitted per A-101 — assert the probe documents this, not that it fails).
|
||||
- **Verify:** `pnpm ai:test` — `tests/sandbox/test_isolation.py` green on this box (probe-gated: skips with an explicit reason if userns unavailable); `lint` clean
|
||||
- **Task 1-1-01** (devops-engineer): CT 113 bring-up probes — SSH to root@10.0.0.113 (founder creds, use once), verify docker daemon (`docker info`), LXC nesting health (cgroup/ns errors), egress (`curl -fsSL https://git.coreci.dev/api/v1/version` → 200; docker.io reachability for `gitea/runner:3` + job images; github.com reachability for actions/checkout per R-8 — record verdict, it selects the checkout strategy). Install an ed25519 deploy key for subsequent phases; founder rotates root password after this task — **rotation is a Wave 1-1 completion condition (G-206): if unconfirmed by Wave 1-2, commit `escalation: ct_root_password_still_live` and carry it in the ship notes — never silently.**
|
||||
- **Task 1-1-01b** (devops-engineer): **Fleet-as-code (G-207)** — the compose file, per-runner config.yaml snippets, and the prune cron land in-repo under `deploy/ci/` (compose with `${REGISTRATION_TOKEN}` env interpolation, configs with `${CT_IP}` documented as bring-up variables); CT 113 deployment = copy + `docker compose up -d`. The runbook's fleet recipe references these committed files, never box memory.
|
||||
- **Task 1-1-02** (devops-engineer): Fleet deployment per R-1..R-5 — `/srv/nc-ci/docker-compose.yaml` with three services (nc-runner-node / nc-runner-python / nc-runner-release), image `docker.io/gitea/runner:3`, per-runner `./data-runner-*` volumes (never shared — lock refusal), host `/var/run/docker.sock` mount, `GITEA_INSTANCE_URL=https://git.coreci.dev`, `GITEA_RUNNER_NAME`, `GITEA_RUNNER_LABELS` per the R-3 hybrid map (node → node:24-bookworm; python → python:3.11-bookworm; release → node:24-bookworm; ubuntu-latest → docker.gitea.com/runner-images:ubuntu-latest, carried by runner-node), registration token via compose env (from founder chat, never a file). Per-runner config.yaml with `capacity: 1`, `cache.enabled/host: 10.0.0.113/port` published per runner (R-5 — else actions/cache ETIMEDOUTs), `health_check.min_free_disk_space_mb: 4096`. systemd unit for the compose stack (`restart: always`, reboot-persistent — house pattern).
|
||||
- **Task 1-1-03** (devops-engineer): Fleet verification — all three runners visible + idle at org level (`/org/coreci/settings/actions/runners`); registration token reset by founder after this task (ask; do not hold the pipeline — the token's job is done once three `.runner` files exist); host-side cache-hygiene cron (daily `docker system prune -af --filter until=168h` + `docker volume prune -f`).
|
||||
|
||||
#### Task 1-1-02: v0.3 dependencies + gitignore + config additions
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-001
|
||||
- **Files:** `apps/ai-service/pyproject.toml` (update), `apps/ai-service/ai_service/config.py` (update), `apps/ai-service/.env.example` (update), `apps/ai-service/scripts/bootstrap.sh` (update if needed), root `package.json` (no change), `turbo.json` (no change)
|
||||
- **Action:** Add pinned deps: `sqlmodel`, `sqlalchemy`, `websockets`, `aiofiles` to pyproject. `config.py` additions (env_prefix `AI_`): `AI_SANDBOX_DIR` (default `apps/ai-service/sandboxes`), `AI_SANDBOX_MAX_CONCURRENT` (default 5, D-032), `AI_SANDBOX_CPU_LIMIT` (default 1 core / cpu.max), `AI_SANDBOX_MEM_LIMIT_MB` (default 512), `AI_SANDBOX_PIDS_LIMIT` (default 256), `AI_SANDBOX_TIMEOUT_S` (default 1800), `AI_DB_PATH` (default `ai_service/data/nextcraft.db`), `AI_VOICE_BASE_URL` / `AI_VOICE_API_KEY` / `AI_VOICE_STT_MODEL` / `AI_VOICE_TTS_MODEL` (all **optional**, default empty — mock-first, D-030; documented in `.env.example` and README). Confirm `.gitignore` already covers `ai_service/data/` + `sandboxes/` (it does — v0.3 block present). Re-run bootstrap idempotently to install new deps.
|
||||
- **Verify:** `pnpm ai:bootstrap` re-installs cleanly (no-op venv, new wheels land); `python -c "import sqlmodel, sqlalchemy, websockets, aiofiles"` succeeds in the venv; settings parse with new keys unset
|
||||
### Wave 1-2: Workflows + secrets + hello-world (devops-engineer)
|
||||
|
||||
#### Task 1-1-03: Resource-limit probe documentation + harness ruff pass
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-001
|
||||
- **Files:** `apps/ai-service/README.md` (update: sandbox section + probe transcript), `apps/ai-service/pyproject.toml` (no change), `apps/ai-service/tests/sandbox/test_isolation.py` (no change)
|
||||
- **Action:** Record the A-101 probe transcript in README (verbatim commands + observed output from this box: `unshare --user --map-root-user --mount --pid --fork --net id -u` → `0`; `ip link` → loopback only; write containment). Document the v0.3 resource-limit mechanism choice: cgroup-v2 delegation via per-sandbox scope files is **not available** on this box without sudo → enforcement = subprocess-level (`ulimit`-equivalent via `preexec_fn`: RLIMIT_AS for memory, RLIMIT_CPU for CPU-seconds, RLIMIT_NPROC for pids) + hard wall-clock timeout kill in the manager. This is the locked v0.3 mechanism (D-024 + no-sudo constraint). Run ruff over the new tree; fix all findings.
|
||||
- **Verify:** `pnpm ai:lint` exits 0; README shows the probe transcript and the rlimit mechanism note
|
||||
- **Task 1-2-01** (devops-engineer): `.gitea/workflows/ci.yaml` — `on: push` (all branches), two jobs: node lane (`runs-on: node`: corepack pnpm 12.3.4 — probe corepack-on-node24, `npm i -g pnpm@12.3.4` fallback per R-11 — `pnpm install --frozen-lockfile`, lint + typecheck via `pnpm --filter` over the 5 TS workspaces (never wholesale `turbo lint` per R-8), `pnpm web:test`, `pnpm cli:test` with E2E env-gated out per D-063) and python lane (`runs-on: python`: bootstrap.sh → lint.sh → typecheck.sh → `FAST=1 test.sh`). checkout via `actions/checkout@v4` if Task 1-1-01 found github.com egress; else raw `git fetch` steps with the job token (R-8). pnpm store + pip cache via `actions/cache@v4` (R-12; volume-mount fallback if cache server unreachable).
|
||||
- **Task 1-2-02** (devops-engineer): `.gitea/workflows/release.yaml` — `on: push: tags: ['v*']`, `runs-on: release`: build the x64 SEA binary stamped `${{ gitea.ref_name }}` (`pnpm --filter @nextcraft/cli build:binary $TAG`), sha256 sidecar, attach both to the tag's release via the REST API (`scripts/release-assets.sh` mechanics per R-14 — call the API directly in the workflow step; the script itself stays untouched as the manual fallback with its .env-only resolution, per D-064/R-14). **Within-phase proof (G-203):** at verify time push a temporary `v0.7.1-rc1`-style tag to the phase-branch head, observe the workflow build + attach green (draft/pre-release or immediately-deleted release), then delete the tag — the phase ship tag remains the real artifact.
|
||||
- **Task 1-2-03** (devops-engineer): `.gitea/workflows/hello.yaml` — `workflow_dispatch` one-step echo job × 3 (`runs-on: node|python|ubuntu-latest`) — the REQ-8-001 fleet proof. Dispatch all three, observe green in the Actions tab.
|
||||
- **Task 1-2-04** (devops-engineer): REGISTRY_TOKEN setup per G-205 — the secret is a **founder-minted PAT with package-write scope** (write:package + write:repository); first attempt the self-serve PUT (`PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` with the `.ciagent/.env.secrets` token, R-13); if the token's scope cannot mint it, escalate to the founder with the exact scope needed — a founder question, never a silent wait. P2's registry push blocks on this secret existing; P1 does not.
|
||||
- **Task 1-2-05** (devops-engineer): `.pre-commit-config.yaml` at repo root (D-070): directory-scoped hooks — ruff (ai-service globs), eslint flat-config run (web/cli/packages globs), tsc `--noEmit` per TS workspace, basic hygiene (trailing whitespace, merge conflicts, large files). Local-only, no network, fail-loud on missing toolchain (per D-070 — CI backstops). Document the opt-in install AND the no-toolbox skip path in the README dev section (G-209 — a contributor without ruff gets a loud pointer, not a broken commit flow).
|
||||
|
||||
### Wave 2: Lifecycle manager (depends on Wave 1)
|
||||
### Wave 1-3: P1 verification (lead-developer + security-auditor)
|
||||
|
||||
#### Task 1-2-01: Sandbox manager + concurrency guard + snapshots
|
||||
- **Persona:** sandbox-engineer — **REQ:** REQ-3-001, REQ-3-002
|
||||
- **Files:** `apps/ai-service/ai_service/sandbox/manager.py`, `apps/ai-service/tests/sandbox/test_manager.py`
|
||||
- **Action:** `SandboxManager`: `create(learner_id) -> SandboxHandle` (guard: active count ≥ `AI_SANDBOX_MAX_CONCURRENT` → raise `PoolFullError` → API maps to **503**, D-032; no queue); `list() -> list[SandboxHandle]`; `get(id)`; `snapshot(id) -> Path` (delegates to workdir); `destroy(id)` (kill process tree, keep or purge workdir per flag); `reap_expired()` background hook for `AI_SANDBOX_TIMEOUT_S` which also performs a **workdir-size sweep**: any sandbox whose `workdir` exceeds `AI_SANDBOX_MAX_WORKDIR_MB` (new config, default 512) is snapshotted-then-destroyed and the event logged as an integrity signal (G-2 — soft disk cap, best-effort, not kernel-enforced); the sweep runs on the same timer as the timeout reaper. Enforce rlimits per spawner (Task 1-1-03: RLIMIT_AS + RLIMIT_CPU + RLIMIT_FSIZE=50MB as a cheap single-file disk guard (a-2); RLIMIT_NPROC noted as shared-per-host-uid, not relied on (G-1)) at exec time. Handle registry persisted **in-memory** (v0.3, single process; not a store — see D-019 precedent) with a clear note that handles are process-local. Startup reaper (a-1): on lifespan boot, scan `AI_SANDBOX_DIR` for workdirs whose recorded pid is dead and reap them, logging a warning. Narrow typed interface only — manager never imports api/ (boundary rule).
|
||||
- **Verify:** `pnpm ai:test` — test_manager covers create/list/destroy/snapshot, 6th create raises PoolFullError (503 path), destroy kills the namespace process (pid gone), snapshot dir exists with workspace contents, timeout reaper removes a stale handle
|
||||
- **Task 1-3-01** (security-auditor): Secrets audit of P1 — no token/credential in any committed file (compose, workflows, pre-commit, docs); registration token lifecycle honored (brought up via env, reset requested); CT root password absent from repo + rotated by founder; workflow env scrubbed (no `GITEA_TOKEN` echo steps).
|
||||
- **Task 1-2-05 → verification hook** (lead-developer): push-lane proof — a deliberate commit touching both lanes lands green runs in the Actions tab; hello-world dispatch green × 3 labels; pre-commit blocks a deliberately broken lint file and passes a clean tree (both directions proven locally).
|
||||
|
||||
#### Task 1-2-02: Resource-limit enforcement test
|
||||
- **Persona:** sandbox-engineer — **REQ:** REQ-3-002
|
||||
- **Files:** `apps/ai-service/tests/sandbox/test_resource_limits.py`
|
||||
- **Action:** Concrete enforcement probes (guarded like isolation tests): (a) spawn a process that allocates > `RLIMIT_AS` → assert it dies with MemoryError/killed within a bound; (b) spawn a CPU spinner past `RLIMIT_CPU` → assert SIGXCPU/kill; (c) single huge file > `RLIMIT_FSIZE` → assert write failure (a-2 partial disk guard); (d) wall-clock: spawn `sleep 9999` with a small manager timeout → reaper destroys it; (e) **disk sweep (G-2)**: write > `AI_SANDBOX_MAX_WORKDIR_MB` across many files → assert the manager sweep destroys the sandbox and logs the integrity signal. Assert limits are observable (handle reports its limit set). NOTE (G-1): per-sandbox `RLIMIT_NPROC` is shared at the host uid — the fork-bomb probe is documented as shared-budget behavior, NOT asserted as per-sandbox isolation.
|
||||
- **Verify:** `pnpm ai:test` — test_resource_limits green; limits proven enforced and observable
|
||||
|
||||
### Wave 3: API exposure (depends on Wave 2)
|
||||
|
||||
#### Task 1-3-01: Sandboxes API module
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-001, REQ-3-002
|
||||
- **Files:** `apps/ai-service/ai_service/api/sandboxes.py`, `apps/ai-service/ai_service/main.py` (update: include router + lifespan manager), `apps/ai-service/ai_service/api/deps.py` (update), `apps/ai-service/tests/api/test_sandboxes.py`
|
||||
- **Action:** DI exposes a singleton `SandboxManager`. Endpoints: `POST /v1/sandboxes {learner_id}` → 201 handle (503 when pool full); `GET /v1/sandboxes` → list; `GET /v1/sandboxes/{id}` → handle; `POST /v1/sandboxes/{id}/snapshot` → snapshot path; `DELETE /v1/sandboxes/{id}` → 204. All behind localhost CORS (A-008). Lifespan creates/destroys the manager; on shutdown destroys any live sandboxes (no orphans). **Abuse control (G-5, NOT KYC):** even in no-auth v0.3 the sandbox API enforces per-`learner_id` rate limiting (`AI_SANDBOX_MAX_PER_LEARNER`, default 1 active → 429) and a global create-rate cap (`AI_SANDBOX_CREATES_PER_MIN`, default 10 → 429); `learner_id` is validated against a server-side allowlist from config (`AI_LEARNER_ALLOWLIST`, default the single mock pilot id → unknown ids rejected 403). This ships in the no-auth milestone so a rogue local process can't exhaust shared NPROC/disk.
|
||||
- **Verify:** `pnpm ai:test` — test_sandboxes green (create→list→snapshot→delete roundtrip via TestClient; 6th create → 503; delete of unknown id → 404; abuse control (G-5): non-allowlisted learner_id → 403; >1 active sandbox for one learner → 429; burst of >10 creates/min → 429); manual probe: `curl -X POST localhost:8420/v1/sandboxes -d '{"learner_id":"l1"}'` returns a handle id
|
||||
|
||||
### Must-Haves (Phase 1)
|
||||
- [ ] Isolation probe test green on this box: in-namespace uid=0, network isolated (0 usable interfaces), host writes confined to the per-sandbox bind dir (A-101 re-verified as an automated test, not just research notes)
|
||||
- [ ] Resource limits enforced + observable: memory (RLIMIT_AS) + CPU (RLIMIT_CPU) rlimits kill violating processes; wall-clock reaper destroys stale sandboxes; disk usage capped by a periodic workdir-size sweep in the manager (soft cap, configurable `AI_SANDBOX_MAX_WORKDIR_MB`, default 512MB — NOT kernel-enforced); per-sandbox NPROC is shared across sandboxes at the host uid — documented, not relied on for isolation (G-1, G-2 — test_resource_limits green)
|
||||
- [ ] No cross-tenant access: sandbox A cannot read sandbox B's workdir (isolation test asserts containment)
|
||||
- [ ] Lifecycle API works end-to-end: create/list/snapshot/destroy via TestClient; pool full → **503** (D-032, no queue)
|
||||
- [ ] Snapshot produces a restorable directory copy under the sandbox's own snapshots/ dir
|
||||
- [ ] `pnpm ai:test` and `pnpm ai:lint` green; new deps (`sqlmodel`, `sqlalchemy`, `websockets`, `aiofiles`) installed via idempotent bootstrap
|
||||
- [ ] Boundary rules hold: `sandbox/` imports nothing from `api/` or `agents/`; only `api/sandboxes.py` composes the manager via DI
|
||||
- [ ] No docker/podman/sudo anywhere in the spawner path (D-024); `SandboxBackend` protocol is the only coupling to the spawner (containerd swap possible later)
|
||||
**Verification hooks P1:** ci.yaml green on a fresh push (both lanes); hello.yaml green × 3 labels; release.yaml exercised at P1 ship (the v0.7.1 tag's release carries binary + checksum, built by the workflow — not by the local ship path); pre-commit both-direction proof; secrets audit clean; runners idle-registered at org level.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Live Build Telemetry
|
||||
## Phase 2: Docker Image Channel (REQ-8-002)
|
||||
|
||||
**Requirements:** REQ-3-003
|
||||
**Goal:** First real persistence (D-027: SQLite via SQLModel) with a `TraceStore` protocol; `TelemetryEvent` model with per-(learner,task) monotonic `seq`; WebSocket ingest endpoint (D-026) with gap detection; stdlib-only in-sandbox capture agent streams real sandbox activity into ai-service; trace retrievable by learner+task
|
||||
**Goal:** The full stack as one unprivileged container — deps pre-loaded, single-port topology preserved, honest sandbox degrade, pushed to the Gitea registry on every phase release.
|
||||
|
||||
### Wave 1: Models + stores + TS types (parallel — no shared files)
|
||||
### Wave 2-1: Image + degrade (devops-engineer + sandbox-engineer)
|
||||
|
||||
#### Task 2-1-01: Telemetry event models
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `apps/ai-service/ai_service/telemetry/__init__.py`, `apps/ai-service/ai_service/telemetry/models.py`, `apps/ai-service/tests/telemetry/__init__.py`, `apps/ai-service/tests/telemetry/test_models.py`
|
||||
- **Action:** Pydantic/SQLModel `TelemetryEvent`: `learner_id`, `task_id`, `seq` (int, monotonic per (learner,task)), `kind` (`command` | `file_diff` | `run_result` | `test_result` | `activity` | `stdin` | `stdout`), `payload` (JSON), `ts` (datetime, monotonic-envelope), `sandbox_id`. `TraceSpan` derived view (ordered events for one (learner,task)). Validation: seq ≥ 0, kind enum, non-empty ids.
|
||||
- **Verify:** `pnpm ai:test` — test_models green (validation rules enforced, JSON payload roundtrip)
|
||||
- **Task 2-1-01** (devops-engineer): Multi-stage Dockerfile per R-16..R-19 — web-export stage (node:24-bookworm, corepack pnpm, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build`, outputs `apps/web/out`), venv stage (python:3.11-bookworm, bootstrap.sh mechanics), runtime stage (python:3.11-slim-bookworm, non-root `nextcraft` user, uvicorn :8420, `AI_WEB_STATIC_DIR` → export, `NC_CONTAINER=1` marker, `EXPOSE 8420`, `HEALTHCHECK` GET /health, state volume `/home/nextcraft/.nextcraft`). CLI embedded per D-066 (`doctor`/`verify`/`guide` usable in-container; image tag stamped `NEXTCRAFT_VERSION_STAMP`).
|
||||
- **Task 2-1-02** (sandbox-engineer): Honest degrade per D-068/R-27..R-29 — `ai_service/sandbox/capability.py` promoting `tests/helpers/userns.py::userns_available()` verbatim (real probe, truth source: attempt userns + report), boot notice at startup when degraded (main.py G-11 loud-warning pattern), 503 arm beside PoolFullError in sandboxes.py (env creation rejected with remediation payload; web surfaces already render honest errors — additive only). **Remediation text is a single tested constant (G-208):** names the unprivileged default, the seccomp constraint, the documented privileged/seccomp-override variant (exact docker run flags), and the host-side alternative — the boot notice and the 503 payload carry the same constant. Tests: degraded-mode unit tests (probe mocked false → 503 + notice asserted); real userns path unchanged.
|
||||
- **Task 2-1-03** (cli-engineer): In-container CLI refusals per D-066/R-30 — `bootstrap`/`deploy`/`dev` detect `NC_CONTAINER` (or `.dockerenv` + marker) and refuse with honest exit 1 + remediation text (image is immutable; upgrade = docker pull per D-071); `doctor` gains container-mode branch (skips repo-root checks, verifies mount + /health); `verify` container semantics (probe the live listener + export presence).
|
||||
|
||||
#### Task 2-1-02: TraceStore protocol + SQLite implementation
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `apps/ai-service/ai_service/telemetry/store.py`, `apps/ai-service/tests/telemetry/test_store.py`, `apps/ai-service/ai_service/data/.gitkeep`
|
||||
- **Action:** `TraceStore` protocol (D-027, Postgres-migration-ready): `append(event) -> None` (idempotent on (learner,task,seq) — at-least-once dedup), `get_trace(learner_id, task_id) -> list[TelemetryEvent]` (ordered by seq), `gaps(learner_id, task_id) -> list[int]` (missing seqs), `latest_seq(learner_id, task_id) -> int`, `list_tasks(learner_id) -> list[str]`, `close()`. `SQLiteTraceStore(SQLModel)`: single `telemetry_event` table, composite PK ((learner_id, task_id, seq)), indexes on (learner_id, task_id). Engine creation from `AI_DB_PATH`; `SQLModel.metadata.create_all` at app lifespan. Enable `PRAGMA journal_mode=WAL` + `synchronous=NORMAL` at engine creation (a-3) so concurrent ingest (writer) and trace reads (grader) don't hit `database is locked` under concurrent sandboxes.
|
||||
- **Verify:** `pnpm ai:test` — test_store green (append/ordered-get/dedup-on-retry/gap detection/latest_seq; tmp-path SQLite per test)
|
||||
### Wave 2-2: Registry push + release integration (devops-engineer)
|
||||
|
||||
#### Task 2-1-03: TS types for telemetry/traces
|
||||
- **Persona:** data-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `packages/types/telemetry.ts` (new), `packages/types/index.ts` (update)
|
||||
- **Action:** TS `TelemetryEvent`, `TraceSpan`, `TelemetryKind` mirroring the Python model field-for-field (cross-referencing header, same string enums). Consumed by Phase 6 web surfaces; no runtime code.
|
||||
- **Verify:** `pnpm typecheck` passes; TS type keys match Python model keys exactly
|
||||
- **Task 2-2-01** (devops-engineer): Release workflow registry step per R-20/D-065 — `docker build` on the tag ref, `docker login git.coreci.dev` with `secrets.REGISTRY_TOKEN`, push `git.coreci.dev/coreci/nextcraft:$TAG` + `:latest`. Blocks on Task 1-2-04's secret existing (escalation if still pending: ship proceeds, push step marked `continue-on-error: false` + release notes note the missing image).
|
||||
- **Task 2-2-02** (devops-engineer): `docker run` smoke gate in the workflow — five pinned assertions (G-204): container starts → `/health` 200 within timeout → `GET /` is content-type text/html AND contains a known page marker (not merely non-JSON-404) → degrade boot notice present in container logs → fabric env-create returns 503 with the remediation payload → clean stop. A broken image = failed release job.
|
||||
- **Task 2-2-03** (security-auditor): Image audit — non-root confirmed, no secrets in layers (`.env*`/`.ciagent/` excluded via `.dockerignore`), healthcheck present, minimal runtime (no build toolchain in the final layer).
|
||||
|
||||
### Wave 2: Capture agent + ingest (depends on Wave 1)
|
||||
|
||||
#### Task 2-2-01: In-sandbox capture agent (stdlib-only)
|
||||
- **Persona:** sandbox-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `apps/ai-service/scripts/sandbox-agent.py`, `apps/ai-service/tests/sandbox/test_sandbox_agent.py`
|
||||
- **Action:** Tiny standalone process (D-031, **stdlib only** — no deps shipped into the namespace): wraps a shell inside the sandbox; captures commands, file diffs (mtime/content polling of `workspace/` at 250ms), run/test results, activity; assigns per-(learner,task) `seq`; buffers to a local spool file on disconnect (at-least-once, D-026); reconnects with **exponential backoff** and flushes spool in order; small WebSocket client implemented over raw `socket` (RFC6455 client handshake + frames — stdlib only, no `websockets` in-namespace). Configured via env baked at spawn (`NC_LEARNER_ID`, `NC_TASK_ID`, `NC_INGEST_URL`).
|
||||
- **Verify:** `pnpm ai:test` — unit tests with a loopback fake WS server: ordered seq emission, spool-on-disconnect, reconnect flush preserves order (no loss, dupes deduped server-side), no third-party imports in the file (asserted by AST scan)
|
||||
|
||||
#### Task 2-2-02: WebSocket ingest endpoint
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `apps/ai-service/ai_service/telemetry/ingest.py`, `apps/ai-service/ai_service/api/sandboxes.py` (update: register WS route), `apps/ai-service/ai_service/main.py` (update), `apps/ai-service/tests/api/test_telemetry_ingest.py`
|
||||
- **Action:** `WS /v1/telemetry/ingest` (D-026): accepts connections carrying learner_id/task_id/sandbox_id; validates + appends events to `TraceStore` (idempotent — server-side dedup on (learner,task,seq)); emits **gap warnings** when seq skips (logged + surfaced in a per-connection status); ping/pong keepalive. **Backpressure / flood control (G-3 — replaces silent drop):** bounded inbound queue; on overflow OR when a per-connection cap `AI_TELEMETRY_MAX_EVENTS_PER_TASK` (default 50000) is exceeded → **reject with a 1008 policy-violation close and mark the (learner,task) trace `INCOMPLETE_FLOODED`** (an integrity signal consumed by Proctor). Silent drop-oldest is FORBIDDEN because it corrupts grading input and is indistinguishable from trace-gaming. `GET /v1/telemetry/traces/{learner_id}/{task_id}` returns the ordered trace; `GET /v1/telemetry/gaps/{learner_id}/{task_id}` returns missing seqs.
|
||||
- **Verify:** `pnpm ai:test` — test_telemetry_ingest green (TestClient websocket: connect → send 3 events → trace retrievable ordered; resend event 2 → deduped; skip seq 5 → gap reported; unknown sandbox tolerated in v0.3 no-auth mode)
|
||||
|
||||
### Wave 3: Sandbox telemetry wiring (depends on Wave 2)
|
||||
|
||||
#### Task 2-3-01: Spawn sandboxes with the capture agent
|
||||
- **Persona:** sandbox-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `apps/ai-service/ai_service/sandbox/manager.py` (update), `apps/ai-service/ai_service/sandbox/unshare_backend.py` (update), `apps/ai-service/tests/sandbox/test_telemetry_wiring.py`
|
||||
- **Action:** `create()` gains optional `task_id`; when set the spawner copies `scripts/sandbox-agent.py` into the sandbox workdir, injects `NC_*` env, and launches the agent as a child of the namespace process (agent lifecycle tied to sandbox lifecycle; destroy kills the agent). No capture when task_id absent (pure shell sandbox).
|
||||
- **Verify:** `pnpm ai:test` — end-to-end on this box: create sandbox with task_id → run 2 commands via exec → events arrive at the ingest endpoint and land in SQLite in order
|
||||
|
||||
### Wave 4: Reliability probe (depends on Wave 3)
|
||||
|
||||
#### Task 2-4-01: Dropped-connection durability probe
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-003
|
||||
- **Files:** `apps/ai-service/tests/telemetry/test_durability.py`
|
||||
- **Action:** Integration probe: run a capture agent against ingest, kill the WS connection mid-stream (simulate network failure), keep generating events, reconnect, assert the SQLite trace contains **every** event exactly once in order (spool + dedup). Document at-least-once semantics + replay path in README.
|
||||
- **Verify:** `pnpm ai:test` — test_durability green; README documents semantics
|
||||
|
||||
### Must-Haves (Phase 2)
|
||||
- [ ] Real telemetry from a live sandbox arrives at ai-service: shell commands, file diffs, run/test results appear as ordered events in SQLite (end-to-end, no mocks)
|
||||
- [ ] Per-(learner,task) monotonic `seq`; gap detection reports missing seqs; replay yields the complete ordered trace
|
||||
- [ ] At-least-once proven: transient disconnect + reconnect loses no events; duplicates deduped server-side (durability probe green)
|
||||
- [ ] Capture agent is stdlib-only (AST-verified) and its lifecycle is tied to the sandbox (destroy kills it)
|
||||
- [ ] Trace retrievable by learner+task via `GET /v1/telemetry/traces/...`; unknown trace → 404
|
||||
- [ ] `TraceStore` protocol respected: no api/ code touches SQLite directly; `telemetry/` never imports `agents/` (D-027)
|
||||
- [ ] Flood control (G-3): burst past `AI_TELEMETRY_MAX_EVENTS_PER_TASK` → connection closed 1008 + trace marked `INCOMPLETE_FLOODED`; no silent event drop on overflow
|
||||
- [ ] `pnpm ai:test` green; `packages/types` telemetry TS types compile (`pnpm typecheck`)
|
||||
**Verification hooks P2:** image builds locally + on the runner; pushed to the registry under the phase tag + latest; smoke gate green (HTML at /, degraded fabric reported honestly); degrade tests green in the fast pytest lane; in-container doctor/verify/refusals green (CLI tests); `.dockerignore` excludes secrets; all existing suites stay green (no regressions from capability.py promotion — the real userns suites unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Process-Trace Grading Engine
|
||||
## Phase 3: Debian-Native Channel (REQ-8-003)
|
||||
|
||||
**Requirements:** REQ-3-004
|
||||
**Goal:** Deterministic feature computation over traces (D-028) → compact digest → LLM rubric scoring via existing D-020 JSON defense → validated structured scores stored in `GradeStore`; LLM never sees the raw trace; engine calibrated against v0.2 mock corpora so process quality separates paste-and-run from iterative debugging
|
||||
**Goal:** arm64 binaries + .deb packages for x64 + arm64, attached to every release; install.sh resolves arch; every channel ends at doctor/verify green.
|
||||
|
||||
### Wave 1: Features + grades store (parallel — no shared files)
|
||||
### Wave 3-1: arm64 cross-build (cli-engineer)
|
||||
|
||||
#### Task 3-1-01: Deterministic trace digest (features)
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-004
|
||||
- **Files:** `apps/ai-service/ai_service/grading/__init__.py`, `apps/ai-service/ai_service/grading/features.py`, `apps/ai-service/tests/grading/__init__.py`, `apps/ai-service/tests/grading/test_features.py`
|
||||
- **Action:** Pure compute module (D-028): `compute_digest(trace: list[TelemetryEvent]) -> TraceDigest`. Deterministic features: test pass/fail counts + final status; edit count; error/fix cycle count + mean fix latency; idle gaps (>Ns, count + total); command category histogram (build/test/file/nav/debug/other); session duration; first-test-pass offset. `TraceDigest` pydantic model — compact (bounded size, no raw commands), LLM-safe.
|
||||
- **Verify:** `pnpm ai:test` — test_features green over synthetic traces: paste-and-run trace (0 error/fix cycles, single test pass at end) vs iterative trace (many cycles) produce observably different digests
|
||||
- **Task 3-1-01** (cli-engineer): `build-binary.mjs` arch parameter per R-25 — `--arch x64|arm64`; the ONLY arch-specific step (copy of `process.execPath`) replaced by the official arm64 node tarball download (probe-verified 200/30MB) for arm64; esbuild bundle + postject injection unchanged (arch-agnostic file surgery); output `nextcraft-linux-arm64` + `.sha256`. **Decided fallback ladder (G-201):** probe QEMU-free cross-arch SEA on the runner first; if the blob fails under emulation, fall back to binfmt+qemu for FULL arm64 builds; if the runner cannot install binfmt, ship v0.8 x64-only (arm64 deferral recorded in ship notes + REQUIREMENTS) — never leave the release lane red or blocked.
|
||||
- **Task 3-1-02** (cli-engineer): Binary contract tests — arm64 binary `--version` reports the stamped tag (under emulation in CI); x64 path byte-identical to the v0.4 pipeline (checksum + version gates unchanged).
|
||||
|
||||
#### Task 3-1-02: GradeStore protocol + SQLite implementation
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-004
|
||||
- **Files:** `apps/ai-service/ai_service/grading/store.py`, `apps/ai-service/tests/grading/test_store.py`
|
||||
- **Action:** `GradeStore` protocol (D-027): `save(grade)`, `get(learner_id, task_id)`, `list_for_learner(learner_id)`, `close()`. SQLModel `GradeRecord`: learner_id, task_id, variant_seed (null until P4), digest (JSON), scores (JSON), verdict, model, created_at. PK (learner_id, task_id). Postgres-migration-ready.
|
||||
- **Verify:** `pnpm ai:test` — test_store green (save/get/list roundtrip, overwrite-on-regrade documented)
|
||||
### Wave 3-2: deb packages + installer + release extension (cli-engineer + devops-engineer)
|
||||
|
||||
### Wave 2: Grading engine + calibration (depends on Wave 1)
|
||||
- **Task 3-2-01** (cli-engineer): `scripts/build-deb.sh` per R-22/R-23 — `dpkg-deb --root-owner-group -Zxz`, package `nextcraft` Version = tag minus `v`, empty Depends + `Recommends: nodejs, python3, git, util-linux`, contents: `/usr/bin/nextcraft` (arch-matched binary) + `/usr/share/doc/nextcraft/` (README + guide); postinst = advisory doctor (`|| echo` → always exit 0, never fails dpkg per D-067); outputs `nextcraft_<ver>_<arch>.deb` + sidecars.
|
||||
- **Task 3-2-02** (cli-engineer): `install.sh` arch-resolve per R-24 — `uname -m` x86_64 → x64 asset, aarch64/arm64 → arm64 asset; checksum + version integrity gates byte-identical for both; degradation messaging unchanged.
|
||||
- **Task 3-2-03** (devops-engineer): Release workflow extension — arm64 binary build + both debs join the release-asset attach step (x64 binary, arm64 binary, 2× sha256, 2× deb — full asset matrix per D-064); smoke: deb installs in a fresh debian container, `nextcraft --version` + advisory doctor run. **Proof via the G-203 temporary-rc-tag mechanism.**
|
||||
- **Task 3-2-04** (security-auditor): Asset audit — all 6 release assets checksum-verified, version stamps match the tag, no secrets in debs.
|
||||
|
||||
#### Task 3-2-01: Rubric scoring engine
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-004
|
||||
- **Files:** `apps/ai-service/ai_service/grading/engine.py`, `apps/ai-service/ai_service/prompts/grading.py` (new), `apps/ai-service/tests/grading/test_engine.py`
|
||||
- **Action:** `GradingEngine.grade(learner_id, task_id) -> GradeRecord`: **trace-completeness gate (G-4)** — first call `TraceStore.gaps()` + check the trace `INCOMPLETE_FLOODED` flag; if gaps are non-empty OR the trace is flagged incomplete → return `verdict=UNGRADABLE_TRACE_INCOMPLETE` (a first-class verdict, not an exception) surfacing the gap list; a credential is NEVER issued from a gapped/incomplete trace. Otherwise: load trace via `TraceStore` → `compute_digest` → render rubric prompt (`prompts/grading.py`: criteria + level anchors for process quality, correctness, debugging discipline, test usage; a-4: treat high edit/command churn with no test-progress as a process-quality negative) → LLM structured output through the **existing D-020 4-layer defense** (`agents/structured.py` reused — engine composes it, never duplicates it) → validate `RubricScore` model (per-criterion 0-4 + strengths + gaps + verdict) → persist via `GradeStore`. Grading depends on `llm/` + `telemetry/` + `prompts/` only (boundary). Mock provider scripts deterministic rubric JSON for tests, including the INCOMPLETE path.
|
||||
- **Verify:** `pnpm ai:test` — test_engine green (mock provider: digest-only prompt asserted — **raw trace string absent from prompt**; validated scores returned; malformed JSON exercises D-020 retry; unknown trace → error)
|
||||
|
||||
#### Task 3-2-02: Calibration against v0.2 mock corpora
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-004
|
||||
- **Files:** `apps/ai-service/ai_service/corpus/trace_fixtures.py` (new), `apps/ai-service/tests/grading/test_calibration.py`
|
||||
- **Action:** Synthetic trace fixtures aligned with v0.2 `corpus/telemetry.py` + `corpus/artifacts.py` scenario IDs (strong/lazy/struggling builder archetypes). Assert grading separates them: strong archetype scores ≥ lazy archetype on process-quality criterion (mock provider maps digest shape → scripted scores; test asserts the ordering contract + that fixture IDs align with existing corpus IDs, D-021).
|
||||
- **Verify:** `pnpm ai:test` — test_calibration enforces the ordering contract
|
||||
|
||||
### Wave 3: Grading endpoint (depends on Wave 2)
|
||||
|
||||
#### Task 3-3-01: Assessment grade endpoint (real traces)
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-004
|
||||
- **Files:** `apps/ai-service/ai_service/api/assessment.py` (update), `apps/ai-service/tests/api/test_grading.py`
|
||||
- **Action:** `POST /v1/assessment/grade {learner_id, task_id}` → `GradingEngine` → validated `RubricScore` JSON; `GET /v1/assessment/grade/{learner_id}/{task_id}` → stored grade; unknown trace → 404. Composed via DI (api/ owns wiring; engine knows nothing of FastAPI).
|
||||
- **Verify:** `pnpm ai:test` — test_grading green (grade roundtrip via TestClient with mock provider; 404 on unknown; GET after POST returns same scores)
|
||||
|
||||
### Must-Haves (Phase 3)
|
||||
- [ ] Engine emits structured rubric-aligned scores from a **real process trace** (not pre-baked input) — TestClient roundtrip green
|
||||
- [ ] Deterministic features computed in code (test pass/fail, edit count, error/fix cycles, idle gaps, command categories); LLM receives the **digest only** — test asserts the raw trace never reaches the prompt (D-028)
|
||||
- [ ] Scores distinguish process quality: iterative-debugging archetype out-scores paste-and-run on the process criterion (calibration contract test)
|
||||
- [ ] Grades persisted + retrievable by learner+task via GradeStore (SQLite, protocol-wrapped, D-027)
|
||||
- [ ] Boundary rules hold: `grading/` imports no `api/`/`agents/` internals except the shared D-020 structured defense; `pnpm ai:test` + `pnpm ai:lint` green
|
||||
- [ ] Incomplete-trace gate (G-4): gapped or `INCOMPLETE_FLOODED` trace → `verdict=UNGRADABLE_TRACE_INCOMPLETE` with the gap list; no credential issued from an incomplete trace (test green)
|
||||
**Verification hooks P3:** arm64 binary + 2 debs build in CI (release lane green on the phase tag); install.sh fetches the right asset per arch (fixture-server test extended, tamper-rejection unchanged); deb installs → binary on PATH → advisory doctor runs; x64 pipeline byte-identical (no regression); asset matrix complete on the v0.7.3 release.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Variant Task Generation
|
||||
## Phase 4: CLI Onboarding (REQ-8-004)
|
||||
|
||||
**Requirements:** REQ-3-005
|
||||
**Goal:** Template library with typed parameter slots (D-029) + seeded LLM instantiation + per-learner variant registry (SQLite) with difficulty-normalization anchors; two learners on the same competency get provably distinct, reproducible, auditable tasks
|
||||
**Goal:** `nextcraft` alone takes an admin zero → running — channel-aware doctor, `guide`, honest refusals, per-channel E2E.
|
||||
|
||||
### Wave 1: Templates + store (parallel — no shared files)
|
||||
### Wave 4-1: Channel awareness + guide (cli-engineer)
|
||||
|
||||
#### Task 4-1-01: Task template library
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-005
|
||||
- **Files:** `apps/ai-service/ai_service/variants/__init__.py`, `apps/ai-service/ai_service/variants/templates.py`, `apps/ai-service/tests/variants/__init__.py`, `apps/ai-service/tests/variants/test_templates.py`
|
||||
- **Action:** ≥3 initial task templates bound to existing competency IDs (D-021 alignment). `TaskTemplate`: id, competency_id, statement skeleton with `{slot}` placeholders, `ParameterSlot[]` (name, type: enum/int-range/string-set, allowed values), `rubric anchors` (difficulty normalization: expected feature envelope — e.g. expected edit-count band — used by grading context), starter-file scaffolds served to the sandbox. Seeded slot sampler is pure code (`random.Random(seed)`), fully reproducible.
|
||||
- **Verify:** `pnpm ai:test` — test_templates green (slot validation: bad value rejected; seeded sampling reproducible across runs; all templates bind to real competency IDs)
|
||||
- **Task 4-1-01** (cli-engineer): Doctor channel awareness per R-30 — container-mode branch (from P2), deb-install detection (`dpkg -s nextcraft` or binary-at-/usr/bin heuristics; report channel + its upgrade semantics per D-071), binary-install default. Each channel's doctor outputs the right next-step line (deploy --service vs docker pull + recreate).
|
||||
- **Task 4-1-02** (cli-engineer): `nextcraft guide` per D-072 — offline-first embedded man-page text (no runtime fetches): sections per channel (first install, upgrade, troubleshooting pointers), forge URLs as pointers. `guide [topic]` optional arg (guide deploy / guide docker / guide deb / guide troubleshooting).
|
||||
- **Task 4-1-03** (cli-engineer): CLI tests for the new surface — dispatch tests (guide routing, refusals, doctor branches per detected channel via env/fixtures); exit-code contract unchanged (0 ok / 1 failure / 2 usage).
|
||||
|
||||
#### Task 4-1-02: VariantStore protocol + SQLite implementation
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-005
|
||||
- **Files:** `apps/ai-service/ai_service/variants/store.py`, `apps/ai-service/tests/variants/test_store.py`
|
||||
- **Action:** `VariantStore` protocol (D-027): `save(variant)`, `get(learner_id, template_id_or_task_id)`, `list_for_learner(learner_id)`, `list_by_template(template_id)`, `close()`. SQLModel `VariantRecord`: learner_id, task_id (the grading/telemetry task key), template_id, seed, params (JSON), statement (rendered), created_at. Unique (learner_id, template_id).
|
||||
- **Verify:** `pnpm ai:test` — test_store green (roundtrip, unique constraint, audit listing)
|
||||
### Wave 4-2: Per-channel E2E (cli-engineer + devops-engineer)
|
||||
|
||||
### Wave 2: Generator (depends on Wave 1)
|
||||
- **Task 4-2-01** (cli-engineer): Docker-channel E2E (env-gated like the v0.4 E2E): pull the released image, run, in-container doctor/verify green, refusals honest, teardown. Wired as `cli:test:e2e:docker`, excluded from default lanes.
|
||||
- **Task 4-2-02** (cli-engineer): deb-channel E2E (env-gated): fresh debian container, install the released deb, advisory doctor, `nextcraft guide`, teardown. `cli:test:e2e:deb`.
|
||||
- **Task 4-2-03** (lead-developer): The binary channel E2E already exists (fresh-clone, v0.4) — verify it still passes with the arch-resolve installer; wire the three E2E names into the docs so admins can reproduce them.
|
||||
|
||||
#### Task 4-2-01: Seeded LLM variant generator
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-005
|
||||
- **Files:** `apps/ai-service/ai_service/variants/generator.py`, `apps/ai-service/ai_service/prompts/variant.py` (new), `apps/ai-service/tests/variants/test_generator.py`
|
||||
- **Action:** `VariantGenerator.generate(learner_id, template_id) -> VariantRecord`: derive seed (`sha256(template_id|learner_id|milestone)` — reproducible, D-029); sample typed slots in code; render a fill prompt (statement skeleton + concrete slot values) → LLM via D-020 structured defense → unique task statement + starter files → validate → persist (seed + params + statement) via `VariantStore`. Cache: existing (learner,template) returns the stored variant (no duplicate work). Mock provider scripts deterministic statements per seed for tests.
|
||||
- **Verify:** `pnpm ai:test` — test_generator green: two different learner_ids → distinct statements for the same template; same learner twice → identical stored variant (reproducible); params JSON contains only schema-valid slot values; **fairness envelope (a-5):** two variants of one template compute digests within the template's expected feature envelope (comparable slot complexity/difficulty features) — "same bar" is testable, not asserted
|
||||
|
||||
### Wave 3: Variant endpoint + TS types (depends on Wave 2)
|
||||
|
||||
#### Task 4-3-01: Variant task endpoint
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-005
|
||||
- **Files:** `apps/ai-service/ai_service/api/variants.py` (new), `apps/ai-service/ai_service/main.py` (update), `apps/ai-service/tests/api/test_variants.py`
|
||||
- **Action:** `POST /v1/variants {learner_id, template_id or competency_id}` → generated (or cached) variant: statement, starter files, task_id, seed; `GET /v1/variants/{task_id}` → stored variant; `GET /v1/variants?learner_id=` → learner's variants. DI wiring in api/ only.
|
||||
- **Verify:** `pnpm ai:test` — test_variants green (generate→get roundtrip; cache hit on regenerate; distinct learners → distinct statements asserted at the API layer)
|
||||
|
||||
#### Task 4-3-02: TS types for variants (+ grades)
|
||||
- **Persona:** data-engineer — **REQ:** REQ-3-005
|
||||
- **Files:** `packages/types/variants.ts` (new), `packages/types/grading.ts` (new), `packages/types/index.ts` (update)
|
||||
- **Action:** TS `TaskVariant`, `VariantParams`, `RubricScore`, `GradeRecord` matching Python models (cross-referencing headers; same field names). Consumed by Phase 6 surfaces.
|
||||
- **Verify:** `pnpm typecheck` passes
|
||||
|
||||
### Must-Haves (Phase 4)
|
||||
- [ ] Two learners requesting the same competency receive **provably distinct** task variants (API-level test)
|
||||
- [ ] Seed derivation reproducible: same (template, learner) → same variant, served from cache without a second LLM call (D-029)
|
||||
- [ ] Variant seed + typed params persisted + auditable (VariantStore listing; proctoring cross-check path exists)
|
||||
- [ ] Difficulty normalization anchors present per template and shipped to the grader prompt context
|
||||
- [ ] Starter-file scaffolds defined per template (P6 wires them into the sandbox workdir)
|
||||
- [ ] `pnpm ai:test` + `pnpm typecheck` green; `variants/` imports no api/ (boundary)
|
||||
**Verification hooks P4:** all CLI suites green at current counts; guide renders offline (no network mock asserted); doctor reports the right channel in all three modes (fixture tests); docker + deb E2E green when env-gated on; existing binary E2E green; no regression in the 61-test CLI baseline surface (net new tests allowed).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Oral / Voice Defense
|
||||
## Phase 5: Admin Guides (REQ-8-005)
|
||||
|
||||
**Requirements:** REQ-3-006
|
||||
**Goal:** `VoiceProvider` protocol with mock + browser fallback (D-030) + seventh `Examiner` agent streaming over existing SSE + `DefenseStore` persisting transcript + integrity signals. **Real server STT/TTS (`OpenAIAudioProvider`) is DEFERRED to v0.4 (with KYC, when there's a real key + real users)** — voice is mock-first (D-030) and the `/audio/*` real path could never be exercised in CI, so v0.3 proves the full defense *dialogue* + integrity-signal pipeline over mock + browser-native fallback only; the protocol seam keeps the real provider a drop-in later.
|
||||
**Goal:** The admin runbook — every channel's first-install + upgrade recipe, evidence-backed, one entry point.
|
||||
|
||||
### Wave 1: Voice provider layer (parallel — no shared files)
|
||||
### Wave 5-1: The runbook (lead-developer + cli-engineer)
|
||||
|
||||
#### Task 5-1-01: VoiceProvider protocol + mock provider + browser fallback
|
||||
- **Persona:** voice-engineer — **REQ:** REQ-3-006
|
||||
- **Files:** `apps/ai-service/ai_service/voice/__init__.py`, `apps/ai-service/ai_service/voice/base.py`, `apps/ai-service/ai_service/voice/mock.py`, `apps/ai-service/ai_service/voice/browser.py`, `apps/ai-service/ai_service/voice/factory.py`, `apps/ai-service/tests/voice/__init__.py`, `apps/ai-service/tests/voice/test_mock.py`, `apps/ai-service/tests/voice/test_factory.py`
|
||||
- **Action:** `VoiceProvider` protocol mirroring `LLMProvider` (D-030): `transcribe(audio: bytes, fmt) -> TranscriptSegment` + `synthesize(text, voice) -> AsyncIterator[bytes]`. `MockVoiceProvider`: deterministic canned transcript (scripted per test), canned 1kHz-tone WAV bytes, scripted failure modes. `browser.py`: fallback **descriptor** (`sr_available: true`, endpoint hints) the web client uses to select browser-native `SpeechRecognition`/`speechSynthesis` when no server provider. `factory.py`: `AI_VOICE_PROVIDER=browser | mock` (default mock when no key). **`OpenAIAudioProvider` (real server STT/TTS) intentionally NOT built in v0.3 — deferred to v0.4**; the protocol is its future seam. `voice/` never imports `agents/` or `api/`.
|
||||
- **Verify:** `pnpm ai:test` — test_mock + test_factory green (deterministic transcribe/synthesize; failure modes; factory selects mock with empty key, browser when provider=browser; zero network calls)
|
||||
- **Task 5-1-01** (lead-developer): `deploy/README.md` → the full admin runbook per R-31/R-32 — structure: pick-your-channel matrix → docker channel (docker run, volumes, privileged variant for full in-container sandboxes, upgrade = pull + recreate per D-071) → deb channel (dpkg -i, advisory doctor, deploy --service integration) → binary channel (install.sh one-liner, existing recipe preserved) → upgrade matrix table → HAProxy/systemd recipes (existing content preserved) → troubleshooting tables (runner/registry/docker-degrade/deb — extended from the CLI README's) → evidence appendix (which test or live probe backs each recipe).
|
||||
- **Task 5-1-02** (cli-engineer): `nextcraft guide` content parity — the runbook's channel sections are the canonical text, extracted to a single shared source (G-210); the guide renders from that source; divergence = parity test failure (never doc drift).
|
||||
- **Task 5-1-03** (lead-developer): README quickstart updated — three channels listed with one command each; the v0.8 milestone line added to the history table.
|
||||
|
||||
#### Task 5-1-03: DefenseStore protocol + SQLite implementation
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-006
|
||||
- **Files:** `apps/ai-service/ai_service/voice/defense_store.py`, `apps/ai-service/tests/voice/test_defense_store.py`
|
||||
- **Action:** `DefenseStore` protocol (D-027): `start(defense)`, `append_turn(defense_id, turn)`, `finalize(defense_id, integrity_signals)`, `get(defense_id)`, `list_for_learner(learner_id)`, `close()`. SQLModel `DefenseRecord` (id, learner_id, task_id, status, created/finished_at) + `DefenseTurn` (defense_id FK, turn seq, role examiner|learner, text, ts, latency_ms) + integrity signals JSON on the record (long pauses, off-scope cadence markers — A-109).
|
||||
- **Verify:** `pnpm ai:test` — test_defense_store green (start→append turns→finalize→get roundtrip; ordered turns by seq)
|
||||
### Wave 5-2: Guide verification (lead-developer)
|
||||
|
||||
### Wave 2: Examiner agent (depends on Wave 1)
|
||||
- **Task 5-2-01** (lead-developer): Every runbook recipe evidence-checked — each recipe carries a pointer to its backing test (E2E name, workflow job, or live-box probe from prior phases); the parity test proves guide ↔ runbook agreement; doc lint (markdown structure) if cheap.
|
||||
|
||||
#### Task 5-2-01: Examiner agent (seventh agent)
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-006
|
||||
- **Files:** `apps/ai-service/ai_service/agents/examiner.py`, `apps/ai-service/ai_service/prompts/examiner.py` (new), `apps/ai-service/ai_service/agents/registry.py` (update: central registration, G-4 pattern), `apps/ai-service/tests/agents/test_examiner.py`
|
||||
- **Action:** `ExaminerAgent(BaseAgent)` (D-030/A-109): builds questions from learner transcript + trace digest + (P4) variant statement; probes understanding + challenges process choices ("why did you choose X at step N?"); streams questions over the existing SSE pipeline; `structured` verdict mode returns verdict + per-answer integrity signal list (long pause flags, off-scope answers) computed from turn metadata; calls voice **only through the `VoiceProvider` protocol** (PERSONAS conflict rule — never concrete providers). Session-scoped history reused from v0.2.
|
||||
- **Verify:** `pnpm ai:test` — test_examiner green (question stream references trace-digest facts; verdict structured output validates via D-020 defense; registry resolves all seven agents; mock-VoiceProvider wiring through protocol only — asserted by import scan in test)
|
||||
|
||||
### Wave 3: Defense endpoints (depends on Wave 2)
|
||||
|
||||
#### Task 5-3-01: Defense session + audio endpoints
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-006
|
||||
- **Files:** `apps/ai-service/ai_service/api/defense.py` (new), `apps/ai-service/ai_service/main.py` (update), `apps/ai-service/tests/api/test_defense.py`
|
||||
- **Action:** `POST /v1/defense/start {learner_id, task_id}` → creates DefenseRecord + streams the first examiner question (SSE, agent=examiner); `POST /v1/defense/{id}/answer` (multipart audio from browser MediaRecorder, or `{text}` for typed fallback) → STT via VoiceProvider → append learner turn → stream examiner follow-up (SSE) → TTS audio chunks over `GET /v1/defense/{id}/audio/{turn_id}`; `POST /v1/defense/{id}/finish` → verdict + integrity signals persisted; `GET /v1/defense/{id}` → full transcript + signals. Browser-fallback mode: when provider=browser, start returns the fallback descriptor instead of server audio.
|
||||
- **Verify:** `pnpm ai:test` — test_defense green (full loop with mock voice + mock LLM: start → answer(text) → answer(audio bytes) → finish → transcript retrievable with per-turn latency; unknown id → 404; `GET` signals present after finish)
|
||||
|
||||
### Wave 4: Examiner latency instrumentation (depends on Wave 3)
|
||||
|
||||
#### Task 5-4-01: Per-turn latency instrumentation (mock-based)
|
||||
- **Persona:** voice-engineer — **REQ:** REQ-3-006
|
||||
- **Files:** `apps/ai-service/tests/voice/test_latency.py`, `apps/ai-service/README.md` (update: conversational-budget doc + v0.4 voice note)
|
||||
- **Action:** Instrument per-turn latency (STT ms + LLM TTFT ms + TTS ms) recorded on each DefenseTurn; deterministic test over mock providers asserts instrumentation presence + `latency_ms` populated + budget constant defined (mock runs are near-instant — wall-clock asserted in v0.4 against a real endpoint). README documents the conversational-latency target as a v0.4 acceptance criterion (real STT/TTS deferred per CUT-1/G-7).
|
||||
- **Verify:** `pnpm ai:test` — test_latency green (latency_ms fields populated on every turn; budget constant defined); README documents the deferred real-voice acceptance probe
|
||||
|
||||
### Must-Haves (Phase 5)
|
||||
- [ ] Spoken defense runs end-to-end over HTTP with mock providers: start → answer (audio + typed fallback) → examiner follow-up streams → verdict + transcript persisted (automated)
|
||||
- [ ] Examiner is the seventh registered agent; streams over the existing SSE envelope (meta agent=examiner)
|
||||
- [ ] Instrumented per-turn latency fields populated on every DefenseTurn (STT ms + LLM TTFT ms + TTS ms); conversational budget named (A-109)
|
||||
- [ ] `VoiceProvider` protocol respected: examiner + api touch voice only via the protocol; mock-first — **no task requires a real voice key to pass**
|
||||
- [ ] Browser-native SR/TTS fallback descriptor returned when no server voice provider configured (mock/browser are first-class, D-030)
|
||||
- [ ] Real server STT/TTS (`OpenAIAudioProvider`) explicitly deferred to v0.4 (with real keys/users); the defense pipeline is fully proven over mock+browser — documented in README + release note
|
||||
- [ ] Optional future voice config noted for v0.4 (`AI_VOICE_BASE_URL` / `AI_VOICE_API_KEY`) in `.env.example` + README; keys only in gitignored `.ciagent/.env.secrets`; tests never call a voice API
|
||||
- [ ] Boundary rules hold: `voice/` imports no `agents/`/`api/`; `pnpm ai:test` + `pnpm ai:lint` green
|
||||
**Verification hooks P5:** runbook complete (all channels, upgrade matrix, troubleshooting, evidence appendix); parity test green; README quickstart accurate; no unevidenced recipe claims (each has a pointer); existing deploy recipes (systemd/HAProxy) preserved verbatim-accurate.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Agent Re-grounding + Learner Surface Integration
|
||||
## Phase 6: Final Review + Ship (milestone release, v0.7.x line)
|
||||
|
||||
**Requirements:** REQ-3-007, REQ-3-008
|
||||
**Goal:** Lab/Assessor/Proctor consume real engine inputs (live telemetry, grading output, defense signals) with **no mock fallback in the learner path**; the v0.1 sandbox + assessment mockups become real — in-browser build/run (Run/Test buttons + read-only exec output, CUT-2 — no interactive shell), live telemetry panel, browser/typed voice defense, live grading; `pnpm build` + `pnpm typecheck` + full `pnpm ai:test` green
|
||||
**Note:** E2E verification runs against the real engines over HTTP with `AI_PROVIDER=mock` + `AI_VOICE_PROVIDER=mock` permitted (G-2 precedent) — the requirement is real engine plumbing (sandbox/telemetry/grading/defense over real endpoints, no corpus mocks in the learner path); a cloud outage must not block P6.
|
||||
|
||||
### Wave 1: Agent re-grounding (parallel — no shared files)
|
||||
|
||||
#### Task 6-1-01: Lab agent on live telemetry
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-007
|
||||
- **Files:** `apps/ai-service/ai_service/agents/lab.py` (update), `apps/ai-service/ai_service/prompts/lab.py` (update: render real trace digest), `apps/ai-service/tests/agents/test_lab_live.py` (new)
|
||||
- **Action:** Lab consumes a **live trace digest** (grading/features `compute_digest` over `TraceStore` events) instead of `corpus/telemetry.py`. build_messages renders digest facts (recent commands, failing tests, idle). Mock-provider scripts assert digest-derived content. v0.2 corpus path removed from the agent (dormant corpus retained until Task 6-1-04 check).
|
||||
- **Verify:** `pnpm ai:test` — test_lab_live green: feedback references events actually present in a seeded SQLite trace (not corpus fixtures); no `corpus.telemetry` import in `agents/lab.py` (AST-asserted)
|
||||
|
||||
#### Task 6-1-02: Assessor agent on grading output
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-007
|
||||
- **Files:** `apps/ai-service/ai_service/agents/assessor.py` (update), `apps/ai-service/ai_service/prompts/assessor.py` (update), `apps/ai-service/tests/agents/test_assessor_live.py` (new)
|
||||
- **Action:** Assessor consumes `GradeStore` output (validated `RubricScore` + digest) for learner+task instead of pre-baked artifacts/transcripts; renders strengths/gaps/verdict with rubric-anchored coaching framing. Structured output unchanged (D-020).
|
||||
- **Verify:** `pnpm ai:test` — test_assessor_live green: given a real stored grade, Assessor output reflects its scores; corpus artifact path gone from the agent (AST-asserted)
|
||||
|
||||
#### Task 6-1-03: Proctor agent on telemetry + defense signals
|
||||
- **Persona:** ai-engineer — **REQ:** REQ-3-007
|
||||
- **Files:** `apps/ai-service/ai_service/agents/proctor.py` (update), `apps/ai-service/ai_service/prompts/proctor.py` (update), `apps/ai-service/tests/agents/test_proctor_live.py` (new)
|
||||
- **Action:** Proctor consumes real integrity inputs: idle gaps + command cadence from the trace digest + defense integrity signals from `DefenseStore` → classified signals + coaching interventions (supportive tone retained). Cross-checks variant seed params (P4) for off-template work.
|
||||
- **Verify:** `pnpm ai:test` — test_proctor_live green: signals derived from seeded real trace + defense records; corpus proctor scenarios no longer imported (AST-asserted)
|
||||
|
||||
#### Task 6-1-04: Corpus dormancy + mockup removal verification
|
||||
- **Persona:** lead-developer — **REQ:** REQ-3-007
|
||||
- **Files:** `apps/ai-service/ai_service/corpus/telemetry.py`, `apps/ai-service/ai_service/corpus/artifacts.py`, `apps/ai-service/ai_service/corpus/` (README note), `apps/ai-service/tests/test_corpus_dormancy.py` (new)
|
||||
- **Action:** Verify no production code path imports `corpus/telemetry.py` or `corpus/artifacts.py` anymore (test scans imports across `agents/`, `api/`, engines). Retain files as Phase-3 calibration history with a header note marking them **dormant — v0.2 mocks, not used at runtime**; learner-context corpus stays (agents still need learner context). Disposes v0.2's G-5-class dead-code risk deliberately.
|
||||
- **Verify:** `pnpm ai:test` — test_corpus_dormancy green (zero runtime importers); suite otherwise unchanged
|
||||
|
||||
### Wave 2: Client plumbing + design primitives (parallel — no shared files)
|
||||
|
||||
#### Task 6-2-01: Sandbox build-panel engine client (run/test-only — no raw shell relay)
|
||||
- **Persona:** frontend-engineer — **REQ:** REQ-3-008
|
||||
- **Files:** `apps/web/hooks/use-sandbox-session.ts` (new), `apps/web/lib/engine-client.ts` (new), `apps/web/.env.example` (update)
|
||||
- **Action:** `engine-client.ts`: typed fetch client for `/v1/sandboxes` (create/destroy — learner_id from the v0.3 mock session constant, allowlisted server-side per G-5), `/v1/sandboxes/{id}/files` (workspace CRUD), `/v1/sandboxes/{id}/exec` (run/test), `/v1/variants`, `/v1/assessment/grade`, `/v1/telemetry/traces`, `/v1/defense/*`; base `NEXT_PUBLIC_AI_SERVICE_URL`. `use-sandbox-session.ts`: create sandbox+variant on task open → destroy on unmount (idempotent cleanup, AbortController pattern); 503 pool-full → user-facing "environment busy, retry" (D-032); 403/429 abuse-control surfaced honestly (G-5). **CUT-2 (G-8): NO raw interactive WS terminal relay (keystroke-level stdin/stdout) in v0.3** — the credential pipeline needs *process events* (from Run/Test + file edits), not a live shell; the interactive xterm relay is the most fragile real-time piece and is deferred to v0.4. The build panel is a **Run/Test output viewer** (exec results + telemetry pulse render), not an interactive shell. `@xterm/xterm` is therefore NOT a dependency in v0.3.
|
||||
- **Verify:** `pnpm install && pnpm typecheck` pass; hook unmount destroys the sandbox (manual probe: `curl localhost:8420/v1/sandboxes` shows count drop after navigation); RUN/TEST buttons produce streamed output + telemetry events in the trace
|
||||
|
||||
#### Task 6-2-02: New design primitives
|
||||
- **Persona:** design-system-engineer — **REQ:** REQ-3-008
|
||||
- **Files:** `packages/ui/src/primitives/terminal-frame.tsx` (new), `packages/ui/src/primitives/mic-control.tsx` (new), `packages/ui/src/primitives/grade-badge.tsx` (new), `packages/ui/src/primitives/telemetry-status.tsx` (new), `packages/ui/src/primitives/transcript-viewer.tsx` (new), `packages/ui/src/primitives/index.ts` (update), `packages/ui/src/index.ts` (update)
|
||||
- **Action:** Token-driven primitives: TerminalFrame (CUT-2: a read-only exec-output viewer chrome — streams Run/Test results, NOT an interactive shell), MicControl (record/stop with consent state + no-mic fallback state, MediaRecorder permission UX), GradeBadge (verdict rendering), TelemetryStatus (live event pulse / disconnected indicator), TranscriptViewer (examiner/learner turn list). Dark mode + WCAG AA; stories for each.
|
||||
- **Verify:** primitives import from `@nextcraft/ui`; Storybook stories render dark + light; `pnpm build` (ui package) passes
|
||||
|
||||
#### Task 6-2-03: Defense TS types
|
||||
- **Persona:** data-engineer — **REQ:** REQ-3-008
|
||||
- **Files:** `packages/types/defense.ts` (new), `packages/types/index.ts` (update)
|
||||
- **Action:** TS `DefenseSession`, `DefenseTurn`, `IntegritySignal`, `Verdict` mirroring P5 Python models (cross-referencing header).
|
||||
- **Verify:** `pnpm typecheck` passes
|
||||
|
||||
### Wave 3: Real build surface (depends on Wave 2)
|
||||
|
||||
#### Task 6-3-01: Sandbox mockup → real in-browser IDE
|
||||
- **Persona:** frontend-engineer — **REQ:** REQ-3-008
|
||||
- **Files:** `apps/web/app/(learner)/build/[competencyId]/page.tsx` (rewrite), `apps/web/components/learner/sandbox-terminal.tsx` (new), `apps/web/components/learner/file-tree.tsx` (new), `apps/web/components/learner/run-controls.tsx` (new), `apps/web/components/learner/lab-feedback-panel.tsx` (update: live trace)
|
||||
- **Action:** Replace the mockup with the real build environment (A-103, CUT-2): file tree (HTTP CRUD into the sandbox workdir via `/v1/sandboxes/{id}/files` routes added to api/sandboxes — read/write/list workspace files), syntax-highlight editor (existing), **Run**/**Test** buttons (exec in sandbox; results stream to a read-only TerminalFrame output panel — no interactive shell), starter files from the P4 variant scaffold. Lab panel posts `learner_id+task_id` → streams Lab feedback over the **live** trace (no scenario IDs). Telemetry sidebar shows live TelemetryStatus. Pool-full 503 → busy state with retry; 403/429 surfaced.
|
||||
- **Verify:** with ai-service up: open `/build/comp-01` → variant statement + starter files load → edit a file → **Run** executes the command in-sandbox and output renders in the panel → **Test** runs the test suite in-sandbox → Lab panel streams digest-derived feedback → telemetry status shows live events. Manual probe documented; `pnpm typecheck` green
|
||||
|
||||
### Wave 4: Live defense + grading surfaces (depends on Waves 2-3)
|
||||
|
||||
#### Task 6-4-01: Assessment mockup → live defense + live grading
|
||||
- **Persona:** frontend-engineer — **REQ:** REQ-3-008
|
||||
- **Files:** `apps/web/app/(learner)/defend/[competencyId]/page.tsx` (rewrite), `apps/web/components/learner/defense-session.tsx` (new), `apps/web/components/learner/assessor-results-panel.tsx` (update), `apps/web/components/learner/proctor-banner.tsx` (update), `apps/web/components/learner/oral-defense-interface.tsx` (rewrite or remove)
|
||||
- **Action:** Real assessment flow: **Start Defense** → POST `/v1/defense/start` → examiner question streams → learner answers via MicControl (MediaRecorder webm/opus → multipart POST) with typed fallback when mic denied or `provider=browser` (native `SpeechRecognition`/`speechSynthesis` path per fallback descriptor) → follow-ups stream → **Finish** → verdict + integrity signals panel (TranscriptViewer, GradeBadge) + **Grade My Work** → POST `/v1/assessment/grade` → structured rubric bars render. Proctor banner reads signals for task_id. Loading + error states throughout; no mock defense data remains in the learner path.
|
||||
- **Verify:** with ai-service up: full defense loop runs in-browser (typed fallback acceptable in CI-less manual probe; mic path exercised manually with permission granted); grade panel renders real rubric scores; `pnpm typecheck` green
|
||||
|
||||
### Wave 5: End-to-end verification (depends on Waves 3-4)
|
||||
|
||||
#### Task 6-5-01: Full learner-path E2E probe + green builds
|
||||
- **Persona:** backend-engineer — **REQ:** REQ-3-007, REQ-3-008
|
||||
- **Files:** `apps/ai-service/tests/api/test_e2e_credential_flow.py` (new), `apps/ai-service/README.md` (update: E2E probe doc)
|
||||
- **Action:** Endpoint-level E2E (mock LLM/voice providers, real engines): create variant → create sandbox with task_id → ingest trace events via real sandbox exec → POST grade → start defense (typed answers) → finish → assert: trace persisted, grade stored + digest-linked, defense transcript + signals stored, Proctor/Assessor endpoints serve them. No corpus fixture anywhere in the flow (AST-asserted). Run `pnpm build` + `pnpm typecheck` + full `pnpm ai:test` at repo root; fix all failures before phase ship.
|
||||
- **Verify:** `pnpm ai:test` green incl. test_e2e_credential_flow; `pnpm build` + `pnpm typecheck` green; README E2E probe section documents the manual browser pass
|
||||
|
||||
### Must-Haves (Phase 6)
|
||||
- [ ] Lab/Assessor/Proctor operate on real inputs with **no mock fallback in the learner path** (AST-verified: no corpus telemetry/artifact/proctor imports in production paths)
|
||||
- [ ] Learner builds in-browser for real (CUT-2): Run/Test buttons execute in a namespace sandbox and stream output to a read-only panel; file tree CRUD works; starter files come from the learner's variant scaffold
|
||||
- [ ] Live telemetry: build activity streams to ai-service and the sidebar shows live status (TelemetryStatus); trace persisted in SQLite
|
||||
- [ ] Live defense: start → answer (mic or typed fallback) → examiner follow-ups → finish → transcript + integrity signals + verdict rendered; browser-native path works with no server voice key
|
||||
- [ ] Live grading: grade request returns structured rubric scores computed from the real trace digest; grade panel renders them
|
||||
- [ ] 503 pool-full surfaced honestly in UI; navigating away destroys the sandbox (no leaked sandboxes — `GET /v1/sandboxes` manual probe)
|
||||
- [ ] Examiner remains protocol-clean (voice only via `VoiceProvider`); module boundary rules hold across all new code
|
||||
- [ ] `pnpm build` and `pnpm typecheck` pass; full `pnpm ai:test` green; no cloud/voice calls in any automated test
|
||||
- [ ] Release-note input (for P7): v0.3 ships real engines; **identity/age-gating (KYC) remains deferred — age-gating is still a visual mockup** (A-110); sandbox scope is coding-IDE only (design tool/simulation deferred to v0.4, D-025)
|
||||
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — workflows correctness, Dockerfile hardening, deb packaging, CLI surface, docs accuracy); security-auditor confirms secrets policy end-to-end (no token in any layer/asset/workflow), image non-root, deb postinst advisory-only.
|
||||
- **Task 6-2-01** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
|
||||
- **Task 6-2-02** (lead-developer → ci-ship): merge milestone → main; tag the milestone release (final v0.7.x phase tag = v0.7.6); Gitea release with full summary + the complete asset matrix (x64/arm64 binaries + checksums + 2 debs) + registry image tags; delete milestone branches; mark REQ-8-001..005 complete; clear checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Final Review + Ship (no planned tasks)
|
||||
## Must-Haves (per phase — concrete, falsifiable, test-linked)
|
||||
|
||||
Orchestrated by the SHIP stage, not this plan: multi-persona code review (correctness, testing, module boundaries, secrets hygiene — keys absent from code/logs/commits/errors, localhost-only CORS, no PII in prompts), project health audit (reconstruction, .ciagent/ discipline, branch/commit hygiene), then merge milestone → main, tag the final v0.2.x patch, create the Gitea release, mark all 8 v0.3 requirements complete.
|
||||
### Phase 1
|
||||
|
||||
**Release-note honesty:** the release note must state (a) Lab/Assessor/Proctor now run on real engine inputs (v0.2 mock-input caveat retired), (b) sandbox scope = coding IDE only — design tool + simulation environments deferred to v0.4 (D-025), (c) identity/age-gating (KYC) deferred per founder directive — age-gating remains the v0.1 visual flow mockup; abuse control (per-learner sandbox caps + server-side learner allowlist, G-5) ships in place of auth (A-110), (d) voice runs mock-first with browser-native fallback — real server STT/TTS deferred to v0.4 (CUT-1), (e) sandbox resource limits are partially enforced (memory/CPU/wall-clock kernel-enforced via rlimits; per-sandbox pids + hard disk quota are NOT — mitigated by a workdir-size sweep and per-learner caps; full enforcement requires cgroup delegation, deferred to the post-MVP containerd backend, D-024/G-1/G-2).
|
||||
- **MH-1a**: Three runners visible + idle at org level (`/org/coreci/settings/actions/runners`) — named nc-runner-node/python/release, hybrid labels declared per the R-3 map; registration token reset requested from founder after green fleet.
|
||||
- **MH-1b**: `ci.yaml` green on a fresh push — node lane (lint+typecheck via per-workspace filters, web:test, cli:test) + python lane (bootstrap/lint/typecheck/FAST test) both observed in the Actions tab.
|
||||
- **MH-1c**: `hello.yaml` dispatch green × 3 labels (node/python/ubuntu-latest) — the fleet proof.
|
||||
- **MH-1d**: `release.yaml` exercised via the G-203 temporary rc tag on the phase branch — workflow observed building binary + checksum and attaching them green; the phase ship release (v0.7.1) carries the same machine-made assets.
|
||||
- **MH-1e**: pre-commit proven both directions — blocks a deliberately broken lint file, passes a clean tree; config is local-only (no network hooks); the no-toolbox skip path is documented in README dev (G-209).
|
||||
- **MH-1f**: Secrets audit clean — grep-verifiable: no token/credential in any committed file; CT root password used once, founder rotation requested as a Wave 1-1 completion condition and confirmed or escalated (G-206); workflows carry no shell-env token echoes.
|
||||
|
||||
**Secrets-hygiene checklist (P7):** `.ciagent/.env.secrets` gitignored and never committed; `AI_VOICE_API_KEY` / `AI_TUTOR_API_KEY` referenced only via env; SQLite DBs + sandbox dirs gitignored; no keys in logs, error messages, or test fixtures.
|
||||
### Phase 2
|
||||
|
||||
**Disposal checks (G-5 class):** v0.2 dormant corpus files carry the dormant-header note (Task 6-1-04); any now-unused mock-data exports for the old sandbox/defense mockups (e.g. `aiTutorResponses`-class leftovers) must be removed or deprecated by review.
|
||||
- **MH-2a**: Image builds (local + release lane) and pushes to `git.coreci.dev/coreci/nextcraft` under the phase tag + `latest`; `/v2/` manifest probe-verified after push.
|
||||
- **MH-2b**: Smoke gate green with the five pinned assertions (G-204) — container start → /health 200 → `GET /` HTML with known marker → degrade notice in logs → 503 with remediation on env-create → clean stop; a broken image fails the release job.
|
||||
- **MH-2c**: Degrade is honest — capability probe is a real userns attempt (not an env-var guess); degraded boot notice logged once with remediation text; env-create rejected 503 with remediation payload; real userns path unchanged (userns suites green locally).
|
||||
- **MH-2d**: In-container CLI semantics — doctor/verify/guide work; bootstrap/deploy/dev refuse with remediation (exit 1); all pinned by tests.
|
||||
- **MH-2e**: Image hygiene — non-root runtime, `.dockerignore` excludes `.env*` + `.ciagent/`, no build toolchain in the final layer (auditor-verified).
|
||||
|
||||
---
|
||||
### Phase 3
|
||||
|
||||
- **MH-3a**: arm64 binary builds per the G-201 ladder — QEMU-free cross-arch SEA if the probe passes; binfmt+qemu full build if not; x64-only deferral (recorded) if binfmt is impossible — the ladder's outcome is recorded either way, `--version` reports the stamped tag under emulation, sha256 sidecar matches.
|
||||
- **MH-3b**: Both debs build + install in fresh debian containers — `nextcraft --version` + advisory doctor run; postinst never fails dpkg (proven with missing-Recommends environment).
|
||||
- **MH-3c**: install.sh arch-resolve — x86_64 → x64 asset, aarch64 → arm64 asset, checksum + version gates byte-identical; fixture-server tests extended to both arches.
|
||||
- **MH-3d**: v0.7.3 release carries the complete asset matrix: 2 binaries + 2 checksums + 2 debs (6 assets), all verified — or the recorded G-201 deferral outcome (x64-only) with the arm64 rows explicitly marked deferred, never silently missing.
|
||||
|
||||
### Phase 4
|
||||
|
||||
- **MH-4a**: Doctor reports the detected channel (container/deb/binary) with the channel-correct next-step line, in fixture tests covering all three.
|
||||
- **MH-4b**: `nextcraft guide` renders offline (no network calls — asserted), topics route (`guide docker|deb|troubleshooting|deploy`), exit-code contract intact.
|
||||
- **MH-4c**: Refusals honest — in-container bootstrap/deploy/dev exit 1 with remediation text (pinned by tests).
|
||||
- **MH-4d**: Both new E2Es (docker, deb) exist env-gated, green when run; the binary-channel E2E still green; guide ↔ runbook parity test green.
|
||||
|
||||
### Phase 5
|
||||
|
||||
- **MH-5a**: Runbook covers all three channels (first-install + upgrade each), the upgrade matrix, HAProxy/systemd recipes, and the four troubleshooting tables — every recipe carries an evidence pointer (test name / workflow job / live probe).
|
||||
- **MH-5b**: Parity test green — `nextcraft guide` content and the runbook agree section-by-section.
|
||||
- **MH-5c**: README quickstart lists all three channels accurately; milestone history updated.
|
||||
|
||||
### Phase 6
|
||||
|
||||
- **MH-6a**: Multi-persona review of the full diff clean (P0s fixed in-phase); secrets policy confirmed end-to-end by security-auditor.
|
||||
- **MH-6b**: ci-audit reconstruction + discipline checks green.
|
||||
- **MH-6c**: Milestone shipped — v0.7.6 tagged, merged to main, release carries the full asset matrix + image tags, REQ-8-001..005 complete, branches deleted, checkpoint cleared.
|
||||
|
||||
## Milestone Must-Haves
|
||||
|
||||
- **MH-M1**: All five requirements complete per their REQUIREMENTS.md rows (REQ-8-001..005).
|
||||
- **MH-M2**: A push to any branch runs lint + typecheck + fast tests on the runner fleet, green (the release-consistency rail the founder asked for).
|
||||
- **MH-M3**: Every phase release from v0.7.1 onward is machine-made the same way: tag push → workflow builds → binary + checksum (+ image from P2, + arm64/deb from P3) attached + registry pushed.
|
||||
- **MH-M4**: Each channel takes a fresh Debian server to a running verified stack: docker run → /health + HTML; dpkg -i → doctor → deploy --service; install.sh → deploy --service (proven by E2E/workflow smoke or live-box evidence).
|
||||
- **MH-M5**: The admin runbook exists as one entry point with per-channel recipes and upgrade paths, every recipe evidence-backed; `nextcraft guide` mirrors it offline.
|
||||
- **MH-M6**: Zero silent failures: honest sandbox degrade (loud notice + 503 remediation), honest in-container refusals, honest channel reporting in doctor — the v0.5+ honesty pattern extended to distribution.
|
||||
|
||||
## Risks
|
||||
|
||||
- **CT 113 single point of failure** (fleet host) — mitigation: compose + config + cron committed as documentation-in-repo (the runbook reproduces the fleet from scratch); founder access path recorded.
|
||||
- **Registration token expiry mid-P1** — mitigation: bring-up is Wave 1-1's first task; the token's job (3 registrations) completes before anything else depends on it; reset requested immediately after.
|
||||
- **Cache-server reachability from job containers** (R-5) — mitigation: cache.host + published ports verified at bring-up; volume-mounted pnpm/pip stores are the deterministic fallback (R-12).
|
||||
- **actions/checkout needs CT egress to github.com** (R-8) — mitigation: probe at 1-1-01; raw git fetch steps as the fallback lane.
|
||||
- **SEA blob cross-arch behavior** (R-26) — mitigation: probe-first in P3; binfmt fallback scoped to blob generation.
|
||||
- **Registry push auth failure** (REGISTRY_TOKEN 401/403 on self-serve) — mitigation: founder UI fallback (escalation commits, P2 marks push step as the only blocked piece; ship still completes).
|
||||
- **docker.io pulls on the release lane** (R-21 note) — bookworm ships docker.io; accepted for v0.8, pre-baked release image documented as the future fix.
|
||||
- **Runner 3.x config drift** (cache.host/bind_workdir/health_check keys) — mitigation: `gitea-runner generate-config` is the authority at bring-up (R-9 risk 9).
|
||||
- **Image size growth over releases** (~400-500MB budget, R-18) — mitigation: budget asserted in the smoke gate; multi-stage keeps toolchains out of the runtime layer.
|
||||
- **Existing v0.4 binary pipeline regression** — mitigation: x64 path byte-identical (checksums + install.sh gates unchanged, pinned by the existing fixture tests).
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
The primary user-facing surface is the **learner build + defend flow** at `http://localhost:3000`, backed by the real engines in ai-service at `http://localhost:8420`:
|
||||
1. **Gitea Actions tab** — every push runs the lanes; every tag builds a release (visible, green, machine-made).
|
||||
2. **`docker run git.coreci.dev/coreci/nextcraft`** — the whole stack, one port, /health, honest degrade notice.
|
||||
3. **Release downloads** — x64 + arm64 binaries, checksums, .deb packages — every release.
|
||||
4. **`nextcraft guide`** — offline man-page for admins: channel recipes, upgrade paths, troubleshooting.
|
||||
5. **`nextcraft doctor`** — channel-aware (container/deb/binary) with the right next-step line.
|
||||
6. **`deploy/README.md`** — the one-entry-point admin runbook.
|
||||
7. **Pre-commit** (opt-in for devs) — local hygiene mirroring CI.
|
||||
|
||||
- `/dashboard` — AI tutor chat (Coach/Tutor, streaming) + Mentor panel (unchanged from v0.2)
|
||||
- `/learn/[competencyId]` — byte viewer with streaming Tutor explanations (unchanged)
|
||||
- `/build/[competencyId]` — **real in-browser build environment**: file tree, syntax editor, **Run/Test buttons that execute in a namespace sandbox and stream output to a read-only panel** (CUT-2 — no interactive shell), live telemetry status, live Lab feedback, per-learner variant task statement
|
||||
- `/defend/[competencyId]` — **live oral defense + live grading**: Examiner voice/typed dialogue, transcript + integrity signals, real rubric scores from the process trace
|
||||
## Happy Path (end-to-end, written BEFORE execute)
|
||||
|
||||
The marketplace, employer, and admin surfaces are unchanged from v0.1/v0.2.
|
||||
|
||||
## Happy Path
|
||||
|
||||
1. Learner opens `/build/comp-01` → a per-learner **variant statement** and starter files load; a namespace sandbox is created for the session
|
||||
2. Learner edits files in the tree and clicks **Run**/**Test** → commands execute in the sandbox and real output renders in the build panel; the telemetry sidebar pulses as events stream to ai-service and persist in SQLite
|
||||
3. The Lab panel streams feedback derived from the **live trace digest** (real commands, real failures)
|
||||
4. Learner opens `/defend/comp-01` → **Start Defense**: the Examiner streams an opening question ("Walk me through your build — why did you structure it this way?")
|
||||
5. Learner answers by voice (mic consent → MediaRecorder → STT) or typed fallback → examiner follow-ups probe the trace ("You hit three test failures before passing — what changed?"); TTS plays examiner audio (or browser speech in fallback)
|
||||
6. Learner finishes the defense → transcript + integrity signals appear; verdict renders in a GradeBadge
|
||||
7. Learner clicks **Grade My Work** → the grading engine computes the digest from the real trace, rubric-scores it, and the panel renders per-criterion bars + strengths/gaps/verdict
|
||||
8. Proctor banner shows integrity signals from the live trace + defense in coaching tone; Mentor panel on `/dashboard` can narrate the real outcome
|
||||
9. Navigating away destroys the sandbox (pool slot freed); killing ai-service shows inline error + retry states on every panel, with no crashes
|
||||
1. **Founder/rails:** tag push → release workflow builds binary + checksum, attaches to the release, pushes the image (from P2), adds arm64 + debs (from P3) — no human touches an artifact.
|
||||
2. **Admin, docker channel:** fresh Debian box → `docker run -d -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:v0.7.x` → /health green, site HTML at :8420, boot notice explains the sandbox degrade + the privileged variant pointer.
|
||||
3. **Admin, deb channel:** `dpkg -i nextcraft_0.7.x_amd64.deb` → advisory doctor → `nextcraft deploy --service` → verified single-port stack.
|
||||
4. **Admin, binary channel:** `curl … install.sh | sh` → `nextcraft doctor` → `nextcraft deploy --service` (the existing path, unchanged).
|
||||
5. **Admin, any channel, stuck:** `nextcraft guide` → offline recipe + troubleshooting pointers → resolved or forge link.
|
||||
6. **Upgrades:** docker = pull + recreate; deb = new deb + deploy --service; binary = install.sh re-run — all idempotent per D-071.
|
||||
7. **Developer:** clone → pre-commit install → push → CI lanes green before review.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. Run/Test output visibly reflects the real sandbox execution (command round-trip to the sandbox, real stdout/stderr), not a replay animation
|
||||
2. The learner path contains **no mock engine data** — scenarios, canned artifacts, and scripted defense transcripts from v0.2 are gone from runtime
|
||||
3. Variant statements visibly differ between two learner sessions on the same competency
|
||||
4. Mic permission flow is graceful: consent prompt, recording indicator, no-mic/typed fallback, and browser-native speech path when no server voice key is configured
|
||||
5. Defense transcript renders turn-by-turn with latency shown; integrity signals render in coaching (supportive) tone
|
||||
6. Grade results render as structured per-criterion bars with verdict, from the real trace — not from final-output-only heuristics
|
||||
7. Pool-full (503) shows an honest "environment busy — retry" state; navigation/unmount destroys sandboxes with no leaks
|
||||
8. When ai-service is unreachable: inline error + retry on every panel — no crashes, no console errors, no blank UI
|
||||
9. All new UI uses design tokens, supports dark mode, meets WCAG AA contrast, responsive at 375px / 768px / 1280px
|
||||
10. `pnpm build` and `pnpm typecheck` pass with zero errors; `pnpm ai:test` green cloud-free and voice-key-free
|
||||
1. No silent failures anywhere in the new surface: image can't sandbox → loud notice + 503 remediation; in-container miscommands refuse with remediation; doctor names the channel and the correct next step.
|
||||
2. Every channel ends at the same place: doctor/verify green on a running stack — the admin doesn't need to know the internals.
|
||||
3. Releases are uniform and machine-made: same asset matrix, same gates (checksum + version), no manual artifact steps.
|
||||
4. The guide works offline and agrees with the runbook (parity-tested — divergence is a test failure, not a doc drift).
|
||||
5. Docs match reality: every recipe carries an evidence pointer; stale docs are a P0 at final review, not a footnote.
|
||||
|
||||
+181
-16
@@ -8,38 +8,200 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Current Milestone: v0.3 — Credential Engines
|
||||
## Hotfix Round (2026-09-21, founder directive)
|
||||
|
||||
**Scope:** Replace v0.2's mock engine inputs with real credential engines. Build the sandbox fabric (sandboxed IDE / design tool / simulation), the live in-environment build-telemetry pipeline, the process-trace grading engine, per-learner variant task generation, and the oral/voice defense with AI examiner. Lab/Assessor/Proctor agents move from mock inputs to real engine inputs; the six tutor agents operate on authentic telemetry and artifacts.
|
||||
**v0.4.6 — deploy-static-404 + dev experience.** Third attempt at the site-down issue (domain root → API JSON 404). Root cause found by host probe (10.0.0.105): the deployed uvicorn ran mount-less — `apps/web/out` was never built on the host, `dev -d` auto-wiring silently skipped `AI_WEB_STATIC_DIR`, node/pnpm were invisible to non-interactive shells, and NOTHING detected the mount-less boot. Founder directive: fix the deploy experience so the one-liner install handles all of it — smooth first run, upgrades, and reboot persistence. Delivered: `nextcraft deploy` (clone/update → install → venv → export build → restart → verify, idempotent), `deploy --service` (generated systemd unit, enabled), toolchain discovery (PATH → nvm → PNPM_HOME → corepack; prefix carries node+pnpm dirs for split installs), loud mount-less boot warning, `verify` live mount probe (GET / must be HTML), install.sh next-steps rewritten. Deployed live on the host via the new command; site 200, service active+enabled.
|
||||
|
||||
**v0.4.7 — pre-public hardening (the 4 P1 flags).** WS ingest HMAC tokens (grading input no longer LAN-poisonable; manager mints into NC_INGEST_URL, endpoint verifies, per-boot secret default), nonce+counter defense-id mint (same-ms collision → permanent 500 closed), browser-mode SR/TTS client implementation (the descriptor's v0.5 contract finally honored — recognizeOnce/speakText pure module + defense-session wiring + honest unsupported state), 8 module pins. All P1 flags from the v0.5 final review are now closed; the recorded pre-public hardening backlog is empty.
|
||||
|
||||
---
|
||||
|
||||
## Milestone v0.8 — Ship Anywhere (ACTIVE, Feature, started 2026-09-27)
|
||||
|
||||
**Scope (founder directive 2026-09-26, options 1–4 confirmed + scope decisions answered 2026-09-26/27):** Release and deployment guides for admins. nextcraft must be installable on any Debian-based Linux server, via every mainstream channel, with release consistency enforced by automation:
|
||||
|
||||
1. **Docker container** (Option 1): packaged image with all dependencies pre-loaded, built and uploaded to the Gitea container registry on every phase release
|
||||
2. **Release binaries** (Option 2, extends the existing v0.4 line): the CLI published as binaries in every release's downloads — x64 (existing) plus arm64, plus .deb packages
|
||||
3. **CLI full suite** (Option 3): the CLI contains the full application suite for easy onboarding — one binary takes an admin zero → running
|
||||
4. **Release consistency** (Option 4): pre-commit hooks + Gitea Actions pipelines so every release is made the same way
|
||||
|
||||
**Founder scope decisions (2026-09-26/27):** deb + arm64 both added (packaging breadth); docker container unprivileged-by-default with honest sandbox degrade (userns conflict documented, privileged variant documented); Gitea act_runner stood up by this milestone on founder-provided LXC CT 113; one milestone, 6 phases (P0 + P1–P5 + P6 final). Three org-level runners on CT 113 as docker containers (gitea/act_runner, sibling-container mode, host docker.sock, hybrid labels ubuntu-latest + node/python/release).
|
||||
|
||||
**Out of scope:** Windows/macOS targets, apt repository hosting (debs ship as release assets), Helm/K8s deployment, Postgres migration, browser E2E tooling.
|
||||
|
||||
**Success (milestone-level):** `docker run` / `.deb install` / one-liner binary each take a fresh Debian server to a running verified stack; every release carries binary + checksum + deb + image tag automatically; a push to any branch runs lint/typecheck/tests on the runner fleet; admins have a single deploy guide covering all channels with upgrade + troubleshooting paths.
|
||||
|
||||
## Clarify decisions (D-063+, CLARIFY stage full autonomy, 2026-09-27)
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|-----------|-----------|------------|
|
||||
| D-063 | CI push lane = lint + typecheck + **fast lanes only**: `ai:test:fast` (`-m "not real_server and not userns"`), default `web:test` + `cli:test` (E2E stays env-gated out). `real_server`/`userns` NEVER run in job containers. | `userns` is blocked by docker's default seccomp in the sibling-container runners (nested `unshare --user` fails — infeasible, not merely slow); `real_server` is feasible (plain uvicorn processes) but the locked spec wording is "pytest fast lane" and push feedback stays minutes-scale; both lanes remain local/verify-lane surfaces. The CLI E2E (900s real bootstrap) is already env-gated out of `cli:test` (D-055) and stays out | 0.85 |
|
||||
| D-064 | Tag-triggered Actions workflow is the **exclusive owner** of release assets + registry push (builds x64/arm64 binaries, checksums, debs from the tag checkout; consumes Actions secrets `GITEA_TOKEN`/`REGISTRY_TOKEN` set via the self-serve PUT mechanism). The local ship path stops calling `scripts/release-assets.sh` once the workflow is green; the script remains as the manual fallback (its .env-only token resolution unchanged). | Exclusive ownership kills the double-attach race (ship workflow + tag trigger would both upload the same asset names); the job must build from a clean tag checkout anyway (dist/ artifacts don't exist in the job), so the script's attach-what-exists logic can't serve the workflow; "release consistency enforced by automation" (founder Option 4) means the pipeline, not the founder's box, is the release maker | 0.75 |
|
||||
| D-065 | Image = `git.coreci.dev/coreci/nextcraft`, tags = the release semver (`v0.7.x`) on every phase ship + `latest` moved to the newest release. No per-push/branch tags. Admin guides pin semver; `latest` is a convenience alias only. | The Gitea registry is owner/repo-scoped (`{instance}/{owner}/{repo}`) — no other image name is addressable, so the naming question has exactly one answer; per-push tags would churn the 32 GB runner disk for zero admin value while the release-consistency scope is phase releases | 0.85 |
|
||||
| D-066 | The runtime image embeds the CLI binary (`/usr/local/bin/nextcraft`): `docker exec` gives admins in-container `doctor`/`verify`/`guide`; `bootstrap`/`deploy`/`dev` detect the container env and refuse honestly, pointing at the docker-channel paths. | REQ-8-004's "detects container env + suggests volume/privileged facts" is only truthful from inside the container (uid_map/seccomp/userns reality lives there — a host-side CLI cannot probe it without knowing the container name); ~117 MB on an image already carrying a venv + static export is acceptable; refusing beats repo-surgery-in-container (git clone + rebuild inside an immutable image is the anti-pattern the docker channel exists to remove) | 0.80 |
|
||||
| D-067 | deb = CLI binary at `/usr/bin/nextcraft` + package docs, nothing else. NO systemd unit in the deb — `deploy --service` owns service-unit generation (it bakes the real clone + binary paths; a deb unit would be stale/wrong). No proxy config fragments (HAProxy recipes are per-box, live in the deploy guide). postinst runs `nextcraft doctor` best-effort as an **advisory** summary (output printed, exit never fails the dpkg transaction) + prints next-steps (`doctor` → `deploy --service`). | The deb packages the CLI, not the stack — the unit's paths only exist after deploy chooses a clone dir; postinst failing on missing node/pnpm would brick unattended installs while advisory doctor output still tells the admin what to fix (honest-loud, never blocking — the install.sh degradation pattern) | 0.75 |
|
||||
| D-068 | Degrade detection = **capability probe**, not markers: at boot + per environment-create the backend attempts a real `unshare --user --map-root-user` (the proven `tests/helpers/userns.py` pattern promoted into the sandbox module); `/.dockerenv` + an `NC_CONTAINER=1` entrypoint var only flavor the remediation text. Degrade semantics: learner environment creation **rejects honestly** (503 + pointer to the documented privileged/seccomp variant) + loud boot notice + doctor surfaces the degraded capability; chat/agents/grading keep working. This supersedes A-101's cwd-jail fallback for deployed schools. | The probe is the truth source — `/.dockerenv` says "docker" but not "userns blocked" (a privileged container passes userns and must NOT degrade; a non-docker host with userns disabled must); unisolated learner code would run as the service user next to provider keys + network — the namespace fabric exists precisely to prevent that, so the A-101 fallback (founder-box convenience) is unacceptable for untrusted multi-learner deployments; reject-with-remediation is the honest degrade | 0.70 |
|
||||
| D-069 | arm64 = **cross-build on the x64 runner, no QEMU for the build**: the SEA pipeline is arch-agnostic file surgery (esbuild bundle + postject into an arm64 node base binary fetched as a build input); x64 + arm64 binaries and debs build in one release job in minutes. QEMU binfmt (if registered on CT 113) adds an optional `--version` smoke of the arm64 binary; `dpkg-deb` never executes its payload, so the deb needs no emulation. No multi-arch docker image in v0.8 (spec scope is binaries + debs; image stays amd64). RESEARCH verifies the arm64 node base + binfmt availability. | Emulated builds are the slow path and unnecessary: SEA injection never runs the target binary; the only execution need is a smoke, which binfmt provides opportunistically; multi-arch manifests would pull image-arm64 into a scope the spec never asked for | 0.70 |
|
||||
| D-070 | pre-commit = ONE root `.pre-commit-config.yaml` for the whole monorepo, directory-scoped (ruff on Python paths, eslint/tsc via workspace-scoped invocations), local-only, no network at hook run. On boxes without the toolchain, hooks **fail loudly** — never silently skip; `--no-verify` is the visible deliberate bypass and CI is the authoritative backstop. | A silently-skipped hook lies about release consistency (Option 4's whole point); pre-commit is inherently a repo-root single config — per-workspace configs buy nothing; CI remains the enforcement gate so a bypassed local hook cannot ship unlinted code | 0.80 |
|
||||
| D-071 | Docker-channel upgrade = `docker pull <tag>` + **recreate** the container (state persists via the `~/.nextcraft` volume) — `deploy`/`bootstrap` never run in-container (D-066 refuse). Binary channel = re-run install.sh + `deploy --service`; deb channel = `dpkg -i` the new deb + `deploy --service` (both idempotent). doctor/guide describe the detected channel's upgrade path. | The image is immutable — deploy-from-repo inside a container is meaningless by construction; the other two channels keep the v0.4.6 idempotent deploy as their upgrade rail (proven on the live box) | 0.85 |
|
||||
| D-072 | `nextcraft guide` = **offline-first embedded text** (man-page style): channel-aware sections checked into the CLI source (mirrored from deploy/README.md at build time), zero runtime fetches; forge URLs print as pointers to the living runbook. | Fresh-box and degraded installs (no network, no clone) are exactly when guidance is needed; the binary is self-contained by design (SEA) — embedding honors that; runtime fetching would make the guide fail precisely on broken boxes | 0.80 |
|
||||
|
||||
## v0.8 research conclusions (Phase 0 RESEARCH, 2026-09-27)
|
||||
|
||||
Full evidence + concrete commands live in `.ciagent/RESEARCH.md` (R-1..R-32); architecture in ARCHITECTURE.md's "v0.8 Distribution & Release Architecture" section. Brief conclusions:
|
||||
|
||||
1. **Runner image = `gitea/runner:3`** — Docker Hub's `gitea/act_runner` is deprecated/renamed; runner 3.x needs Gitea ≥1.21 (instance probe-verified at 1.26.1). Basic flavour + host docker.sock = exactly sibling mode.
|
||||
2. **3 containers, one per lane, each with its OWN `/data` volume** (`.runner` registration file — shared = lock refusal + identity loss). Registration token via env at bring-up only (founder chat-provided, reset after fleet green, never in any repo file). Org-level scope comes from the token itself.
|
||||
3. **Labels via `GITEA_RUNNER_LABELS` env** (passed to both register + daemon): `node:docker://node:24-bookworm`, `python:docker://python:3.11-bookworm`, `release:docker://node:24-bookworm`, `ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest`. Unmatched `runs-on` silently runs on a default image — always pin our labels.
|
||||
4. **Capacity 1 per runner** (config default; also bounds docker subnet consumption). Fleet concurrency = 3.
|
||||
5. **Cache requires `cache.host` + published port** on dockerized runners (per-job networks make the auto-detected cache address unreachable — actions/cache ETIMEDOUTs otherwise); 32 GB guarded by cache caps + health_check disk floor + host prune cron. `actions/cache@v4` works on runner 3.x (cache v2 API, stock action self-patched).
|
||||
6. **Sibling-mode path alignment NOT needed for our jobs** (no bind_workdir; `docker build` TARS context over the API); documented as the gotcha if a future workflow bind-mounts.
|
||||
7. **Push lane = repo's own scripts, no turbo lint wholesale** (it pulls venv-dependent ai-service tasks): node lane (corepack pnpm 12.3.4, eslint+tsc per TS workspace, web/cli node:test) + python lane (bootstrap.sh venv, ruff, mypy, FAST=1 test.sh). D-063's fast-only rule is both policy and physical (docker default seccomp blocks nested unshare). ubuntu-latest lane = dispatch verification only.
|
||||
8. **Release lane = tag-triggered exclusive owner**: builds from the tag checkout, stamps with `gitea.ref_name`, attaches assets via the same REST API scripts/release-assets.sh uses (NOT actions/upload-artifact); reuses the API in-workflow, script untouched as manual fallback (.env-only token resolution preserved).
|
||||
9. **Automatic job GITEA_TOKEN cannot publish packages (upstream-confirmed)** — registry push needs the `REGISTRY_TOKEN` PAT secret (self-serve PUT first, founder UI fallback). `docker/build-push-action` unnecessary: plain login+build+push for a single-arch amd64 image.
|
||||
10. **Dockerfile**: web-export stage (NEXT_PUBLIC_AI_SERVICE_URL=self, build-time-only) + venv stage (prod deps) + `python:3.11-slim-bookworm` runtime (~400-500 MB incl. the ~117 MB CLI embed, D-066): non-root, urllib `/health` healthcheck (no curl in slim), `VOLUME /home/nextcraft/.nextcraft` (state root is `~/.nextcraft` by config default, env-overridable with ~-expansion), zero AI_* env at build, all keys optional at runtime (G-11).
|
||||
11. **deb**: `dpkg-deb --root-owner-group -Zxz` staging build (never executes payload — cross-arch free), Version = tag minus `v`, empty Depends + `Recommends: nodejs, python3, git, util-linux` (soft deps), postinst advisory doctor (exit 0 always), no unit, no proxy fragments (D-067).
|
||||
12. **arm64 cross-build QEMU-free**: the only arch-specific step in build-binary.mjs is `copyFileSync(process.execPath, out)` — replaced by a copy from the official arm64 node tarball (probe: nodejs.org dist 200, 30 MB); esbuild bundle + postject ELF-note injection are arch-agnostic file surgery. SEA blob cross-arch needs a P3 probe (config has no useCodeCache/useSnapshot — good sign); binfmt `--version` smoke optional per D-069.
|
||||
13. **D-068 degrade**: probe promoted verbatim from tests/helpers/userns.py into `ai_service/sandbox/capability.py`; boot notice copies the main.py G-11 warning pattern; per-create rejects 503 via a new SandboxUnavailableError arm beside the existing PoolFullError→503 (today it 500s); doctor upgrades `which unshare` to a real userns attempt. Markers only flavor text.
|
||||
14. **install.sh arch-resolve**: `x86_64`→x64, `aarch64|arm64`→arm64; sha256+version integrity gates stay byte-identical per arch.
|
||||
15. **Admin-guide structure** (REQ-8-005): per-channel chapters (docker/deb/binary) each ending in the SAME verification checklist, an upgrade matrix table (D-071 semantics), symptom/cause/fix troubleshooting tables, where-state-lives table per channel, sibling-mode accepted-risk row, SEA/corepack/cache-egress probe outcomes recorded. Ecosystem scan: Ollama installer UX, Tailscale per-channel doc skeleton, Gitea/act_runner's own dual distribution, Miniflux binary+image+deb shape.
|
||||
|
||||
## Milestone v0.7 — Test Harness Review (COMPLETE, shipped as v0.6.6, 2026-09-26)
|
||||
|
||||
**Scope (founder directive post-v0.6, scope decisions answered 2026-09-26):** Once v0.6 shipped, review the entire test harness — make sure the right tests are being run, make them effective, cut tests that provide no real value, review all warnings, and address all of it within this single milestone. NFR refinement-only: zero `feat:` commits (D-001 purity gate at final phase).
|
||||
|
||||
**Baseline research (completed at phase 0, 2026-09-26):** 785 tests (671 pytest / 53 web node:test / 61 CLI node:test), all green. Coverage unmeasured anywhere. No framework-level timeouts (20-30s polling loops, 600s CLI bootstrap step). Zero pytest markers despite a real split (fast-unit vs real-uvicorn vs real-userns). Web tests outside the turbo pipeline. 1,716 warnings per pytest run (~99% from 14 `session.query()` SQLModel deprecation sites; 14 ResourceWarning sites from unclosed UploadFile/sockets/file). All 81 Python `# noqa` inert (rules not in the ruff select-set); 5 orphaned eslint-disable comments; **no ESLint exists at all** (turbo lint is a no-op for 5 of 6 packages). `recharts@2.15.4` deprecated direct dep. No Python type checker (27 inert `# type: ignore`). Stale PLAN.md counts (488/22/61 vs actual 671/53/61).
|
||||
|
||||
**Founder scope decisions:** D-048 ESLint included; D-049 recharts 2→3 included; D-050 source-pin triage (cut trivial, keep real regression pins); D-051 mypy included.
|
||||
|
||||
**Out of scope:** Gitea Actions CI YAML (the CIAgent verify hooks are the CI), Playwright/browser E2E tooling, new features of any kind.
|
||||
|
||||
**Success (milestone-level):** pytest runs green with zero first-party warnings; the fast-subset (`-m fast`) completes in seconds; coverage measured and reported in all three workspaces; `turbo lint` exercises all 6 packages; all suppressions (noqa/eslint-disable/ts-ignore/type:ignore) are enforced-and-meaningful or gone; the suite is smaller (trivial tests cut) with no loss of regression value.
|
||||
|
||||
**Clarify decisions (D-052+, CLARIFY stage full autonomy, 2026-09-26):**
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|----------|-----------|------------|
|
||||
| D-052 | Coverage tooling: **pytest-cov for ai-service; c8 for web/cli** wrapping the existing `tsx --test` runner — no vitest/nyc/istanbul, no runner swap | c8 handles tsx source maps and adds one devDep per workspace; node's `--experimental-test-coverage` mis-reports through the tsx loader; vitest adoption is a framework swap, not NFR refinement | 0.85 |
|
||||
| D-053 | Marker taxonomy: registered `fast`/`real_server`/`userns` under `--strict-markers`; **unmarked tests default to fast**; canonical fast run = `-m "not real_server and not userns"` (aliased `ai:test:fast`); `real_server`/`userns` always exclude from the fast subset | Zero-churn — no need to stamp ~600 unit tests with `fast`; the exclusion markers carry the selection; strict mode catches typo'd markers that would silently deselect | 0.85 |
|
||||
| D-054 | pytest-timeout: **`signal` method**, global 60s (comfortably above the 20-30s polling loops), conftest collection hook bumps to 180s for `real_server`/`userns`-marked tests; `@pytest.mark.timeout` for known outliers | Linux-only target makes SIGALRM safe and accurate; the thread method can't cancel blocked calls; 60s bounds a hang without false-positiving real polling | 0.80 |
|
||||
| D-055 | CLI fresh-clone E2E: **keep the 600s real bootstrap as-is** (the real path is the value), give it an explicit per-test 900s budget (600 + slack), env-gate it out of the default `cli:test` run with a dedicated `cli:test:e2e` script for the verify/ship path | A 10-minute E2E on every run is wrong for feedback loops but too valuable to cut; opt-in keeps both properties | 0.85 |
|
||||
| D-056 | filterwarnings: **`error` as the base action** (new first-party warnings fail) + a narrow enumerated allowlist of upstream ignores each scoped by exact message + emitting module (fastapi/starlette TestClient httpx warning, anyio BlockingPortal); no blanket category ignores, no ignores on `ai_service.*`; first-party ResourceWarnings fixed at source, never ignored | Error-by-default makes warning hygiene self-enforcing; module-scoped ignores cannot mask first-party regressions; after REQ-7-003 cleanups the non-ignored set is empty so no dedup machinery needed | 0.85 |
|
||||
| D-057 | Source-pin triage rule (operationalizes D-050): **KEEP a pin iff** it encodes a documented regression lesson (E87-style) or verifies cross-boundary wiring invisible to typecheck/build/behavioral tests (runtime fetch wiring, env keys, gate composition, JSON-LD presence); **CUT a pin** whose assertion duplicates another test or merely asserts code says what it says; kept pins move to a single `pins.test.ts` naming their regression | Falsifiable per-assertion; expected outcome ~5-8 kept of the 21 pin sites; cuts tautologies with zero regression loss | 0.75 |
|
||||
| D-058 | mypy strictness: **relaxed-first baseline** — `check_untyped_defs`, `warn_unused_ignores` (each of the 27 ignores either types or is removed), `no_implicit_optional`, `warn_redundant_casts`, `warn_unreachable`; NOT `strict=true`; per-module tightening via `[[tool.mypy.overrides]]` available later | Full strict on a never-myped codebase turns P5 into an annotation retrofit; `warn_unused_ignores` delivers the milestone goal (suppressions meaningful or gone) at realistic cost | 0.85 |
|
||||
| D-059 | recharts 2→3 chart-surface verification = **`tsc --noEmit` green + `next build` static export green (SSR-renders the chart pages) + existing web tests green**; no jsdom/happy-dom render harness (new deps = framework addition); live mount probe on the deployed site as the operational smoke | Build+typecheck exercise the real React 19/recharts 3 integration through SSR; browser/render tooling is explicitly out of scope for v0.7 | 0.80 |
|
||||
| D-060 | Shared Python test helpers: **`tests/helpers/` package** (`KillableProxy`, `free_port`, userns probe, real-uvicorn `serve`, `wirePosting`) imported as `from tests.helpers...`; fixtures stay in conftest.py; changes are test-internal import paths only — no public/test-facing API beyond that | Helpers are direct utilities, conftest is for fixtures — explicit imports keep call-site dependency honest; `tests/` is already a package (`__init__.py` present) | 0.90 |
|
||||
| D-061 | Version drift: **pyproject.toml is the single version source** — bumped to `0.5.6` (the shipped line); `FastAPI(version=)` derives from `importlib.metadata.version("nextcraft-ai-service")` (safe fallback for un-installed runs); stale version comments removed in the same sweep | One hardcoded "0.3.0", one pyproject "0.2.0", and a shipped 0.5.x all drifted — metadata lookup makes drift structurally impossible | 0.90 |
|
||||
|
||||
## Milestone v0.6 — Marketplace Engine (COMPLETE, shipped as v0.5.6, 2026-09-26)
|
||||
|
||||
**Scope (auto-decided at SPECIFY per full autonomy — founder scope question left unanswered 2026-09-21):**
|
||||
|
||||
The marketplace has no backend today: every job board, job detail, employer profile, search, and pricing surface reads `packages/mock-data` directly; the only live route is the G-18 `POST /v1/marketplace/apply` gated stub (18+ verified, honest 501). The guiding outcome — ≥1,000 learners placed in AI-orchestration roles via the Nextcraft marketplace — runs *through* this surface, so the marketplace engine is the highest-leverage next build.
|
||||
|
||||
**Scope decision (D-047):** Activate **REQ-F-011..016** as REQ-6-001..006 — the real marketplace backend behind the existing UI:
|
||||
|
||||
1. **Job aggregation pipeline** (REQ-6-001, from REQ-F-011): job ingestion from employer postings + a seeded/synthetic corpus (3M+ external jobs is post-MVP — the pipeline ingests at pilot scale with the same adapter contract); postings store (6th D-027 store family); posting lifecycle (draft → published → closed).
|
||||
2. **AI-powered tagging** (REQ-6-002, from REQ-F-012): skills/seniority/category extraction from posting text via the existing LLM structured-output layer (D-020), provider-agnostic, mock-first.
|
||||
3. **Semantic vector search** (REQ-6-003, from REQ-F-013): embedding-backed role matching — candidate-competency vs posting-requirement similarity; SQLite-local at pilot scale (no pgvector before Postgres migration), deterministic embedding provider protocol (mock-first, real provider env-configured).
|
||||
4. **Company enrichment** (REQ-6-004, from REQ-F-014): employer profile enrichment from posting/company data (descriptions, social links, logo refs) — LLM-assisted, human-in-the-loop founder review deferred to post-pilot.
|
||||
5. **Resume/profile parsing** (REQ-6-005, from REQ-F-015): learner profile auto-fill from an uploaded resume — LLM structured extraction mapping to the competency profile shape.
|
||||
6. **SEO-optimized programmatic pages** (REQ-6-006, from REQ-F-016): server-rendered job/stack/landing pages with metadata, sitemap, structured data — built on the existing Next.js static-export single-port deploy (D-039-compatible).
|
||||
|
||||
**Deferred beyond v0.6 (unchanged):** REQ-F-018 payments/subscriptions, REQ-F-019 human tutor marketplace, REQ-F-020 CIRR placement tracking.
|
||||
|
||||
**Success (milestone-level):** the marketplace surfaces read from a real backend (postings, search, matching, employer profiles) instead of mock data; job posting + tagging + search work end-to-end for a pilot employer; the deploy stays one-command on the v0.4.x/v0.5.x fabric.
|
||||
|
||||
## Prior Milestone: v0.5 — Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (COMPLETE, shipped as v0.4.5; hotfixes v0.4.6 deploy-static-404, v0.4.7 pre-public hardening)
|
||||
|
||||
**Scope (the four seams D-016 deferred out of v0.4, locked at v0.5 Phase 0 SPECIFY):**
|
||||
|
||||
1. **Real server STT/TTS** (CUT-1/G-7 seam): implement the `openai-audio` VoiceProvider against OpenAI-compatible `/audio/transcriptions` (STT) + `/audio/speech` (TTS) — the D-030 protocol drop-in; mock stays first-class for tests; browser fallback stays for the no-key path. Voice defense (Examiner flows) gains the real server path end-to-end.
|
||||
2. **KYC/identity + age-gating backend** (REQ-F-017): real identity verification behind a provider protocol (mock-first, D-014 pattern), age-gate enforcement (school 16+, marketplace 18+ with verified identity) replacing the v0.1 visual-only flow, session/learner identity wired into the API surface (the G-5 allowlist evolves toward real identity).
|
||||
3. **Design/simulation sandbox environments** (REQ-F-021 remainder): extend the D-024/D-025 namespace fabric beyond the coding IDE — design-tool and simulation environment types alongside the existing build environment, one lifecycle, per-type telemetry.
|
||||
4. **Exec-telemetry seq-lease / replay-margin fix**: the one-line ACK gap documented in the P6 lesson (P07 review): reconnect replay margin so at-least-once ingest acknowledges received seqs and the capture agent resumes from the ack — closing the documented gap.
|
||||
|
||||
**Success (milestone-level):** voice defense runs on real server STT/TTS when keys exist; age-gating is enforced by the backend (not a mockup page); the sandbox fabric provisions design/sim environments; the reconnect-replay gap is closed with a regression test.
|
||||
|
||||
## Prior Milestone: v0.4 — Distribution & Bootstrap CLI (COMPLETE, shipped as v0.3.4; hotfixes v0.3.5 fresh-box experience, v0.3.6 single-port unattended deploy)
|
||||
|
||||
**Scope (founder directive, 2026-09-12):** Streamline installing Nextcraft. Ship a bootstrap CLI with a single-liner install script, and publish release binaries on an ongoing basis for every release going forward.
|
||||
|
||||
**Delivered:** `nextcraft` CLI — `doctor` (prerequisite checks), `bootstrap` (deps + venv + env from templates + key validation), `verify` (health check), `dev` (thin passthrough to scripts/dev.sh); one-liner install script downloading the linux x64 binary from the latest Gitea release with sha256 + version integrity gates; binary release pipeline attached to every ship from v0.3.2 onward; install/quickstart documentation backed by a fresh-clone E2E test. All 5 requirements (REQ-4-001..005) complete.
|
||||
|
||||
**Status of v0.3:** Complete and shipped (v0.2.8). Credential engines live: namespace-isolated sandbox fabric, live build telemetry, process-trace grading, seeded variants, oral defense; real learner build/defense/grading surfaces.
|
||||
|
||||
**Status of v0.2:** Complete and shipped (v0.2.0). Six AI tutor agents live over mockengine inputs (D-015).
|
||||
|
||||
**Deferred from earlier plan:** REQ-F-017 (identity verification + 16+/18+ age-gating) is explicitly deferred to a later milestone per founder directive. Age-gating remains the v0.1-style visual flow mockup; no real KYC backend is built in v0.3.
|
||||
**Deferred from earlier plan:** REQ-F-017 (identity verification + age-gating KYC), real server STT/TTS, design/simulation sandbox environments, and the exec-telemetry seq-lease are all deferred to v0.5. Age-gating remains the v0.1-style visual flow mockup.
|
||||
|
||||
**Tech stack:** v0.1 TS monorepo (pnpm/turborepo, Next.js) + v0.2 Python FastAPI ai-service + new credential-engine services (sandbox fabric orchestrator, telemetry ingest, grading engine) in Python/TypeScript as determined at RESEARCH.
|
||||
|
||||
---
|
||||
|
||||
## Requirements (Validated)
|
||||
## v0.4 Requirements (Complete)
|
||||
|
||||
The following requirements have been validated during specification and are locked for milestone v0.3 (REQ-F-007..010 and REQ-F-021 activated from the deferred pool; REQ-F-017 deferred per founder directive):
|
||||
All 5 v0.4 requirements (REQ-4-001..005) are complete and shipped as v0.3.4:
|
||||
1. Bootstrap CLI — `nextcraft` executable with `doctor` / `bootstrap` / `verify` / `dev` (REQ-4-001, REQ-4-002)
|
||||
2. One-liner install — `curl | sh` fetching the linux x64 binary from the latest Gitea release with sha256 + version integrity verification (REQ-4-003)
|
||||
3. Ongoing release binaries — every release from v0.3.2 onward ships the CLI binary + checksum as release assets (REQ-4-004)
|
||||
4. Install documentation — README quickstart + CLI reference verified by a fresh-clone E2E test (REQ-4-005)
|
||||
|
||||
1. Sandbox fabric — sandboxed IDE, design tool, and simulation environments with isolated execution and lifecycle management (REQ-F-021)
|
||||
2. Live build telemetry — in-environment capture of process events (keystrokes, commands, file diffs, run/test results) streamed to ai-service (REQ-F-010)
|
||||
3. Process-trace grading engine — grade artifacts from their process traces, not just final output (REQ-F-007); feeds the Assessor agent real inputs
|
||||
4. Variant task generation — per-learner task variants so no two learners receive identical prompts (REQ-F-008)
|
||||
5. Oral/voice defense — AI examiner conducts spoken defense of submitted work (REQ-F-009); feeds the Proctor/Mentor agents
|
||||
6. Agent re-grounding — Lab/Assessor/Proctor consume real engine inputs (telemetry, traces, defenses) instead of v0.2 mocks
|
||||
7. Learner surface integration — wire the v0.1 sandbox + assessment mockups to the real engines (build/run in-browser, live telemetry, live defense)
|
||||
## v0.3 Requirements (Complete)
|
||||
|
||||
## v0.2 Requirements (Complete)
|
||||
|
||||
All 12 v0.2 requirements (REQ-2-001..012) are complete and shipped as v0.2.0. See REQUIREMENTS.md traceability matrix.
|
||||
All 8 v0.3 requirements (REQ-3-001..008) are complete and shipped as v0.2.8. See REQUIREMENTS.md traceability matrix.
|
||||
|
||||
## v0.1 Requirements (Complete)
|
||||
|
||||
All 28 v0.1 requirements (REQ-001..028) are complete and shipped as v0.1.0. See REQUIREMENTS.md traceability matrix.
|
||||
|
||||
## Clarified Assumptions (v0.6 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
|---|-----------|------------|-------------|
|
||||
| A-401 | Who authors postings at pilot (no employer auth exists)? | **Founder-authored; employer self-serve deferred.** Postings enter via founder/admin API routes (`/v1/marketplace/admin/postings` create + lifecycle transitions draft→published→closed), guarded by an `AI_ADMIN_TOKEN` env-header check (keys env-only, D-014; operator route — D-043 learner gate composition untouched). The employer dashboard's posting-management mockup stays mock; real employer self-serve lands with the deferred B2B employer network milestone. | 0.85 |
|
||||
| A-402 | Ingestion adapter contract shape? | **`PostingSource` protocol in `ai_service/marketplace/`** (D-031 module, D-014 mock-first): `fetch() -> Iterable[IngestPosting]` — pydantic-typed with `source`, `external_id`, employer + posting fields. Two v0.6 implementations: `seeded_corpus` (in-repo JSON seed, ported once from packages/mock-data jobs.ts/employers.ts per D-021 convention) and `admin_api` (A-401 founder path). Upserts idempotent on `(source, external_id)`. No scheduler/crawler — the 3M+ external corpus drops in later as a new adapter with zero store/route changes. | 0.80 |
|
||||
| A-403 | Where do embeddings live at SQLite scale (pip-only venv, no pgvector)? | **`embedding` table inside the marketplace SQLite store (6th D-027 family)**: posting_id → BLOB of struct-packed float32 + model + dim columns; the candidate side embeds the learner's competency profile text on demand (not stored). **Brute-force cosine in pure Python** at query time (pilot scale ≤ ~10⁴ postings — no numpy, no FAISS/chromadb, no new deps). `VectorStore` protocol-wrapped so the Postgres migration swaps to pgvector with no API changes (D-027/D-019 pattern). | 0.85 |
|
||||
| A-404 | Embedding provider + dimensionality? | **`EmbeddingProvider` protocol (mirror of D-030 VoiceProvider)**: deterministic mock (256-dim feature-hash of posting/profile tokens — similarity is deterministic; tests never call the cloud) + real OpenAI-compatible `POST {AI_EMBED_BASE_URL}/embeddings` via `AI_EMBED_MODEL`/`AI_EMBED_API_KEY` (env-only; ollama-cloud exposes /embeddings). Dimension is **not hardcoded** — persisted per row with the model name; on provider/model change, postings re-embed lazily. **[SUPERSEDED by D-051 probe: ollama-cloud does NOT expose /embeddings (404 path-not-found) — `AI_EMBED_BASE_URL` must target another OpenAI-compatible endpoint (e.g. local ollama `:11434/v1`); protocol design unaffected, default stays mock]** | 0.75 |
|
||||
| A-405 | Does semantic search replace the filter sidebar? | **Hybrid — augments, not replaces.** REQ-6-002 tag facets (skills/seniority/category + location/remote/salary) remain hard filters driving the existing sidebar; the semantic query + similarity compose as a **ranking layer over the facet-filtered set** (no facets selected → pure semantic ranking). Match score on job cards = cosine(candidate-competency vector, posting vector), replacing the mock `matchScore`; skill-breakdown panel derives from tag overlap + per-skill vector contribution. ARCHITECTURE.md's old "filter UI replaced" future-note is superseded by this hybrid. | 0.80 |
|
||||
| A-406 | What happens to packages/mock-data jobs.ts/employers.ts (runtime data vs static export)? | **Marketplace web surfaces switch from build-time mock imports to runtime client fetch** (A-002 direct-fetch + engine-client pattern, proven by build/defend surfaces): board/detail/employer-profile become client shells fetching `/v1/marketplace/*`; `generateStaticParams` on jobs/[jobId] and employers/[employerId] is removed (one static shell per route, data at runtime). jobs.ts/employers.ts are ported **once** into the Python seed fixture (A-402) and go dormant for the marketplace path (mock-data remains for admin/employer-dashboard mockups + shared types). No build-time DB reads — the D-039 deploy export build stays repo-only. | 0.80 |
|
||||
| A-407 | Employer auth model at pilot? | **None — employers are data, not principals, in v0.6.** Employer records are ingested/enriched entities keyed by `employer_id` on postings; no employer login, sessions, or accounts. The employer dashboard surface stays the v0.1 mockup; enrichment is founder-triggered/admin-reviewed (A-409), never employer-edited. Real employer auth lands post-payments with the B2B employer network. | 0.85 |
|
||||
| A-408 | Apply-flow shape beyond the 501 stub? | **The existing `POST /v1/marketplace/apply` route becomes real, D-043 composition unchanged** (G-5 allowlist → verified-18+ verdict → rate caps): body `{posting_id}` → posting must be `published` → insert an `application` row (status `submitted`, identity ref, timestamp) in the marketplace store → honest confirmation payload. Duplicate apply (same learner + posting) is idempotent. **No notification infra**: founder reviews applications via `/v1/marketplace/admin/applications` and contacts employers out-of-band. | 0.75 |
|
||||
| A-409 | Enrichment provenance rules? | **Separate `employer_enrichment` table — raw fields never overwritten** (REQ-6-004): append-only rows `(employer_id, field, value, source: llm\|manual, model, created_at)`. Read precedence **manual > llm > ingested-raw**; API responses merge and carry per-field provenance markers (A-304 honesty pattern — LLM-authored copy visibly marked). Enrich-on-post: new employer's first published posting triggers LLM structured extraction (D-020, mock-first) of description/social links/logo refs. Founder override = upsert a `manual`-source row, never a mutation of `llm` rows. | 0.85 |
|
||||
| A-410 | Resume upload format bounds? | **PDF/TXT/MD only, ≤2 MB, text-layer required.** PDF text via `pypdf` (pure-Python, pip-reachable through the A-009 venv-pip channel — the only new dep); extracted text capped ~100K chars before the D-020 structured-extraction call mapping to the competency-profile shape. Image-only/scanned PDFs → honest 422 ("text layer required") — **no OCR in v0.6**. Parsed result returns as a DRAFT: nothing persists to the learner profile until explicit learner confirm (REQ-6-005 review gate); raw resume bytes never stored, never logged (A-305 pattern). | 0.80 |
|
||||
| A-411 | Which pages get programmatic SEO treatment under static export — and how? | **Build-time-static set: landing, catalog/stack detail, marketplace board, pricing** — full server-component metadata (title/desc/OG) + JSON-LD (Organization, stack/Course, ItemList) rendered at export (D-039 build unchanged). **Runtime-data set: job detail + employer profile** — client shells (A-406) inject `JobPosting`/`Organization` JSON-LD + title client-side after fetch (best-effort per-job SEO at pilot; recorded as an accepted limitation — JS-less crawlers see the shell). **Sitemap/robots: `/sitemap.xml` + `/robots.txt` served dynamically by ai-service** (API routes beat the static mount, D-039), enumerating static pages + published postings; robots points at `/sitemap.xml`. | 0.70 |
|
||||
| A-412 | Pricing page — real or mock? | **Stays mock — zero backend.** Payments/subscriptions are REQ-F-018, explicitly deferred beyond v0.6 (D-047). The pricing page gains only build-time metadata/OG (A-411 static set); no pricing API routes, no package enforcement — posting is free at pilot (founder-mediated). | 0.95 |
|
||||
|
||||
## Clarified Assumptions (v0.5 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
|---|-----------|------------|------------|
|
||||
| A-301 | Which STT/TTS endpoint for `openai-audio`? | **OpenAI-compatible `/audio/transcriptions` + `/audio/speech`** via `AI_VOICE_BASE_URL` (ollama-cloud does not expose audio endpoints; any OpenAI-compatible audio API works — the provider is endpoint-agnostic by config, D-014 pattern). Model via `AI_VOICE_MODEL` (default `whisper-1` STT / `tts-1` TTS class names, configurable). Keys env-only, never committed. | 0.75 |
|
||||
| A-302 | Does real voice replace browser fallback? | **No — layered.** `openai-audio` when configured, browser-native second-class fallback (CUT-1 keeps it first-class for the no-key path), deterministic mock for tests. The defense surface probes provider capability and degrades with a visible badge (mic mock vs browser vs server). | 0.85 |
|
||||
| A-303 | KYC vendor in v0.5? | **No vendor — protocol + mock-first backend.** `IdentityProvider` protocol (submit/poll/verify), deterministic mock (approve-on-policy), SQLite store. A real vendor (Stripe Identity/Persona/Onfido class) drops in later without API changes. Solo-founder economics: no vendor spend before pilot. | 0.85 |
|
||||
| A-304 | What counts as "verified 18+" for marketplace? | **Provider verdict = DOB-verified 18+; until a real vendor exists the mock verdict is explicit and marked `mock` in API responses** so downstream surfaces can label unverified state honestly (never display mock-verified as production-verified). | 0.80 |
|
||||
| A-305 | PII storage? | **Server-side only, minimal**: document refs + provider verdicts in SQLite; raw documents NEVER stored, NEVER logged (payload scrubbing pinned by test). Enrollment DOB stored as derived age-band, not raw DOB where possible. | 0.85 |
|
||||
| A-306 | Age-gate enforcement points? | **School enrollment (16+)**: identity submit → verify → age check at enrollment API. **Marketplace (18+ verified)**: gated marketplace routes check verified-identity verdict; unverified → 403 with verify-CTA payload. G-5 learner allowlist REMAINS as a pilot guard (identity doesn't replace sandbox rate caps). | 0.82 |
|
||||
| A-307 | What are "design" and "simulation" environments concretely? | **Same namespace fabric, typed starter contents + allowed commands.** `design`: canvas-style artifacts (files the learner edits: SVG/HTML/schematic text), Run = validator/renderer harness command; `simulation`: parameterized run harness (benchmark scripts + dataset files), Run = bounded harness execution. No new isolation tech — D-024 unchanged; the ENVIRONMENT is a typing over starter files + command policy. | 0.78 |
|
||||
| A-308 | Does the learner UI need new surfaces for design/sim? | **Reuse the build surface.** The existing /build flow accepts `environment` kind; the file tree, editor, Run/Test buttons, and read-only output panel (CUT-2) all carry over; only starter contents and command policy differ per kind. No new route groups; variants carry the kind. | 0.80 |
|
||||
| A-309 | Seq-lease protocol shape? | **Ingest acks highest-contiguous received seq per (learner, task) on the existing WS (JSON ack frame); the capture agent trims its spool to the ack and replays from there on reconnect.** Replay margin bounded (spool cap already exists). No new endpoint; G-3 flood semantics unchanged; acks are advisory hints, gap detection stays authoritative. | 0.80 |
|
||||
| A-310 | Where does identity live in the app? | **New `ai_service/identity/` module (D-031 pattern — extend ai-service, no new apps)**: provider protocol + mock, SQLite store (D-027 family), `/v1/identity/*` router. Web enrollment + marketplace surfaces call it via engine-client. | 0.85 |
|
||||
|
||||
## Clarified Assumptions (v0.4 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
|---|-----------|------------|-------------|
|
||||
| A-201 | CLI language/toolchain for the binary? | **Probe-driven at RESEARCH** — Go → Rust → Node SEA → Python zipapp fallback chain; spec stays toolchain-agnostic so PLAN locks the probe-verified toolchain | 0.70 |
|
||||
| A-202 | Does bootstrap replace scripts/bootstrap.sh? | **No — reuse it.** CLI wraps existing `scripts/bootstrap.sh` + `scripts/dev.sh` via subprocess; zero orchestration logic duplicated in the CLI (thin passthrough pattern) | 0.85 |
|
||||
| A-203 | Where does the one-liner fetch the binary? | **Gitea latest-release API** (`/repos/{owner}/{repo}/releases/latest`) → download `nextcraft-linux-x64` + `.sha256` asset; repo raw serves `install.sh` as the stable URL | 0.80 |
|
||||
| A-204 | Install target + PATH? | **~/.local/bin** (XDG-style, no sudo), PATH hint printed when missing; `--dest` override flag | 0.85 |
|
||||
| A-205 | Binary "ongoing releases" scope? | **Every ship from v0.4 onward** attaches `nextcraft-linux-x64` + sha256 sidecar to the Gitea release — the ship workflow gains an asset step; retroactive binaries for old releases NOT required | 0.90 |
|
||||
| A-206 | No binary available yet / non-linux? | **Graceful degradation**: install script prints source-bootstrap instructions (git clone + scripts/bootstrap.sh) — never a hard fail | 0.88 |
|
||||
| A-207 | Checksum trust root? | **sha256 sidecar shipped as a release asset next to the binary** (same release, same channel); script verifies download against it. Signature/PKI out of scope for v0.4 (single forge, TLS transport) | 0.75 |
|
||||
| A-208 | Which prerequisites does doctor check? | node ≥18, pnpm ≥8, python3 ≥3.11, git, `unshare` availability (sandbox fabric needs it) — versions from the existing bootstrap tooling, not invented | 0.85 |
|
||||
| A-209 | Does `dev` manage multiple processes? | **No.** Thin passthrough to scripts/dev.sh only — the CLI stays bootstrap-scoped (D-016); orchestration remains in dev.sh | 0.82 |
|
||||
| A-210 | `.env.secrets` handling by bootstrap? | **Template copy only for `.env.example` → `.env`; secrets NEVER generated, NEVER committed; bootstrap validates presence of optional keys and warns (not blocks) when missing — mock-first providers keep the stack runnable** | 0.90 |
|
||||
|
||||
## Clarified Assumptions (v0.3 CLARIFY stage, full autonomy — auto-resolved)
|
||||
|
||||
| # | Ambiguity | Resolution | Confidence |
|
||||
@@ -133,6 +295,9 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
| D-013 | v0.1 prototype founder-agreed; D-001 business-logic gate unlocked | Founder approved starting v0.2 with AI Tutor Architecture, which constitutes agreement of the v0.1 prototype per D-001. Recorded at v0.2 SPECIFY. | Business logic authorized from v0.2 onward |
|
||||
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
|
||||
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
|
||||
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
|
||||
| D-047 | v0.6 = Marketplace Engine (auto-decided at SPECIFY, full autonomy; founder scope question left unanswered 2026-09-21) | Activates REQ-F-011..016 as REQ-6-001..006: real marketplace backend (aggregation, tagging, vector search, enrichment, resume parsing, SEO pages). Rationale: the guiding outcome (≥1,000 placements via the marketplace) runs through the only surface with zero backend; every prior milestone fed the credential side, placement side is the bottleneck. Confidence 0.80 ≥ threshold 0.60 (D-007). External 3M+ job corpus scaled to pilot (adapter contract preserves the full-scale path). | Milestone scope locked at SPECIFY; tags on v0.5.x line; feature milestone |
|
||||
| D-039 | Single-port deploy + unattended dev (v0.3.6 hotfix, founder directive) | Only :8420 reachable behind HAProxy; site was down (domain root hit the API 404). Web app = static export served same-origin by the ai-service (`AI_WEB_STATIC_DIR`); `dev -d`/`stop`/`log` daemonize ops; durable state (DB, sandboxes, pid/log) moves to `~/.nextcraft/` out of the repo. | One port serves UI+API; unattended deploy recipe in deploy/README.md |
|
||||
|
||||
---
|
||||
|
||||
@@ -140,4 +305,4 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
|
||||
Nextcraft is being built by a solo founder using the CIAgent v0.7.0 autonomous pipeline. The vision document defines a 36-month, 3-phase roadmap to ≥1,000 placements. The first CIAgent milestone (v0.1) is intentionally scoped to UI/UX only — validating the product vision through interactive prototypes before any backend or business logic investment.
|
||||
|
||||
The four surfaces (Learner, Marketplace, Employer Dashboard, Admin) map directly to the four audiences in the vision: learners, employers, marketplace operators, and platform administrators. The prototype will demonstrate the complete user journey across all surfaces with realistic mock data reflecting the AI-era competency stacks, AI-orchestration job listings, and artifact+process trace+oral defense credential model.
|
||||
The four surfaces (Learner, Marketplace, Employer Dashboard, Admin) map directly to the four audiences in the vision: learners, employers, marketplace operators, and platform administrators. The prototype will demonstrate the complete user journey across all surfaces with realistic mock data reflecting the AI-era competency stacks, AI-orchestration job listings, and artifact+process trace+oral defense credential model.
|
||||
|
||||
+184
-9
@@ -1,5 +1,147 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.8 Requirements (ACTIVE — Ship Anywhere, Feature)
|
||||
|
||||
**Milestone type: Feature** — new distribution channels (docker image, deb packages, arm64 binaries) and release automation (Gitea Actions, pre-commit). Tags on the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release); branch `milestone/v0.8-ship-anywhere`.
|
||||
|
||||
### Release Consistency Rails
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-8-001 | Release-consistency rails: three org-level Gitea act_runners stood up on founder LXC CT 113 (10.0.0.113, docker containers in sibling-container mode via host /var/run/docker.sock, hybrid labels ubuntu-latest/node/python/release, org-level coreci scope, registration token founder-provided and reset after fleet green), `.gitea/workflows/` lint+typecheck+test pipeline on push (v0.7 toolchain: eslint, ruff, mypy, pytest fast lane, web/cli node:test; CI test lanes = fast only per D-063: `ai:test:fast` `-m "not real_server and not userns"`, default web/cli node:test, CLI E2E env-gated out — real_server/userns never run in job containers), tag-triggered release workflow as the exclusive owner of release assets + registry push (D-064; local release-assets.sh remains manual fallback), single root `.pre-commit-config.yaml` (ruff + eslint + tsc local-only fast hooks, directory-scoped whole-monorepo, no network, secrets never in hooks or workflow env; hooks fail loudly on missing toolchain, never silently skip — D-070; CI is the authoritative backstop), runner cache-hygiene (docker prune cron + cache caps, 32 GB disk), hello-world dispatch verification across all three labels | critical | 1 | complete |
|
||||
|
||||
### Container Channel
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-8-002 | Docker image channel: multi-stage Dockerfile (deps layer: pnpm store + pip wheels pre-loaded; build layer: web static export with NEXT_PUBLIC_AI_SERVICE_URL=self; runtime: uvicorn :8420 serving API + export — single-port topology preserved; runtime image embeds the CLI binary at /usr/local/bin/nextcraft for in-container doctor/verify/guide — D-066), unprivileged default + honest degrade (capability probe at boot + per create — a real `unshare --user --map-root-user` attempt as the truth source, `/.dockerenv`/NC_CONTAINER only flavor remediation text; degraded = learner environment creation rejects 503 with privileged/seccomp remediation pointer + loud boot notice + doctor surfacing — D-068), healthcheck on /health, `~/.nextcraft` state volume, image pushed to the Gitea container registry as `git.coreci.dev/coreci/nextcraft` tagged semver release `v0.7.x` + `latest` on newest release (D-065), REGISTRY_TOKEN as Gitea Actions secret (self-serve PUT attempt first, founder UI fallback) | critical | 2 | pending |
|
||||
|
||||
### Debian-Native Channel
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-8-003 | Debian packaging: arm64 SEA binary + sha256 sidecar cross-built on the x64 runner without QEMU for the build (arch-agnostic esbuild+postject file surgery into an arm64 node base; optional binfmt `--version` smoke — D-069), .deb packages (dpkg-deb) for x64+arm64 = CLI binary at /usr/bin/nextcraft + package docs only, NO systemd unit (deploy --service owns unit generation) and no proxy fragments; postinst runs `nextcraft doctor` best-effort advisory (never fails dpkg) + prints next-steps (D-067); all attached to every release's downloads by the tag-triggered workflow (D-064); install.sh arch-gate becomes arch-resolve (x64 + arm64 binary paths); deb install + binary install both end at `nextcraft doctor` verify | high | 3 | pending |
|
||||
|
||||
### CLI Onboarding
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-8-004 | CLI full-suite onboarding: `nextcraft` alone takes an admin zero → running — doctor gains deb/docker-channel awareness (detects container env + suggests volume/privileged facts; detects deb install + knows its binary location; embedded in-container binary per D-066), `nextcraft guide` surfaces the admin runbook channel-appropriately as offline-first embedded text with forge pointers (D-072), docker-channel upgrade = docker pull + recreate (deploy/bootstrap refuse in-container — D-066/D-071), onboarding glue verified by an E2E test per channel (binary one-liner path exists from v0.4; docker + deb paths new) | high | 4 | pending |
|
||||
|
||||
### Admin Guides
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-8-005 | Admin release & deployment guides: deploy/README.md expanded into the full admin runbook — three install channels (docker / deb / binary+install.sh) first-install recipes, per-channel upgrade paths (docker pull + recreate / deb reinstall via dpkg -i + deploy --service / install.sh re-run + deploy --service, all idempotent — D-071), HAProxy + systemd recipes preserved, troubleshooting tables extended (runner, registry, docker degrade, deb), every recipe backed by a test or verified live-box evidence (house rule) | critical | 5 | pending |
|
||||
|
||||
## v0.7 Requirements (Complete — Test Harness Review, NFR; shipped as v0.6.6)
|
||||
|
||||
**Milestone type: NFR — refinement-only (D-001 purity gate: zero `feat:` commits).** Review the entire test harness: make sure the right tests are being run, make them effective, cut tests that provide no real value, eliminate all warnings, and address the findings within this single milestone. Founder scope decisions locked 2026-09-26: ESLint included (D-048), recharts 2→3 included (D-049), source-pin triage cut-trivial-keep-real (D-050), mypy included (D-051).
|
||||
|
||||
### Harness Wiring
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-7-001 | Test harness infrastructure: coverage measurement wired (pytest-cov for ai-service, c8 around the existing `tsx --test` for web/cli — D-052), pytest-timeout safety net (`signal` method, 60s global / 180s for `real_server`+`userns` via conftest collection hook — D-054; CLI fresh-clone E2E keeps its real 600s bootstrap, env-gated out of the default `cli:test` into a `cli:test:e2e` script with a 900s budget — D-055), markers `fast`/`real_server`/`userns` registered under `--strict-markers` with fast subset = `-m "not real_server and not userns"` (`ai:test:fast` script; unmarked tests default to fast — D-053), web tests inside the turbo pipeline (`web#test` task — package name is `web` + root `test`/`web:test` scripts), silent-skip fixes (path-invocation reports skip; env/global leaks fixed — `AI_PROVIDER` env mutation restored, conftest exception swallow narrowed), stale PLAN.md test counts corrected | critical | 1 | complete |
|
||||
|
||||
### Suite Composition
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-7-002 | Evidence-based suite composition: trivial/no-value tests cut per the D-057 rule (keep a source pin only if it encodes a documented regression lesson or cross-boundary wiring invisible to typecheck/build/behavioral tests — e.g. E87, runtime fetch wiring, gate composition; cut pins that merely assert the code says what it says; kept pins named after their regression), duplicated harness infrastructure consolidated (KillableProxy ×2, `_free_port` ×4, userns probe ×4, real-uvicorn serve boilerplate ×5, wirePosting ×2 → `tests/helpers/` package — D-060; fixtures stay in conftest), flake strategy replaces timeout-widening with deterministic waits where possible, leaked sandbox-droppings cleanup + `tests/sandboxes/` anchoring fixed | critical | 2 | complete |
|
||||
|
||||
### Warning Elimination
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-7-003 | Zero-warning pytest runs: `session.query()` → `session.exec()` at all 14 sites (marketplace/store.py ×12, identity/store.py ×2), resource cleanup for the 14 ResourceWarning sites (UploadFile close ×10 in api/defense.py + api/marketplace.py, socket close ×3 in scripts/sandbox-agent.py, file handle in tests/grading/test_engine.py), pytest `filterwarnings` policy (base action `error`; narrow module+message-scoped upstream ignores for the fastapi/starlette TestClient httpx warning and anyio BlockingPortal — no blanket category ignores, nothing ignored for `ai_service.*` — D-056), `fixture_loop_scope` pinned, dead `if False` branch removed | critical | 3 | complete |
|
||||
|
||||
### Lint & Suppression Hygiene
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-7-004 | Lint toolchain completion: ESLint flat config (typescript-eslint + react-hooks) for all JS/TS workspaces wired into `turbo lint` (currently a no-op for 5 of 6 packages, D-048), all 81 inert Python `# noqa` reconciled via RUF100 (dead ones removed), the 5 orphaned eslint-disable comments resolved under the real config, dead exports removed (ai-scenarios.ts, NavItem/ThemeConfig, Spawn, MicControl), stale version comments + `FastAPI(version="0.3.0")` drift fixed (pyproject.toml as single version source bumped to the shipped line; FastAPI version read from package metadata — D-061), `engines` fields added to package.jsons | high | 4 | complete |
|
||||
|
||||
### Type & Dependency Hardening
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-7-005 | Static typing for ai-service (D-051): mypy wired with `ai:typecheck` script + turbo task at a relaxed-first baseline (D-058: `check_untyped_defs`, `warn_unused_ignores`, `no_implicit_optional`, `warn_redundant_casts`, `warn_unreachable` — not full strict; per-module overrides available later), all 27 `# type: ignore` comments made meaningful or removed; recharts 2→3 upgrade (D-049) with chart-surface verification = `tsc --noEmit` + static-export `next build` + existing web tests green, live mount probe as the operational smoke (D-059 — no browser/render harness, out of scope); deprecated transitive deps resolved where toolchain allows | high | 5 | complete |
|
||||
|
||||
## v0.6 Requirements (Complete — Marketplace Engine; shipped as v0.5.6)
|
||||
|
||||
### Job Aggregation & Postings
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-001 | Job posting backend (from REQ-F-011, pilot-scaled): postings store (6th D-027 store family — SQLite, insert-safe, protocol-wrapped), employer posting lifecycle (draft → published → closed), ingestion adapter contract (employer-authored postings + seeded corpus at pilot scale; 3M+ external aggregation preserved by the adapter interface), marketplace API routes replacing the mock-data reads on job board/detail/employer surfaces, 18+ verified identity gate on apply/post flows (D-043 composition retained) | critical | 1 | complete |
|
||||
| REQ-6-002 | AI-powered tagging (from REQ-F-012): skills/seniority/category extraction from posting text via the LLM structured-output layer (D-020) — deterministic mock-first, provider-agnostic; tags persisted with the posting; tag facets drive the search filter sidebar | high | 2 | complete |
|
||||
|
||||
### Search & Matching
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-003 | Semantic vector search (from REQ-F-013): embedding provider protocol (mock-first, deterministic; real provider env-configured per D-014), vector store at pilot scale (SQLite-local — no pgvector before the Postgres migration), candidate-competency ↔ posting-requirement similarity ranking, match-score surfacing on job cards + candidate matches (replaces mock match scores) | critical | 3 | complete |
|
||||
|
||||
### Enrichment & Parsing
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-004 | Company enrichment (from REQ-F-014): employer profile enrichment from posting/company data (descriptions, social links, logo refs) via LLM structured output; enrich-on-post with manual founder override path; enriched data stored separately from raw (provenance marked) | medium | 4 | complete |
|
||||
| REQ-6-005 | Resume/profile parsing (from REQ-F-015): learner profile auto-fill from an uploaded resume — LLM structured extraction mapping to the competency-profile shape, learner-review-before-save (never silently overwrite), upload bounded (size/format guard, house pattern) | medium | 4 | complete |
|
||||
|
||||
### SEO & Programmatic Pages
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-6-006 | SEO-optimized programmatic pages (from REQ-F-016): server-rendered job/stack/landing pages with metadata (title/description/OG), sitemap.xml + robots.txt, JSON-LD structured data (JobPosting schema), static-export compatible (D-039 single-port deploy unchanged) | high | 5 | complete |
|
||||
|
||||
## 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 | 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 | 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 | 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 | complete |
|
||||
|
||||
## v0.4 Requirements (Complete — Distribution & Bootstrap CLI, shipped as v0.3.4)
|
||||
|
||||
### Bootstrap CLI
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-4-001 | `nextcraft` CLI (linux x64 binary): `doctor` command checking prerequisites (node, pnpm, python3, git, unshare) with actionable error messages | critical | 1 | complete |
|
||||
| REQ-4-002 | `bootstrap` command: pnpm install, ai-service venv + pinned deps, .env from templates, key validation, .env.secrets handling; `verify` health check (ports, imports, builds); `dev` thin passthrough to scripts/dev.sh | critical | 1 | complete |
|
||||
|
||||
### Distribution
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-4-003 | One-liner install script (`curl -fsSL <url> \| bash`): detects linux x64, resolves latest release from Gitea API, downloads binary + checksum, verifies sha256, installs to ~/.local/bin (PATH hint), degrades to source-bootstrap instructions when no binary | critical | 2 | complete |
|
||||
| REQ-4-004 | Binary release pipeline: reproducible linux x64 build script, sha256 checksum sidecar, upload as release assets on every ship from v0.4 onward (ongoing binaries requirement) | critical | 2 | complete |
|
||||
| REQ-4-005 | Install + quickstart documentation: README one-liner quickstart, CLI command reference, fresh-clone-to-running-stack end-to-end verification | high | 3 | complete |
|
||||
|
||||
## v0.3 Requirements (Credential Engines)
|
||||
|
||||
### Sandbox & Telemetry
|
||||
@@ -136,18 +278,18 @@
|
||||
|
||||
| ID | Description | Priority | Milestone | Status |
|
||||
|----|-------------|----------|-----------|--------|
|
||||
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.4+ | deferred |
|
||||
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.4+ | deferred |
|
||||
| REQ-F-013 | Semantic vector search for role matching | high | v0.4+ | deferred |
|
||||
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.4+ | deferred |
|
||||
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.4+ | deferred |
|
||||
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.4+ | deferred |
|
||||
| REQ-F-011 | AI job aggregation pipeline (3M+ jobs from 120K companies) | high | v0.6 | activated → REQ-6-001 (pilot-scaled; adapter contract preserves full scale) |
|
||||
| REQ-F-012 | AI-powered tagging (skills, categories, seniority) | high | v0.6 | activated → REQ-6-002 |
|
||||
| REQ-F-013 | Semantic vector search for role matching | high | v0.6 | activated → REQ-6-003 |
|
||||
| REQ-F-014 | AI company enrichment (logos, descriptions, social links) | medium | v0.6 | activated → REQ-6-004 |
|
||||
| REQ-F-015 | AI resume parsing (profile auto-fill) | medium | v0.6 | activated → REQ-6-005 |
|
||||
| REQ-F-016 | SEO-optimized programmatic pages | medium | v0.6 | activated → REQ-6-006 |
|
||||
|
||||
### Platform Infrastructure
|
||||
|
||||
| ID | Description | Priority | Milestone | Status |
|
||||
|----|-------------|----------|-----------|--------|
|
||||
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.4+ | deferred (deferred from v0.3 per founder directive) |
|
||||
| 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 |
|
||||
@@ -170,7 +312,40 @@
|
||||
|
||||
## Traceability Matrix
|
||||
|
||||
### v0.3 (current milestone)
|
||||
### v0.6 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-6-001 | 1 | complete |
|
||||
| REQ-6-002 | 2 | complete |
|
||||
| REQ-6-003 | 3 | complete |
|
||||
| REQ-6-004 | 4 | complete |
|
||||
| REQ-6-005 | 4 | complete |
|
||||
| REQ-6-006 | 5 | complete |
|
||||
|
||||
### v0.5 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| 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)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-4-001 | 1 | complete |
|
||||
| REQ-4-002 | 1 | complete |
|
||||
| REQ-4-003 | 2 | complete |
|
||||
| REQ-4-004 | 2 | complete |
|
||||
| REQ-4-005 | 3 | complete |
|
||||
|
||||
### v0.3 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
@@ -231,4 +406,4 @@
|
||||
| REQ-025 | 5 | complete |
|
||||
| REQ-026 | 6 | complete |
|
||||
| REQ-027 | 6 | complete |
|
||||
| REQ-028 | 6 | complete |
|
||||
| REQ-028 | 6 | complete |
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
# Nextcraft — RESEARCH.md (v0.8 Phase 0 RESEARCH — Ship Anywhere)
|
||||
|
||||
Probe-verified census of the release/distribution surface, 2026-09-27.
|
||||
Every conclusion carries evidence (file paths probed in this repo, live HTTP
|
||||
probes, upstream docs). Binding founder decisions D-063..D-072 are NOT
|
||||
re-litigated; this research operationalizes them. Upstream docs consulted:
|
||||
docs.gitea.com runner 3.x (install/labels/configuration/cache/registration),
|
||||
Gitea Actions design/FAQ/comparison/secrets/token-permissions/container-registry,
|
||||
postject README, nodejs.org dist index. Live probes against the actual
|
||||
instance: `git.coreci.dev` reports **Gitea 1.26.1** (`/api/v1/version`), the
|
||||
OCI registry endpoint `/v2/` is live (401 unauth = up), the latest release
|
||||
(v0.6.6, id 909) carries `nextcraft-linux-x64` + `.sha256`.
|
||||
|
||||
---
|
||||
|
||||
## R-1..R-7 — Runner fleet on CT 113 (question 1)
|
||||
|
||||
**R-1. Use the `docker.io/gitea/runner:3` image, not `gitea/act_runner`.**
|
||||
The Docker Hub `gitea/act_runner` repository is marked *Deprecated Gitea
|
||||
runner. Moved to gitea/runner* (hub.docker.com/r/gitea/act_runner, last push
|
||||
5 months ago); the current series is runner `3.x` (docs dropdown: develop /
|
||||
3.3.0 / 2.3.0 / 1.0.8 / 0.6.1) with the binary renamed `gitea-runner`. The
|
||||
founder's "gitea/act_runner docker containers" intent (PROJECT.md line 28)
|
||||
maps to the renamed official image. Pin `gitea/runner:3` (follows newest
|
||||
3.x); runner versioning is independent of Gitea's and requires Gitea ≥1.21 —
|
||||
1.26.1 is fine. The *basic* flavour (alpine base, no bundled daemon, tini,
|
||||
root) is exactly sibling mode: jobs share the host daemon via the mounted
|
||||
socket. The `dind` flavours are NOT wanted (D-decision: sibling mode).
|
||||
|
||||
**R-2. Concrete compose shape (3 sibling containers, one per lane).**
|
||||
Each runner needs its OWN `/data` volume (holds the `.runner` registration
|
||||
file + config; two runners sharing one file refuse to start via an advisory
|
||||
lock) and the host socket:
|
||||
|
||||
```yaml
|
||||
# /srv/nc-ci/docker-compose.yaml on CT 113 (10.0.0.113)
|
||||
x-runner-base: &runner-base
|
||||
image: docker.io/gitea/runner:3
|
||||
restart: always
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://git.coreci.dev"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}" # founder-provided, NEVER committed
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/data # per-runner, see below
|
||||
- ./cache:/data-cache # optional shared actcache mount (R-5 alt)
|
||||
|
||||
services:
|
||||
runner-node:
|
||||
<<: *runner-base
|
||||
container_name: nc-runner-node
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://git.coreci.dev"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
|
||||
GITEA_RUNNER_NAME: "nc-runner-node"
|
||||
GITEA_RUNNER_LABELS: "node:docker://node:24-bookworm,ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data-runner-node:/data
|
||||
|
||||
runner-python:
|
||||
<<: *runner-base
|
||||
container_name: nc-runner-python
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://git.coreci.dev"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
|
||||
GITEA_RUNNER_NAME: "nc-runner-python"
|
||||
GITEA_RUNNER_LABELS: "python:docker://python:3.11-bookworm"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data-runner-python:/data
|
||||
|
||||
runner-release:
|
||||
<<: *runner-base
|
||||
container_name: nc-runner-release
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: "https://git.coreci.dev"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
|
||||
GITEA_RUNNER_NAME: "nc-runner-release"
|
||||
GITEA_RUNNER_LABELS: "release:docker://node:24-bookworm"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data-runner-release:/data
|
||||
```
|
||||
|
||||
(YAML anchors with per-service volume overrides: write volumes per service
|
||||
explicitly — compose merges maps; keep the literal per-service form in the
|
||||
final guide.) The entrypoint registers on first start (when `/data/.runner`
|
||||
absent), unsets the registration token from the env, then execs
|
||||
`gitea-runner daemon`. Org-level scope comes from the TOKEN itself
|
||||
(obtained at `/org/coreci/settings/actions/runners`) — one token registers
|
||||
all three and stays valid until reset (founder directive: reset after the
|
||||
fleet is green).
|
||||
|
||||
**R-3. Labels: `GITEA_RUNNER_LABELS` env, not config file, for first
|
||||
registration.** Format `name[:schema[:args]]`, comma-separated; our shapes
|
||||
are all `docker://<image>`. Precedence (runner docs, Labels):
|
||||
`--labels`/`GITEA_RUNNER_LABELS` > `runner.labels` in the config file >
|
||||
labels stored in `.runner`; the entrypoint passes `GITEA_RUNNER_LABELS` to
|
||||
both `register` and `daemon`, and later label changes are written back and
|
||||
re-declared without re-registering. The hybrid-label fleet map (above):
|
||||
`node` = node:24-bookworm; `python` = python:3.11-bookworm; `release` =
|
||||
node:24-bookworm (the release job needs node + dpkg-deb + git + curl —
|
||||
bookworm ships dpkg-deb and git; docker CLI installed in-job, R-21);
|
||||
`ubuntu-latest` = `docker.gitea.com/runner-images:ubuntu-latest` (official
|
||||
Gitea runner image; community `catthehacker/ubuntu:act-latest` is the
|
||||
documented alternative) carried by runner-node so the dispatch
|
||||
verification lane (REQ-8-001) has a generic home. A job whose `runs-on`
|
||||
matches NO runner label still runs, on the runner's default image — always
|
||||
pin our own labels (runner docs: "first match wins… unmatched jobs run in
|
||||
the default image").
|
||||
|
||||
**R-4. Capacity = 1 per runner (config default), giving fleet concurrency 3.**
|
||||
`runner.capacity` default is `1` in the example config; with
|
||||
`container.network` empty each concurrent docker job takes a subnet from
|
||||
the daemon address pool, so 1-per-runner is also the safe default for the
|
||||
docker daemon. One config file per runner is optional — defaults are safe;
|
||||
if we mount one, set only what deviates:
|
||||
|
||||
```yaml
|
||||
# config-runner-release.yaml (mounted at /config.yaml + CONFIG_FILE=/config.yaml)
|
||||
runner:
|
||||
capacity: 1
|
||||
timeout: 3h # Gitea instance also enforces 3h default
|
||||
cache:
|
||||
enabled: true
|
||||
host: "10.0.0.113" # R-5 — job containers must reach the cache server
|
||||
port: 8088
|
||||
health_check:
|
||||
enabled: true
|
||||
min_free_disk_space_mb: 4096 # half the disk floor for a 32 GB CT
|
||||
```
|
||||
|
||||
**R-5. Cache + 32 GB disk hygiene.** Every runner starts its own cache
|
||||
server (`actions/cache` works with zero config), but a dockerized runner
|
||||
creates a per-job network, so the auto-detected cache address is
|
||||
UNREACHABLE from job containers → `actions/cache` fails with
|
||||
`connect ETIMEDOUT` (runner docs, Caching → Dockerized runners). Concrete
|
||||
fix: set `cache.host` to an address job containers can reach (CT 113's own
|
||||
IP/hostname — NOT `0.0.0.0`, invalid) + `cache.port` to a fixed port and
|
||||
publish it from the runner container (`-p 8081:8088` etc., one per runner),
|
||||
or put job containers on a shared `container.network`. Disk caps for the
|
||||
32 GB CT: `cache.retention: 168h` (default), `cache.repo_size_limit: 10GB`
|
||||
(default), plus `health_check.enabled: true` + `min_free_disk_space_mb`
|
||||
(pausing new-task fetch when low). Host-side cron per REQ-8-001:
|
||||
`docker system prune -af --filter until=168h` + `docker volume prune -f`
|
||||
daily; the runner's own idle cleanup (workdir_cleanup_age 24h,
|
||||
idle_cleanup_interval 10m) removes stale workspaces and orphaned per-job
|
||||
networks by itself. A shared cache across runners via
|
||||
`gitea-runner cache-server` + `external_server`/`external_secret` is
|
||||
available later; not needed at fleet size 3.
|
||||
|
||||
**R-6. `/data` volume is the only per-runner state that matters.** It holds
|
||||
the `.runner` registration file (identity + API credentials). Losing it =
|
||||
re-registration as a NEW runner + stale entry + outright failure if the
|
||||
token was reset (runner docs, Registration). So: named dirs per runner
|
||||
(`data-runner-{node,python,release}/`), persisted on the CT; the token is
|
||||
NEVER written to any repo file (compose reads it from an env var on the CT
|
||||
or a `.env` beside the compose file, founder-provided out-of-band).
|
||||
|
||||
**R-7. Sibling-mode path alignment: NOT needed for our workloads.** The
|
||||
alignment requirement ("identical paths inside/outside the runner
|
||||
container") applies only when workflows bind-mount the workspace into
|
||||
sibling containers (`container.bind_workdir: true` — e.g.
|
||||
`docker run -v "$PWD":/app`). Our jobs (a) run plain build/test commands in
|
||||
the job container, (b) run `docker build` in the release lane — `docker
|
||||
build` TARS UP the context dir inside the job container and streams it to
|
||||
the daemon over the API, so the daemon never needs to resolve the workspace
|
||||
path. By default the runner creates the workspace as a docker volume,
|
||||
mounts it at `/workspace` in the job container, and (with
|
||||
`container.docker_host` empty) auto-detects the host daemon and mounts
|
||||
`/var/run/docker.sock` INTO job containers + sets `DOCKER_HOST` — docker
|
||||
CLI in the job just works. If a future workflow needs sibling bind mounts,
|
||||
flip `container.bind_workdir: true` + mount the workspace parent into the
|
||||
runner container at the IDENTICAL host path + allow it in
|
||||
`container.valid_volumes` (glob syntax) — document in the admin guide as
|
||||
the known sibling-mode gotcha.
|
||||
|
||||
---
|
||||
|
||||
## R-8..R-15 — Workflow syntax + CI lanes (questions 2–3)
|
||||
|
||||
**R-8. Workflows live at `.gitea/workflows/*.yaml`** (`.github/workflows`
|
||||
also honored; we use `.gitea/`). `runs-on` values match label NAMES; a
|
||||
label's `docker://` image selects the job container. Gitea-specific
|
||||
extensions exist (absolute action URLs, `@daily` schedules) but the
|
||||
core syntax is GitHub-compatible. Supported triggers include `push` (with
|
||||
`tags:` filters) and `release`, plus `workflow_dispatch` — the FAQ's event
|
||||
table confirms `push`/`create`/`release` all supported on 1.26.
|
||||
Recommended triggers: push lane `on: push` (all branches; the fleet is
|
||||
private to the org), release lane `on: push: tags: ['v*']`.
|
||||
|
||||
**R-9. Lanes are plain `run:` steps — zero external actions required
|
||||
(except checkout).** Every CI command already exists as a repo script
|
||||
(root package.json: `web:test`, `cli:test`, `ai:test:fast`, turbo lint /
|
||||
typecheck tasks; apps/ai-service/scripts/{lint,typecheck,test}.sh are
|
||||
self-contained venv-relative bash). `actions/checkout@v4` is the one stock
|
||||
action worth keeping (shallow fetch of the triggering ref); it downloads
|
||||
from github.com by default (`DEFAULT_ACTIONS_URL=github` since 1.21) so the
|
||||
CT needs internet — Connection 3/4 in the Actions design doc. If CT 113 has
|
||||
no internet: set the instance's `DEFAULT_ACTIONS_URL=self` + mirror
|
||||
actions, or replace checkout with a raw `git fetch` step using the
|
||||
auto-provided `GITHUB_TOKEN`. Concrete lanes:
|
||||
|
||||
```yaml
|
||||
# node lane (runner label: node → container node:24-bookworm)
|
||||
- run: corepack enable && corepack prepare pnpm@12.3.4 --activate
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter web --filter @nextcraft/cli --filter @nextcraft/ui
|
||||
--filter @nextcraft/types --filter @nextcraft/mock-data lint
|
||||
- run: pnpm --filter web --filter @nextcraft/cli --filter @nextcraft/ui
|
||||
--filter @nextcraft/types --filter @nextcraft/mock-data typecheck
|
||||
- run: pnpm web:test # tsx --test tests/*.test.ts (node:test)
|
||||
- run: pnpm cli:test # E2E stays env-gated OUT (D-055/D-063)
|
||||
```
|
||||
|
||||
```yaml
|
||||
# python lane (runner label: python → container python:3.11-bookworm)
|
||||
- run: bash apps/ai-service/scripts/bootstrap.sh # venv + pip install -e .[dev]
|
||||
- run: bash apps/ai-service/scripts/lint.sh # ruff check .
|
||||
- run: bash apps/ai-service/scripts/typecheck.sh # mypy (D-058 baseline)
|
||||
- run: FAST=1 bash apps/ai-service/scripts/test.sh # -m "not real_server and not userns" (D-063)
|
||||
```
|
||||
|
||||
Note the lane split is FORCED by the repo's own scripts: `turbo lint` /
|
||||
`turbo typecheck` include `ai-service#lint/typecheck`, which require the
|
||||
venv (scripts/lint.sh:8, typecheck.sh:13 refuse without it) — so the python
|
||||
lane invokes the three bash scripts DIRECTLY (no pnpm needed in that
|
||||
container at all), and the node lane invokes eslint/tsc per TS workspace
|
||||
(`pnpm --filter <5 TS workspaces>`), never `turbo lint` wholesale. This
|
||||
matches D-063 exactly: fast pytest lane, default web/cli node:test,
|
||||
`real_server`/`userns` never in job containers (also genuinely BLOCKED
|
||||
there — docker's default seccomp denies the nested `unshare --user` the
|
||||
backend needs; the userns suites stay local/verify-lane surfaces).
|
||||
|
||||
**R-10. ubuntu-latest lane = dispatch verification only.** Nothing in the
|
||||
CI matrix needs the generic image for correctness (node/python cover the
|
||||
lanes; release needs node:24-bookworm per R-3). The ubuntu-latest label
|
||||
exists to satisfy REQ-8-001's hello-world dispatch verification across all
|
||||
three runners — a workflow_dispatch-triggered one-step job per label
|
||||
(`runs-on: [node|python|ubuntu-latest]`, `run: echo ok`), proving each
|
||||
runner registers, pulls, and executes.
|
||||
|
||||
**R-11. Toolchain/base-image recommendations.** `node:24-bookworm`
|
||||
(production runs v24.15.0; engines node≥20; bookworm = Debian 12, ships
|
||||
git/curl/dpkg-deb), pnpm 12.3.4 via **corepack** (root package.json
|
||||
`packageManager: "pnpm@12.3.4"` pins it; `corepack enable pnpm` is the
|
||||
doctor's own hint, doctor.ts:42) — probe in P1 that node:24-bookworm still
|
||||
bundles corepack (Node ≥25 dropped it; 24.x retains it, deprecated) and
|
||||
fall back to `npm i -g pnpm@12.3.4` if not. `python:3.11-bookworm`
|
||||
(requires-python ≥3.11; production 3.11.2; the venv script's
|
||||
`--without-pip` + get-pip fallback, bootstrap.sh:34-71, covers
|
||||
Debian-without-ensurepip, though the full image has ensurepip).
|
||||
`docker.gitea.com/runner-images:ubuntu-latest` for the generic label
|
||||
(official, small; `catthehacker/ubuntu:act-latest` documented as the
|
||||
community alternative). Pin by tag — the runner docs advise pinned tags
|
||||
for reproducible jobs; our lanes pin exact image tags in the compose
|
||||
labels.
|
||||
|
||||
**R-12. pnpm/pip caching strategy.** Primary: `actions/cache@v4` is
|
||||
Gitea-compatible on runner 3.x (runner serves the cache v2 API and
|
||||
self-patches the stock action ≥v4.2; `actions/upload-artifact` ≥v4.4.0
|
||||
likewise patched by the runner) — but ONLY works once R-5's
|
||||
`cache.host`+published-port fix is live on the dockerized fleet. Concrete
|
||||
steps: cache `~/.local/share/pnpm/store` keyed on `pnpm-lock.yaml` hash
|
||||
(pnpm 12 default store dir; verify with `pnpm store path` in-job) and
|
||||
`~/.cache/pip` keyed on `apps/ai-service/pyproject.toml` hash. Fallback if
|
||||
cache networking proves flaky in P1: mount a host dir into job containers
|
||||
as the store via `container.options: --volume /srv/nc-ci/pnpm-store:/root/.local/share/pnpm/store`
|
||||
(listed in `container.valid_volumes`) — deterministic, no cache server
|
||||
involved. The venv itself is cheap (~40s with warm pip cache) and not
|
||||
worth caching.
|
||||
|
||||
**R-13. Secrets: `${{ secrets.REGISTRY_TOKEN }}` + `${{ secrets.GITEA_TOKEN }}`.**
|
||||
Gitea 1.26 supports repo- and org-level Actions secrets (names alphanumeric
|
||||
+ underscore, not starting with `GITEA_`/`GITHUB_`/digit, case-insensitive);
|
||||
precedence lowest-level wins. The self-serve PUT exists:
|
||||
`PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}` with
|
||||
`{"data": "..."}` (probed: 401 unauthenticated — auth'd PUT is the
|
||||
self-serve path per D-064; founder UI fallback if the token's scope can't
|
||||
PUT secrets). CRITICAL upstream fact: the automatic per-job `GITEA_TOKEN`
|
||||
CANNOT publish packages — "Package repository authorization… is not
|
||||
implemented in Gitea Actions now. A workaround… is to use a Personal
|
||||
Access Token" (Actions comparison doc, confirmed by the token-permissions
|
||||
page). So the registry push MUST use the `REGISTRY_TOKEN` secret (a PAT
|
||||
with package write), exactly as D-064 specifies. The job token's `releases`
|
||||
scope CAN create releases/attach assets, but D-064 pins the `GITEA_TOKEN`
|
||||
secret (PAT) as the release owner too — one credential class, founder-set.
|
||||
|
||||
**R-14. Release assets attach via the REST API — the same call
|
||||
scripts/release-assets.sh already makes.** Evidence:
|
||||
scripts/release-assets.sh:32-49 resolves the release id via
|
||||
`GET /repos/coreci/nextcraft/releases/tags/{tag}` then POSTs multipart to
|
||||
`/repos/{owner}/{repo}/releases/{id}/assets?name={asset}` — that API works
|
||||
from a workflow with `secrets.GITEA_TOKEN` verbatim. `actions/upload-artifact`
|
||||
is a DIFFERENT mechanism (job artifacts via the runner's cache server, not
|
||||
release downloads) — do NOT use it for release files. `tea` CLI is
|
||||
unnecessary. The tag-triggered workflow therefore: build all assets from
|
||||
the tag checkout → reuse the script's upload function (env-resolved token:
|
||||
the workflow exports the secret into the script's env — NOTE the script
|
||||
currently resolves tokens from .env files ONLY by design
|
||||
(release-assets.sh:15-25, "v1.8 root cause"); for workflow use either
|
||||
(a) call the API directly in a workflow step, or (b) extend the script with
|
||||
an explicit `GITEA_TOKEN` env override flagged for CI-only use. Recommend
|
||||
(a) for the workflow + script untouched as the manual fallback, per D-064's
|
||||
"script remains as the manual fallback (its .env-only token resolution
|
||||
unchanged)". Version stamping: `gitea.ref_name` (the tag, e.g. `v0.7.3`)
|
||||
feeds `build:binary <tag>` (build-binary.mjs:15-20 already validates + the
|
||||
installer verifies `--version == tag`, install.sh:83-85).
|
||||
|
||||
**R-15. Event triggers + ref semantics.** `on: push` fires per branch
|
||||
push (lane runs on any branch — founder scope: "a push to any branch runs
|
||||
lint/typecheck/tests"); `on: push: tags: ['v*']` fires the release lane on
|
||||
tag push only. `gitea.ref_name` = tag name on tag events. The release
|
||||
lane builds from the tag checkout (checkout@v4 with the triggering ref)
|
||||
— dist/ artifacts never exist in a fresh job workspace, which is exactly
|
||||
why D-064 made the workflow the exclusive release maker.
|
||||
|
||||
---
|
||||
|
||||
## R-16..R-21 — Dockerfile + registry push (questions 4–5)
|
||||
|
||||
**R-16. Multi-stage Dockerfile shape for THIS repo.** No Dockerfile exists
|
||||
today (probe: no Dockerfile/.dockerignore in the tree). The build is
|
||||
verified against the actual scripts:
|
||||
|
||||
```dockerfile
|
||||
# syntax=docker/dockerfile:1
|
||||
# Stage 1 — web export (NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build, deploy/README.md:33)
|
||||
FROM node:24-bookworm AS web
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/ai-service/package.json apps/ai-service/
|
||||
COPY apps/cli/package.json apps/cli/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY packages/mock-data/package.json packages/mock-data/
|
||||
RUN corepack enable && corepack prepare pnpm@12.3.4 --activate \
|
||||
&& pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build # turbo build → apps/web/out (16 MB, probe-verified)
|
||||
|
||||
# Stage 2 — ai-service venv (prod deps only; scripts/bootstrap.sh semantics)
|
||||
FROM python:3.11-bookworm AS venv
|
||||
COPY apps/ai-service /src/apps/ai-service
|
||||
RUN python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir /src/apps/ai-service \
|
||||
&& sed -i 's|--mount=old-styleeditable --no-index ||' /opt/venv/pyvenv.cfg || true
|
||||
# (non-editable install keeps the runtime image path-stable; if editable
|
||||
# install is preferred for exact-parity with dev.sh, COPY the source to the
|
||||
# same /src path in the runtime stage instead — pip install -e bakes the path)
|
||||
|
||||
# Stage 3 — runtime (single-port: uvicorn serves API + export; D-039/D-066)
|
||||
FROM python:3.11-slim-bookworm
|
||||
RUN useradd --system --create-home nextcraft
|
||||
COPY --from=venv /opt/venv /opt/venv
|
||||
COPY --from=web /repo/apps/web/out /opt/nextcraft/web
|
||||
COPY apps/cli/dist/nextcraft-linux-x64 /usr/local/bin/nextcraft # D-066 embed
|
||||
COPY apps/ai-service/ai_service /opt/nextcraft/ai_service # source for exec + editable parity
|
||||
ENV PATH=/opt/venv/bin:$PATH \
|
||||
AI_WEB_STATIC_DIR=/opt/nextcraft/web \
|
||||
AI_HOST=0.0.0.0 \
|
||||
NC_CONTAINER=1
|
||||
RUN mkdir -p /home/nextcraft/.nextcraft && chown -R nextcraft:nextcraft \
|
||||
/home/nextcraft/.nextcraft /opt/nextcraft
|
||||
USER nextcraft
|
||||
WORKDIR /opt/nextcraft
|
||||
EXPOSE 8420
|
||||
VOLUME /home/nextcraft/.nextcraft
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=4)" || exit 1
|
||||
CMD ["uvicorn", "ai_service.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
||||
```
|
||||
|
||||
Key facts baked from the code: `NEXT_PUBLIC_AI_SERVICE_URL=self` is
|
||||
BUILD-time only — engine-base-url.ts inlines it at build (relative
|
||||
same-origin fetches); zero AI_* env is needed at build. Runtime needs
|
||||
`AI_WEB_STATIC_DIR` (config.py:84, main.py:377 mounts StaticFiles) and
|
||||
nothing else mandatory — provider keys/admin token/public base URL are all
|
||||
optional with G-11 fallbacks (main.py:142-219). `.dockerignore` must allow
|
||||
`apps/web/out` is NOT copied (built in-stage) but must NOT exclude
|
||||
`apps/cli/dist/nextcraft-linux-x64` (the CLI embed input) — and the
|
||||
release workflow must build the binary BEFORE `docker build`. Healthcheck
|
||||
uses venv python urllib (slim has no curl); `/health` returns
|
||||
`{"status":"ok",...}` (main.py:286-292). Non-root `USER nextcraft` — but
|
||||
note `VOLUME` + chown ordering: chown BEFORE the VOLUME declaration to
|
||||
seed ownership (VOLUME after chown preserves it on named volumes' first
|
||||
use).
|
||||
|
||||
**R-17. State volume mapping is env-overridable by design.** config.py:17
|
||||
`_STATE_ROOT = Path.home() / ".nextcraft"` → for USER nextcraft that is
|
||||
`/home/nextcraft/.nextcraft` (DB at `data/nextcraft.db`, sandbox workdirs
|
||||
at `sandboxes/`, config.py:39/77). Both `AI_DB_PATH` and `AI_SANDBOX_DIR`
|
||||
carry the `~`-expanduser validator (config.py:86-93) — operators can
|
||||
relocate freely. Volume recipe: `-v nc-state:/home/nextcraft/.nextcraft`.
|
||||
On the degraded userns path (R-24) the sandboxes/ subtree simply never
|
||||
gets populated.
|
||||
|
||||
**R-18. Image size budget ≈ 400–500 MB.** python:3.11-slim-bookworm ≈
|
||||
120 MB base; prod venv (fastapi/uvicorn/pydantic/sqlmodel/httpx/pypdf etc.,
|
||||
NO dev extras) ≈ 130 MB (the dev venv with mypy/pytest-cov measures 179 MB
|
||||
on this box; prod-only is smaller); web export 16 MB (probed du);
|
||||
CLI binary ~117 MB (apps/cli/README.md:23, dist probe 118 MB — D-066 accepts
|
||||
it). Total well under the 32 GB CT disk and reasonable for `docker pull` on
|
||||
a LAN; document the budget in the guide with the breakdown table.
|
||||
|
||||
**R-19. Unprivileged-by-default + honest degrade is already half-built.**
|
||||
The container needs no capabilities for the API/chat/grading path; the
|
||||
sandbox fabric (unshare --user --map-root-user) is BLOCKED by docker's
|
||||
default seccomp in an unprivileged container — that is D-068's whole
|
||||
premise. The privileged variant (`--privileged` or
|
||||
`--security-opt seccomp=unconfined` + userns tweaks) is the documented
|
||||
opt-in for full sandbox capability, per the founder decision.
|
||||
|
||||
**R-20. Registry push mechanics = raw docker login + build + push.** The
|
||||
Gitea container registry follows `{registry}/{owner}/{image}` naming
|
||||
(docs: Container Registry) → `git.coreci.dev/coreci/nextcraft` is the ONLY
|
||||
addressable name (confirms D-065); the live endpoint probed at
|
||||
`https://git.coreci.dev/v2/` (401 unauth = serving). `docker/build-push-action`
|
||||
would drag buildx + GitHub-context assumptions for a single-arch amd64
|
||||
image we don't need (D-069: no multi-arch in v0.8). Concrete release steps
|
||||
(label `release`, container node:24-bookworm + `apt-get install -y
|
||||
--no-install-recommends docker.io` in-job — bookworm main ships docker.io;
|
||||
the runner mounts the host socket + DOCKER_HOST per R-7):
|
||||
|
||||
```yaml
|
||||
- run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.coreci.dev -u coreci --password-stdin
|
||||
docker build -t git.coreci.dev/coreci/nextcraft:${{ gitea.ref_name }} \
|
||||
-t git.coreci.dev/coreci/nextcraft:latest .
|
||||
docker push git.coreci.dev/coreci/nextcraft:${{ gitea.ref_name }}
|
||||
docker push git.coreci.dev/coreci/nextcraft:latest
|
||||
```
|
||||
|
||||
Tag policy per D-065: semver tag per phase release + `latest` moved to the
|
||||
newest release; no per-push/branch tags. Note `docker.io` pull-through for
|
||||
the base images requires CT internet (node/python/slim from Docker Hub) —
|
||||
same Connection-3 assumption as R-8's checkout.
|
||||
|
||||
**R-21. Sibling-mode job→daemon trust note for the guide.** Jobs reach the
|
||||
host daemon via the socket: a job (or a malicious workflow) COULD touch
|
||||
other containers/images on CT 113 (runner docs flag this trade-off on the
|
||||
basic flavour). Mitigations to document: fleet is org-scoped to coreci
|
||||
private repos (the gitea.com hardening recipe: org-level runners only,
|
||||
isolated containers, PR-approval gates for forks — our repo has no fork PRs);
|
||||
the CT hosts nothing else. This is an accepted-risk table row in the admin
|
||||
guide, not a code change.
|
||||
|
||||
---
|
||||
|
||||
## R-22..R-26 — deb packaging + arm64 cross-build (questions 6–7)
|
||||
|
||||
**R-22. deb = dpkg-deb build of a staging tree; control fields concrete.**
|
||||
`dpkg-deb --build` exists on this box (1.21.23, probed) and never executes
|
||||
payload → cross-arch safe (D-069). Build script home: `scripts/build-deb.sh`
|
||||
(sibling of install.sh/release-assets.sh in scripts/). Layout:
|
||||
|
||||
```
|
||||
stage/
|
||||
DEBIAN/control
|
||||
DEBIAN/postinst # advisory doctor (D-067)
|
||||
usr/bin/nextcraft # the SEA binary
|
||||
usr/share/doc/nextcraft/copyright
|
||||
usr/share/doc/nextcraft/README.md.gz
|
||||
```
|
||||
|
||||
control fields (per Debian policy, all satisfiable):
|
||||
`Package: nextcraft`, `Version: <tag minus leading v>` (e.g. v0.7.3 →
|
||||
0.7.3; upstream-version policy forbids leading `v`), `Section: utils`,
|
||||
`Priority: optional`, `Architecture: amd64|arm64` (passed by the workflow
|
||||
per build), `Maintainer: Nextcraft <founder@coreci.dev>`,
|
||||
`Depends:` — NOTHING hard (the binary is self-contained; doctor checks are
|
||||
advisory), `Recommends: nodejs (>= 18), python3 (>= 3.11), git,
|
||||
util-linux` (honest soft deps so `apt install` pulls the toolchain the CLI
|
||||
orchestrates without bricking unattended installs — the D-067 rationale),
|
||||
`Description: ...`. Build invocation:
|
||||
`dpkg-deb --root-owner-group -Zxz --build stage nextcraft_<ver>_<arch>.deb`
|
||||
(root-owner-group = deterministic ownership; xz default compression).
|
||||
Asset names: `nextcraft_<ver>_amd64.deb` / `nextcraft_<ver>_arm64.deb` +
|
||||
per-deb `.sha256` sidecars, attached by the tag workflow (D-064).
|
||||
|
||||
**R-23. postinst = advisory doctor + next-steps, NEVER fails dpkg.**
|
||||
Concrete:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "nextcraft: running advisory environment check (never blocks this install)…"
|
||||
if command -v nextcraft >/dev/null 2>&1; then
|
||||
nextcraft doctor || echo "nextcraft: doctor reported issues above — advisory only."
|
||||
fi
|
||||
cat <<'EOF'
|
||||
Next steps:
|
||||
nextcraft doctor # full preflight (node, pnpm, python3, git, unshare)
|
||||
nextcraft deploy --service # one-command stack deploy (clone→build→restart→verify + systemd)
|
||||
EOF
|
||||
exit 0
|
||||
```
|
||||
|
||||
`|| echo` keeps exit 0 through a failing doctor (D-067's "exit never fails
|
||||
the dpkg transaction" — the install.sh degradation pattern). NO systemd
|
||||
unit, NO proxy fragments in the deb (D-067: `deploy --service` owns unit
|
||||
generation with real paths).
|
||||
|
||||
**R-24. install.sh: arch-gate → arch-resolve.** Current gate:
|
||||
`uname -s = Linux && uname -m = x86_64` else degrade (install.sh:19-25).
|
||||
Change: map `x86_64 → nextcraft-linux-x64`, `aarch64/arm64 →
|
||||
nextcraft-linux-arm64`, anything else keeps the source-bootstrap
|
||||
degradation. The asset/sum URL sed patterns (install.sh:40-41) gain an
|
||||
arch-parameterized asset name; the sha256-verify-BEFORE-install and
|
||||
`--version == tag` integrity gates (install.sh:64-85) stay byte-identical
|
||||
per arch. Keep POSIX sh (dash-safe).
|
||||
|
||||
**R-25. arm64 cross-build: the ONLY arch-specific step in build-binary.mjs
|
||||
is the node-binary copy.** Evidence from the script: esbuild bundle is
|
||||
arch-agnostic JS (build-binary.mjs:23-35, `--target=node18` CJS);
|
||||
`node --experimental-sea-config` produces the SEA blob (line 43);
|
||||
`copyFileSync(process.execPath, out)` (lines 45-46) copies the HOST node
|
||||
binary — THE arch-specific part; postject injects `NODE_SEA_BLOB` into the
|
||||
copied binary with the sentinel fuse (lines 47-51). postject on Linux adds
|
||||
an ELF **note** (postject README: "For ELF executables, the resources are
|
||||
added as notes") — pure file surgery, arch-agnostic by construction; it
|
||||
never runs the target binary. Arm64 node base: official tarball
|
||||
`https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.xz` —
|
||||
**probed live: HTTP 200, 30.1 MB** (version-matched to the production
|
||||
node). Concrete change: `build-binary.mjs` gains an arch parameter
|
||||
(`build:binary <tag> [--arch x64|arm64]`): x64 keeps `process.execPath`;
|
||||
arm64 downloads + extracts the arm64 tarball (cache under
|
||||
`~/.cache/nextcraft/node-arm64/`) and copies `bin/node` from it; output
|
||||
`dist/nextcraft-linux-arm64` + `.sha256` sidecar (the script's existing
|
||||
checksum block, lines 54-56, retargeted). Both binaries + debs build in one
|
||||
release job in minutes (esbuild ~5s, download ~30 MB once, postject ~1s).
|
||||
|
||||
**R-26. SEA blob cross-arch feasibility: default config is
|
||||
arch-independent — verify with a P3 probe.** build-binary.mjs's sea config
|
||||
is `{main, output, disableExperimentalSEAWarning}` (lines 37-42) — it does
|
||||
NOT enable `useCodeCache`/`useSnapshot`, the two options that tie a blob to
|
||||
an architecture; the default blob is the script + metadata, which is why
|
||||
cross-generation is workable. Honesty requirement: this is
|
||||
upstream-doc-implicit, not doc-explicit, so P3 ships a probe: generate the
|
||||
blob on the x64 runner node 24.15.0, inject into the arm64 base, and
|
||||
`--version`-smoke it under binfmt (one-time CT registration:
|
||||
`docker run --privileged --rm multiarch/qemu-user-static --reset -p yes`,
|
||||
D-069's optional opportunistic path). If the probe fails, the fallback
|
||||
keeps the build QEMU-free where it matters: run `--experimental-sea-config`
|
||||
under the DOWNLOADED arm64 node binary via binfmt (one emulated invocation
|
||||
for blob generation only — esbuild/pip/deb still native x64). The deb
|
||||
never executes its payload (R-22) so packaging needs no emulation at all.
|
||||
Record the probe result in the P3 ship notes.
|
||||
|
||||
---
|
||||
|
||||
## R-27..R-29 — Sandbox degrade detection (question 8, D-068)
|
||||
|
||||
**R-27. The userns probe exists and is exactly promotable.**
|
||||
tests/helpers/userns.py:28-40 — `userns_available()`: `shutil.which("unshare")`
|
||||
guard, then `subprocess.run(["unshare", "--user", "--map-root-user", "true"],
|
||||
capture_output=True, timeout=10)`, True iff returncode==0. D-068 says
|
||||
promote "the proven tests/helpers/userns.py pattern into the sandbox
|
||||
module" — concrete home: `ai_service/sandbox/capability.py` exporting
|
||||
`userns_available()` (verbatim semantics; importable by main.py boot
|
||||
wiring, the API layer, and doctor-adjacent code) while tests keep importing
|
||||
the test helper (which can delegate to the promoted one to avoid drift).
|
||||
A "real userns capability probe" means exactly this: kernel says yes/no to
|
||||
`unshare --user --map-root-user`, NOT a marker file — markers only flavor
|
||||
remediation (D-068's own rationale: `/.dockerenv` says docker, not
|
||||
userns-blocked; a privileged container passes and must NOT degrade).
|
||||
|
||||
**R-28. Integration points, all with in-repo precedents.**
|
||||
(1) **Boot notice**: main.py lifespan — the mount-less warning at
|
||||
main.py:404 and the admin-token warning at main.py:202 are the G-11
|
||||
pattern to copy: after provider wiring, run the probe once; on failure
|
||||
`logger.warning("user namespaces unavailable (probe failed) — learner
|
||||
sandbox creation will be rejected 503; running inside Docker? see the
|
||||
deploy guide's privileged/seccomp variant …")`. (2) **Per-create 503**:
|
||||
api/sandboxes.py create_sandbox already maps `PoolFullError → 503`
|
||||
(sandboxes.py:157-160); add an `except SandboxUnavailableError` arm in the
|
||||
same try with a remediation `detail` (pointer to the documented
|
||||
privileged/seccomp variant). SandboxUnavailableError is raised by the
|
||||
backend on missing/blocked unshare (unshare_backend.py:90, 256, 344-355)
|
||||
and currently would 500 — the degrade must be the honest 503. (3) **doctor
|
||||
surfaces it**: doctor.ts checkUnshare (doctor.ts:138-150) currently only
|
||||
`which unshare` — upgrade to attempt the real
|
||||
`unshare --user --map-root-user true` (mirroring userns.py) and report
|
||||
container context. (4) **Container detection flavors only**: the marker
|
||||
set is `/.dockerenv` file + `NC_CONTAINER=1` (entrypoint sets it, R-16) +
|
||||
optional `/proc/self/cgroup` containerd/docker string — used ONLY to pick
|
||||
docker-specific vs generic remediation text. Chat/agents/grading need zero
|
||||
changes (they never touch the fabric). This supersedes A-101's cwd-jail
|
||||
fallback for deployed schools (D-068 rationale: unisolated learner code
|
||||
next to provider keys is unacceptable) — note the A-101 fallback text stays
|
||||
in PROJECT.md history as superseded, no code change needed (no cwd-jail
|
||||
fallback was ever implemented — probe: backend.py has no such fallback
|
||||
class).
|
||||
|
||||
**R-29. Degrade semantics are additive to existing honest-refusal
|
||||
plumbing.** The manager's create already refuses loudly (PoolFullError
|
||||
"no queue (D-032)"); the degrade adds a capability-class refusal with the
|
||||
same shape (503 + actionable detail). The web client's use-sandbox-session
|
||||
already renders 503 honestly (v0.3 surfaces per ARCHITECTURE.md hooks
|
||||
row) — no web work in scope beyond what P4's channel-aware doctor/guide
|
||||
does.
|
||||
|
||||
---
|
||||
|
||||
## R-30..R-31 — CLI onboarding + admin guides (D-066/D-071/D-072 anchors)
|
||||
|
||||
**R-30. Channel-aware doctor/guide/refuse: concrete hooks.** doctor gains a
|
||||
container-env section: detect `NC_CONTAINER=1`/`/.dockerenv` → print
|
||||
volume + privileged/seccomp facts (state at
|
||||
`/home/nextcraft/.nextcraft` on the `nc-state` volume; userns verdict from
|
||||
R-27's probe) and the docker-channel upgrade path (R-31). deb detection:
|
||||
`dpkg -s nextcraft` or binary path `/usr/bin/nextcraft` → print
|
||||
deb-channel facts. `bootstrap`/`deploy`/`dev` refuse in-container
|
||||
(D-066): each command already starts with repo-root checks
|
||||
(deploy.ts:94-131, dev.ts:9-14) — add a container check BEFORE them,
|
||||
exit 1 with the pointer text (`docker pull + recreate` per D-071).
|
||||
`guide` = offline-first embedded text (D-072): man-page-style sections
|
||||
checked into `apps/cli/src/guide/` as TS string constants (mirrored from
|
||||
deploy/README.md at build time — a build step or a copy discipline; the SEA
|
||||
binary embeds whatever esbuild bundles, so the text lands in the binary for
|
||||
free), channel-aware section selection at runtime, forge URLs printed as
|
||||
pointers. `doctor`/`verify`/`guide` remain the allowed in-container
|
||||
commands (D-066) — `verify` needs a container-mode variant (its venv/
|
||||
export checks assume a repo clone, verify.ts:7-14 — in-container it should
|
||||
check the mounted web dir + `/health` instead).
|
||||
|
||||
**R-31. Channel upgrade semantics (D-071, to be tabulated in the admin
|
||||
guide).** docker: `docker pull git.coreci.dev/coreci/nextcraft:<tag>` +
|
||||
`docker rm -f` + recreate with the same volume (state persists;
|
||||
`deploy`/`bootstrap` never run in-container). deb: `dpkg -i
|
||||
nextcraft_<ver>_<arch>.deb` + `nextcraft deploy --service` (idempotent
|
||||
clone-update→build→restart→verify, v0.4.6-proven). binary: re-run
|
||||
install.sh one-liner (fetches latest release binary, checksum+version
|
||||
gates) + `nextcraft deploy --service`. doctor/guide describe the detected
|
||||
channel's path. All three keep the `~/.nextcraft` state layout compatible
|
||||
(deploy/README.md:85-94 table) — the docker channel relocates it into the
|
||||
named volume (R-17).
|
||||
|
||||
---
|
||||
|
||||
## R-32 — Ecosystem/prior-art scan (question 10, brief)
|
||||
|
||||
Comparable "single binary + web + API monorepo" distributions:
|
||||
**Ollama** (single static binary + docker image; `curl | sh` installer that
|
||||
already does arch-resolve x64/arm64 — steal the installer UX: detect
|
||||
`uname -m`, pick the asset, verify, PATH hint — exactly our install.sh
|
||||
shape), **Tailscale** (static binary + per-platform packages + apt repo;
|
||||
their docs structure "Requirements → Install → Verify → Upgrade →
|
||||
Troubleshoot" per channel is the admin-guide skeleton), **Gitea itself**
|
||||
(docker image + binary releases + Actions runner: the upstream we're
|
||||
consuming — its docs' "Volumes" and cache sections shaped R-5/R-6),
|
||||
**act_runner** (docker + binary dual distribution), **Miniflux** (single
|
||||
Go binary + minimal docker image + deb in distro repos — closest analog to
|
||||
"CLI binary + slim runtime image"). Takeaways for the admin guide
|
||||
structure: (1) per-channel chapters each ending in the SAME verification
|
||||
checklist (`/health` 200, `/` HTML, `nextcraft doctor` clean — the
|
||||
deploy/README.md verification checklist already exists, extend it);
|
||||
(2) an upgrade matrix table (channel × step × idempotency note) instead of
|
||||
prose; (3) troubleshooting tables with symptom/cause/fix rows
|
||||
(apps/cli/README.md + install.sh degradation strings are the house style);
|
||||
(4) a "where state lives" table per channel (deploy/README.md:85-94 is
|
||||
the precedent — add the docker volume row); (5) deb users expect a
|
||||
`/usr/share/doc` README + copyright — cheap Debian-politeness win.
|
||||
|
||||
---
|
||||
|
||||
## Open risks (tracked into PLAN)
|
||||
|
||||
1. **SEA blob cross-arch** (R-26): probe-first in P3; fallback keeps the
|
||||
heavy build native and emulates only blob generation via binfmt.
|
||||
Confidence 0.7.
|
||||
2. **corepack on node:24-bookworm** (R-11): Node ≥25 dropped corepack;
|
||||
24.x still ships it. P1 probe; `npm i -g pnpm@12.3.4` fallback.
|
||||
3. **Cache-server reachability** from job containers on dockerized runners
|
||||
(R-5/R-12): needs `cache.host` + published ports live-verified on CT 113;
|
||||
volume-mounted pnpm store is the deterministic fallback.
|
||||
4. **actions/checkout needs CT internet** (R-8): DEFAULT_ACTIONS_URL=github;
|
||||
fallback = instance-level `self` + mirror, or raw git steps. Probe CT
|
||||
113 egress in P1.
|
||||
5. **`docker.io` apt package** on the release lane (R-20): bookworm main
|
||||
ships it (engine+cli); if the pull is too heavy, pre-bake a
|
||||
`git.coreci.dev/coreci/runner-release` image later — out of v0.8 scope.
|
||||
6. **Token handling**: registration token founder-provided in chat only,
|
||||
reset after fleet green (R-2/R-6); REGISTRY_TOKEN self-serve PUT first,
|
||||
founder UI fallback (R-13). GITEA_TOKEN job token can't push packages —
|
||||
upstream-confirmed, not a bug.
|
||||
7. **Sibling-mode shared-daemon blast radius** (R-21): org-scoped private
|
||||
repos only; accepted-risk row in the guide.
|
||||
8. **deb Version policy**: strip the leading `v` (upstream-version must not
|
||||
start with `v`) — a `--nocheck` escape hatch exists but staying policy-
|
||||
clean is free.
|
||||
9. **Runner 3.x config drift**: config keys cited here (cache.host,
|
||||
bind_workdir, health_check) are runner 3.x — verify the deployed 3.x
|
||||
minor accepts them at P1 bring-up (generate-config is the authority).
|
||||
10. **In-container `verify`** semantics (R-30): repo-clone assumptions need
|
||||
a container-mode branch — P4 must cover it with a test, not prose.
|
||||
+109
-118
@@ -2,32 +2,82 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone v0.3 — COMPLETE (shipped as v0.2.8, 2026-09-12).** Next milestone: v0.4 (real server STT/TTS + KYC/identity + design/simulation sandbox environments + exec-telemetry seq-lease — the deferred seams named in the v0.2.8 release note).
|
||||
**Milestone v0.7 — Test Harness Review (COMPLETE, shipped as v0.6.6, 2026-09-27).** NFR refinement-only milestone reviewing the entire test harness: coverage/timeouts/markers wired, trivial tests cut with recorded triage, duplicated infra consolidated, 1,716 warnings/run eliminated to zero, ESLint+RUF100+mypy toolchain completed, recharts 3. Founder scope decisions D-048..D-051; clarify D-052..D-061; grill G-101..G-110. Tags on the v0.6.x line (v0.6.0 phase 0 → v0.6.6 final = milestone release); branch `milestone/v0.7-test-harness-review`.
|
||||
|
||||
**Milestone v0.3** — Credential Engines: Replace v0.2's mock engine inputs with real credential engines. Build the sandbox fabric (sandboxed IDE / design tool / simulation), the live in-environment build-telemetry pipeline, the process-trace grading engine, per-learner variant task generation, and the oral/voice defense with AI examiner. Lab/Assessor/Proctor agents move from mock inputs to real engine inputs.
|
||||
**Milestone v0.6 — Marketplace Engine (COMPLETE, shipped as v0.5.6, 2026-09-26).** The real marketplace backend behind the existing surfaces: job posting backend + aggregation adapter (REQ-6-001), AI tagging (REQ-6-002), semantic vector search + match scoring (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). Scope decision D-047 (auto-decided at SPECIFY, full autonomy). Tags on the v0.5.x line (v0.5.0 phase 0 → v0.5.N final = milestone release); branch `milestone/v0.6-marketplace-engine`.
|
||||
|
||||
**Deferred per founder directive:** REQ-F-017 identity verification + age-gating (real KYC backend) is deferred beyond v0.3. Age-gating remains the v0.1 visual flow mockup.
|
||||
**Hotfix v0.4.7 (2026-09-21) — pre-public hardening.** The 4 P1 flags from the v0.5 final review closed: HMAC ingest tokens on the WS grading input (LAN poisoning closed), nonce+counter defense-id mint (same-ms collision closed), browser-mode client SR/TTS per the descriptor contract (gap closed), 8 browser-voice module pins. Deployed via `nextcraft deploy --service`; site 200, service enabled.
|
||||
|
||||
**Hotfix v0.4.6 (2026-09-21) — site-down + deploy experience.** The domain root (nextcraft.coreci.dev) served the API's JSON 404 for three releases: the deployed service ran mount-less (web export never built on the host; dev -d auto-wiring silently skipped; nothing detected it). Fixed the class: `nextcraft deploy` (one-command clone/update → install → venv → export build → restart → verify), `deploy --service` (generated systemd unit, reboot-persistent), toolchain discovery (nvm/PNPM_HOME/corepack, incl. split installs — the host's exact layout), loud mount-less boot warning, verify live mount probe. Deployed live on the host via the new command; site 200, service enabled.
|
||||
|
||||
**Hotfix v0.3.5/v0.3.6 (2026-09-13) — fresh-box + single-port unattended deploy.**
|
||||
|
||||
**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/`.
|
||||
|
||||
**Milestone v0.3** — Credential Engines: complete, shipped as v0.2.8 (2026-09-12). Real sandbox fabric, live build telemetry, process-trace grading, per-learner variants, oral defense, real learner surfaces.
|
||||
|
||||
**Deferred per founder directive (D-016):** REQ-F-017 identity verification + age-gating (real KYC backend), real server STT/TTS, design/simulation sandbox environments, and the exec-telemetry seq-lease are deferred to v0.5. Age-gating remains the v0.1 visual flow mockup.
|
||||
|
||||
**Prior milestone:** v0.2 (ai-tutor-architecture) — complete, shipped as v0.2.0, six tutor agents live over mock engine inputs (D-015).
|
||||
|
||||
**Milestone type:** Feature (new credential-engine services + real agent inputs)
|
||||
**Tag line:** v0.2.x (patches on the v0.2 line; milestone release as the final v0.2.x patch)
|
||||
**Branch:** milestone/v0.3-credential-engines
|
||||
**Milestone type:** Feature (voice provider + identity backend + new env types + ingest protocol fix)
|
||||
**Tag line:** v0.4.x (patches on the v0.4 line; milestone release as the final v0.4.x patch)
|
||||
**Branch:** milestone/v0.5-real-voice-identity-envs
|
||||
|
||||
---
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.8 (in progress)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 | complete | 6 | — | Code review clean; audit passes; milestone tagged (v0.2.x final patch); release created on Gitea |
|
||||
| 0 | Pre-execution | complete (v0.7.0) | — | — | Specification, clarify (D-063..D-072), research (R-1..R-32), plan (rails→artifacts→onboarding→guides), grill (G-201..G-210), MVP/UX check complete (shipped as v0.7.0, release 913) |
|
||||
| 1 | Release-consistency rails | complete (v0.7.1) | 0 | REQ-8-001 | 3 org-level runners green on CT 113 (hybrid labels, sibling mode); ci.yaml push lanes green; hello-world × 3 labels; release.yaml builds assets on tag (v0.7.1 release machine-made); pre-commit both-direction proof; secrets audit clean |
|
||||
| 2 | Docker image channel | pending | 1 | REQ-8-002 | Multi-stage image (unprivileged, /health, state volume) pushed to the Gitea registry on every phase release; honest sandbox degrade (probe + notice + 503 remediation); in-container CLI semantics; smoke gate in workflow |
|
||||
| 3 | Debian-native channel | pending | 2 | REQ-8-003 | arm64 QEMU-free cross-build; .deb x64+arm64 (advisory postinst); install.sh arch-resolve; full asset matrix (2 binaries + 2 checksums + 2 debs) on every release |
|
||||
| 4 | CLI onboarding | pending | 3 | REQ-8-004 | Channel-aware doctor; nextcraft guide offline; honest in-container refusals; docker/deb E2Es env-gated green; guide↔runbook parity test |
|
||||
| 5 | Admin guides | pending | 4 | REQ-8-005 | deploy/README.md full runbook (3 channels × install/upgrade, upgrade matrix, troubleshooting, evidence appendix); README quickstart; every recipe evidence-backed |
|
||||
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit pass; milestone tagged v0.7.6 (final v0.7.x patch = milestone release); release with full asset matrix + image tags; milestone merged to main |
|
||||
|
||||
### v0.7 (complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete | — | — | Specify, clarify (D-052..D-061), research (44-50), plan (D-062 waves), grill (G-101..G-110), MVP/UX check complete (shipped as v0.6.0) |
|
||||
| 1 | Harness wiring | complete (v0.6.1) | 0 | REQ-7-001 | Coverage in all 3 workspaces; timeouts 60s/180s probe-proven; markers fast/real_server/userns strict; web tests in turbo; leaks fixed; fast subset <60s |
|
||||
| 2 | Suite composition | complete (v0.6.2) | 1 | REQ-7-002 | 10 trivial tests cut with recorded per-assertion triage; 8 cross-boundary pins kept (CWD-independent); tests/helpers/ consolidation zero-behavior-change; droppings swept |
|
||||
| 3 | Warning elimination | complete (v0.6.3) | 2 | REQ-7-003 | session.exec ×14; ResourceWarning sites closed at source; filterwarnings=error + 2 scoped ignores; full suite ZERO warnings |
|
||||
| 4 | Lint & suppression hygiene | complete (v0.6.4) | 3 | REQ-7-004 | ESLint 6/6 workspaces; 20 violations fixed; 80 noqa removed (6 live fixed at source); dead exports gone; version single-source; engines |
|
||||
| 5 | Type & dependency hardening | complete (v0.6.5) | 4 | REQ-7-005 | mypy relaxed-first green (178 files, 27 ignores dispositioned); recharts 3.10.1 proven; transitive audit recorded |
|
||||
| 6 | Final review + ship | complete (v0.6.6) | 1-5 | — | Purity gate zero feat:; review SHIP verdict; audit PASS; milestone tagged v0.6.6; release with binary assets; milestone merged to main |
|
||||
|
||||
### v0.6 (complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | complete | — | — | Specification, clarify, research, plan, grill, MVP/UX check complete; .ciagent/ files updated for v0.6 (shipped as v0.5.0) |
|
||||
| 1 | Postings backend + aggregation adapter | complete (v0.5.1) | 0 | REQ-6-001 | Postings store + lifecycle + `/v1/marketplace/*` routes live; job board/detail/employer surfaces read real data; 18+ gates composed per D-043; seeded ingestion adapter proven |
|
||||
| 2 | AI tagging | complete (v0.5.2) | 1 | REQ-6-002 | Posting text → skills/seniority/category tags via LLM structured output (mock-first); tag facets drive search filters; tags persisted |
|
||||
| 3 | Semantic vector search | complete (v0.5.3) | 2 | REQ-6-003 | Embedding provider protocol (mock-first) + SQLite-local vectors; competency↔posting similarity ranking; match scores surface on job cards + candidate matches |
|
||||
| 4 | Enrichment + resume parsing | complete (v0.5.4) | 3 | REQ-6-004, REQ-6-005 | Employer enrichment from posting data with provenance; resume upload → profile auto-fill with learner review gate; bounded uploads |
|
||||
| 5 | SEO programmatic pages | complete (v0.5.5) | 4 | REQ-6-006 | Metadata + JSON-LD + sitemap/robots on job/stack/landing pages; static-export compatible (single-port deploy unchanged) |
|
||||
| 6 | Final review + ship | complete (v0.5.6) | 1-5 | — | Review clean; audit passes; milestone tagged (final v0.5.x patch); release with binary assets on Gitea; milestone merged to main |
|
||||
|
||||
---
|
||||
|
||||
## Phase List (v0.5 — complete)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,147 +85,88 @@
|
||||
|
||||
### Phase 0: Pre-execution
|
||||
|
||||
**Goal:** Establish v0.3 specification, clarify ambiguities, research credential-engine architecture (sandbox isolation, telemetry transport, trace grading, variant generation, voice IO), create detailed plans.
|
||||
**Goal:** Lock the v0.5 specification (four deferred seams), clarify ambiguities, research the audio endpoint contract + KYC provider landscape + env-type design + the seq-lease protocol, plan waves, grill adversarially.
|
||||
|
||||
**Stages:** SPECIFY → CLARIFY → RESEARCH → PLAN → GRILL → MVP/UX CHECK → SHIP
|
||||
|
||||
**Deliverables:**
|
||||
- Updated .ciagent/config.json, PROJECT.md, REQUIREMENTS.md, ROADMAP.md, ARCHITECTURE.md, PERSONAS.md, PLAN.md
|
||||
|
||||
**Success criteria:** All .ciagent/ files updated for v0.3; phase 0 shipped as first v0.2.x patch.
|
||||
**Success criteria:** All .ciagent/ files updated for v0.5; phase 0 shipped as v0.4.0.
|
||||
|
||||
---
|
||||
### Phase 1: Seq-Lease + Replay Margin (REQ-5-007)
|
||||
|
||||
### Phase 1: Sandbox Fabric
|
||||
|
||||
**Goal:** Provision and manage isolated per-learner execution environments.
|
||||
|
||||
**Requirements:** REQ-3-001, REQ-3-002
|
||||
**Goal:** Close the reconnect-replay ACK gap from the v0.3 P6 lesson.
|
||||
|
||||
**Key deliverables:**
|
||||
- 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 Linux user/mount/pid/net namespace subprocess isolation (`unshare`, D-024); no cross-tenant filesystem/network access
|
||||
- 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
|
||||
- Ingest WS: ack frames carrying highest-contiguous received seq per (learner, task); capture agent tracks ack and resumes spool replay from acked position (bounded replay margin)
|
||||
- Real-server regression test: mid-burst kill → reconnect → no loss/dup exactly-once within margin (deterministic — waits for server-side observation like the P07 fix)
|
||||
|
||||
**Success criteria:**
|
||||
- A sandbox can be created, written to, snapshotted, and destroyed via API
|
||||
- Isolation verified: a sandbox cannot read another learner's data
|
||||
- Resource limits enforced and observable — enforcement mechanism: rlimits (memory/CPU) + wall-clock reaper + workdir-size sweep; per-sandbox pids and hard-disk-quota are accepted v0.3 gaps (no cgroup delegation/sudo on this box, G-1)
|
||||
**Success criteria:** regression test green on repeated runs; margin documented; flood/gap semantics (G-3/G-4) unchanged.
|
||||
|
||||
---
|
||||
### Phase 2: Real Server Voice (REQ-5-001, REQ-5-002)
|
||||
|
||||
### Phase 2: Live Build Telemetry
|
||||
|
||||
**Goal:** Capture in-environment process events and stream them to ai-service reliably.
|
||||
|
||||
**Requirements:** REQ-3-003
|
||||
**Goal:** The `openai-audio` VoiceProvider against OpenAI-compatible STT/TTS endpoints; voice defense real path end-to-end.
|
||||
|
||||
**Key deliverables:**
|
||||
- Telemetry capture agent (in-sandbox): commands, file diffs, run/test results, keystroke-level/activity events
|
||||
- Telemetry transport: durable, ordered, resumable stream to ai-service ingestion endpoint
|
||||
- Trace persistence: per-learner, per-task process traces stored for grading and proctoring
|
||||
- Transport hardening: retries, backpressure, exactly-once-or-at-least-once semantics documented
|
||||
- `ai_service/voice/openai_audio.py` — provider per D-030 protocol (STT: multipart upload → transcript; TTS: text → audio bytes); httpx via the app pool; timeouts; error mapping
|
||||
- `AI_VOICE_BASE_URL`/`AI_VOICE_API_KEY`/`AI_VOICE_MODEL` settings resolution (env-only, never committed); `AI_VOICE_PROVIDER=openai-audio|browser|mock` selection in factory
|
||||
- Defense answer route: real STT path (audio upload transcribed server-side when provider=openai-audio; browser/mock paths unchanged); examiner TTS question audio
|
||||
- Tests: MockTransport byte-contract tests (multipart shape, response parse, error modes); defense-flow tests stay mock-only (never call the cloud)
|
||||
|
||||
**Success criteria:**
|
||||
- Sandbox activity produces a complete ordered process trace in ai-service
|
||||
- Stream survives transient network failure without trace loss
|
||||
- Trace retrievable by learner+task ID for grading
|
||||
**Success criteria:** provider contract pinned by tests; voice defense works against a scripted audio endpoint; `pnpm ai:test` green; manual cloud probe documented.
|
||||
|
||||
---
|
||||
### Phase 3: Identity + Age-Gating (REQ-5-003, REQ-5-004)
|
||||
|
||||
### Phase 3: Process-Trace Grading Engine
|
||||
|
||||
**Goal:** Grade learner artifacts from their full process traces.
|
||||
|
||||
**Requirements:** REQ-3-004
|
||||
**Goal:** Real identity verification backend behind a provider protocol; backend-enforced age gates.
|
||||
|
||||
**Key deliverables:**
|
||||
- Trace analyzer: reconstructs build/decision timeline from a process trace
|
||||
- Grading engine: rubric-aligned scoring over the trace (process quality, not just final artifact)
|
||||
- Structured score output consumable by the Assessor agent
|
||||
- Calibration against v0.2 mock corpora to validate grading dimensions
|
||||
- `ai_service/identity/` — IdentityProvider protocol (submit/poll/verify), mock provider (deterministic), store (SQLite, D-027 family), API router (`/v1/identity/*`)
|
||||
- Age-gate enforcement: school 16+ (enrollment), marketplace 18+ verified (gated routes reject under-age/unverified with 403) — dependency-injected gate, allowlist (G-5) retained as pilot guard
|
||||
- PII hygiene: document refs stored, never raw docs in logs; secrets-hygiene checklist extended
|
||||
|
||||
**Success criteria:**
|
||||
- Engine emits structured rubric-aligned scores from a real process trace
|
||||
- Scores distinguish process quality (e.g., iterative debugging vs. paste-and-run)
|
||||
- Output feeds Assessor; replaces pre-baked artifact corpus inputs
|
||||
**Success criteria:** verification flow API green under mock; gated routes enforce ages in tests; zero PII in captured logs (pinned by test).
|
||||
|
||||
---
|
||||
### Phase 4: Design/Sim Environments (REQ-5-005, REQ-5-006)
|
||||
|
||||
### Phase 4: Variant Task Generation
|
||||
|
||||
**Goal:** Generate per-learner task variants so no two learners receive identical prompts.
|
||||
|
||||
**Requirements:** REQ-3-005
|
||||
**Goal:** Environment kinds beyond the coding IDE on the existing namespace fabric.
|
||||
|
||||
**Key deliverables:**
|
||||
- Variant generator: parameterized task templates → unique per-learner instances
|
||||
- Variant seed registry: record variant parameters for grading fairness and proctoring
|
||||
- Difficulty normalization: variants calibrated to equivalent difficulty
|
||||
- Environment-type registry in the sandbox fabric: `build` (existing), `design` (canvas/file artifacts), `simulation` (run/benchmark harness) — per-type starter contents + allowed exec commands, one lifecycle, one telemetry path
|
||||
- Learner surface + engine-client accept environment kind; telemetry events carry kind; grading digest stays kind-agnostic (D-028 unchanged)
|
||||
|
||||
**Success criteria:**
|
||||
- Two learners requesting the same competency receive distinct task variants
|
||||
- Variant parameters persisted and auditable
|
||||
- Grading engine scores variants equitably
|
||||
**Success criteria:** all three kinds provisionable + provable isolation; per-kind starter files land; kind flows through telemetry to the digest.
|
||||
|
||||
### Phase 5: Final Review + Ship
|
||||
|
||||
**Goal:** Code review, audit, milestone release with binary assets.
|
||||
|
||||
**Success criteria:** review P0s fixed in-phase; audit reconstruction clean; final v0.4.x patch tagged; Gitea release with `nextcraft-linux-x64` + `.sha256`; milestone merged to main; branches deleted.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Oral / Voice Defense
|
||||
## Hotfixes (v0.4.x line, post-v0.5)
|
||||
|
||||
**Goal:** AI examiner conducts a spoken defense of the learner's submitted work.
|
||||
| Tag | Date | Summary |
|
||||
|-----|------|---------|
|
||||
| v0.4.7 | 2026-09-21 | Pre-public hardening (the 4 P1 flags from the v0.5 final review): HMAC ingest tokens on the WS grading input (LAN trace-poisoning closed, per-boot secret, manager mints / endpoint verifies), nonce+counter defense-id mint (same-ms PK collision closed), browser-mode client SR/TTS implemented per the descriptor contract (the v0.5 gap), 8 browser-voice module pins. 488 backend (+10), web 22/22 (+8), CLI 61/61. Deployed via `nextcraft deploy --service`; site 200, service enabled. |
|
||||
| v0.4.6 | 2026-09-21 | Site-down hotfix + one-command deploy: `nextcraft deploy [--service]` (clone/update → install → venv → export build → restart → verify), systemd unit generation (reboot persistence), toolchain discovery (nvm/PNPM_HOME/corepack + split installs), loud mount-less boot, verify live mount probe, subcommand flag dispatch. Deployed live via the command itself; nextcraft.coreci.dev → 200 HTML. |
|
||||
|
||||
**Requirements:** REQ-3-006
|
||||
## v0.7 (Complete — Shipped as v0.6.6)
|
||||
|
||||
**Key deliverables:**
|
||||
- Voice pipeline: STT → defense dialogue (LLM examiner) → TTS
|
||||
- Examiner agent: probes understanding, challenges process choices from the trace
|
||||
- Transcript + integrity signals captured for Proctor/Mentor
|
||||
- Latency budget: defense feels conversational (bounded turn latency)
|
||||
Test Harness Review (NFR refinement-only, founder directive post-v0.6): coverage measured in all three workspaces (pytest-cov 95% branch, c8 web 95.9%/cli 76.9%); pytest-timeout safety net (60s/180s dual-tier, signal method, both probe-proven); markers fast/real_server/userns under --strict-markers with a 620-test fast subset completing in ~56s; web tests inside the turbo pipeline; CLI E2E env-gated. Suite composition: 10 trivial tests cut with per-assertion recorded triage (D-057), 8 cross-boundary/regression pins kept in CWD-independent pins.test.ts; duplicated harness infra consolidated into tests/helpers/ (KillableProxy split into two semantics-distinct helpers per G-101); deterministic waits replace sleeps where hooks exist; sandbox droppings swept + session-end fixtures. Warning elimination: 1,716 warnings/run → ZERO (14 session.query→session.exec sites, 14 ResourceWarning sites closed at source, filterwarnings=error with exactly 2 scoped upstream ignores). Lint hygiene: ESLint live in all 6 workspaces (was none), 20 violations fixed, 80 inert noqa removed (6 live ones fixed at source), RUF100 enforced, dead exports gone, version single-sourced 0.6.5, engines everywhere. Type & dependency hardening: mypy 2.3.1 relaxed-first green over 178 files (215 errors found: ~140 fixed at source incl. 8+ genuine bugs, 34 scoped suppressions; all 27 original ignores dispositioned); recharts 2.15.4→3.10.1 (deprecated direct dep resolved); transitive audit recorded (4 dev-only remain, none actionable). 7 phases (P0-P6). All 5 requirements (REQ-7-001..005) complete. Tags v0.6.0-v0.6.5 per phase, milestone release v0.6.6.
|
||||
|
||||
**Success criteria:**
|
||||
- A spoken defense runs end-to-end (speak → examiner question → learner response → verdict)
|
||||
- Transcript + integrity signals persisted and consumable by Proctor
|
||||
- Turn latency within the documented budget
|
||||
## v0.6 (Complete — Shipped as v0.5.6)
|
||||
|
||||
---
|
||||
Real backend behind the marketplace surfaces: postings store + aggregation adapter + marketplace API (REQ-6-001), AI tagging (REQ-6-002), embedding-backed semantic search + match scores (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). 7 phases (P0–P6), all 6 requirements complete. Tags v0.5.0–v0.5.5 per phase, milestone release v0.5.6. Final review CLEAN (all G-101..G-110 honored, MH-M1..M6 met); audit PASS.
|
||||
|
||||
### Phase 6: Agent Re-grounding + Learner Surface Integration
|
||||
## v0.5 (Complete — Shipped as v0.4.5)
|
||||
|
||||
**Goal:** Move Lab/Assessor/Proctor to real engine inputs; wire learner surface to the real engines.
|
||||
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.
|
||||
|
||||
**Requirements:** REQ-3-007, REQ-3-008
|
||||
## v0.4 (Complete — Shipped as v0.3.4)
|
||||
|
||||
**Key deliverables:**
|
||||
- Lab agent consumes live sandbox telemetry (replaces v0.2 mock telemetry)
|
||||
- Assessor agent consumes grading-engine output (replaces pre-baked artifacts)
|
||||
- Proctor consumes telemetry + defense integrity signals (replaces mock telemetry)
|
||||
- Learner sandbox mockup → real in-browser build/run; assessment mockup → live defense + live grading
|
||||
|
||||
**Success criteria:**
|
||||
- Lab/Assessor/Proctor operate on real inputs with no mock fallback in the learner path
|
||||
- Learner can build in-browser and see live telemetry + live feedback
|
||||
- Assessment surface runs a live defense and shows live grading
|
||||
- `pnpm build` and `pnpm typecheck` pass
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Final Review + Ship
|
||||
|
||||
**Goal:** Code review, audit, milestone release.
|
||||
|
||||
**Key deliverables:**
|
||||
- Multi-persona code review (correctness, testing, security, performance, maintainability)
|
||||
- Project health audit (reconstruction test, .ciagent/ file discipline, branch hygiene, commit discipline)
|
||||
- Milestone ship: merge milestone → main, tag final v0.2.x patch, create Gitea release
|
||||
|
||||
**Success criteria:**
|
||||
- Code review: P0 fixes applied, P1+ documented
|
||||
- Audit: all checks pass, project state reconstructable from git log
|
||||
- Ship: milestone tagged, branch merged to main, Gitea release created — release note states identity/age-gating (KYC) is deferred and age-gating remains a visual mockup
|
||||
- All v0.3 requirements marked complete
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## v0.3 (Complete — Shipped as v0.2.8)
|
||||
|
||||
@@ -192,4 +183,4 @@ AI Tutor Architecture: Six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor
|
||||
|
||||
## v0.1 (Complete — Shipped as v0.1.0)
|
||||
|
||||
UI/UX Prototype: High-fidelity interactive prototype of all four Nextcraft surfaces. 7 phases (P0 + P1-P6 execution + P7 final). All 28 requirements complete. Tags v0.0.1–v0.0.7, milestone release v0.1.0.
|
||||
UI/UX Prototype: High-fidelity interactive prototype of all four Nextcraft surfaces. 7 phases (P0 + P1-P6 execution + P7 final). All 28 requirements complete. Tags v0.0.1–v0.0.7, milestone release v0.1.0.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"autonomy": {
|
||||
"level": "full",
|
||||
"decision_confidence_threshold": 0.6,
|
||||
@@ -46,9 +46,9 @@
|
||||
"projects": [],
|
||||
"active_project": null,
|
||||
"milestone": {
|
||||
"version": "v0.3",
|
||||
"name": "credential-engines",
|
||||
"version": "v0.8",
|
||||
"name": "ship-anywhere",
|
||||
"type": "feature",
|
||||
"branch": "milestone/v0.3-credential-engines"
|
||||
"branch": "milestone/v0.8-ship-anywhere"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Build context hygiene (v0.8 P2): secrets and runtime droppings never
|
||||
# enter an image layer. The .env* exclusion is the hard rule — the v1.8
|
||||
# class of leak (tokens) must be structurally impossible here.
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
!.env.example
|
||||
.ciagent
|
||||
**/.ciagent
|
||||
**/.venv
|
||||
**/node_modules
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
**/.turbo
|
||||
**/coverage
|
||||
**/dist
|
||||
apps/web/out
|
||||
apps/web/.next
|
||||
**/*.tsbuildinfo
|
||||
.turbo
|
||||
node_modules
|
||||
*.log
|
||||
@@ -0,0 +1,130 @@
|
||||
# Push lanes — every branch, every push (REQ-8-001, D-063).
|
||||
#
|
||||
# Two jobs, two runner labels, NEVER wholesale `turbo lint`/`turbo typecheck`
|
||||
# in the node lane (RESEARCH R-8): those turbo tasks pull in
|
||||
# @nextcraft/ai-service#lint/typecheck, which hard-fail without the Python
|
||||
# venv (apps/ai-service/scripts/lint.sh:8, typecheck.sh:13) — a venv that
|
||||
# does not exist in the node:24-bookworm job container. The node lane runs
|
||||
# lint/typecheck via `pnpm --filter` over the 5 TS workspaces only; the
|
||||
# python lane invokes the three ai-service bash scripts directly (no pnpm
|
||||
# needed in that container at all).
|
||||
#
|
||||
# Concurrency: serialize per-ref so a rapid-fire branch push reuses one lane
|
||||
# instead of stacking jobs on the capacity-1 runners (R-4).
|
||||
name: CI
|
||||
|
||||
on: push
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
node:
|
||||
# Fleet label `node` -> container docker.io/library/node:24-bookworm
|
||||
# (deploy/ci/docker-compose.yaml, R-3 label map).
|
||||
runs-on: node
|
||||
steps:
|
||||
# actions/checkout@v4 downloads from github.com (DEFAULT_ACTIONS_URL
|
||||
# default). github.com egress is verified reachable from the LAN;
|
||||
# the CT-local probe is still pending (Wave 1-1 blocked on founder
|
||||
# deploy-key install). If checkout fails at the first real run, the
|
||||
# fallback is raw git fetch steps using the automatic job token:
|
||||
# - run: |
|
||||
# git init -q .
|
||||
# git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# git fetch --depth=1 origin "$GITHUB_SHA"
|
||||
# git checkout FETCH_HEAD
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Optional pnpm store cache (R-12). Depends on the runner cache server
|
||||
# being reachable from job containers (R-5: cache.host = CT IP + a
|
||||
# published port — configured in deploy/ci/). If the cache server
|
||||
# ETIMEDOUTs on first real runs, drop this block (fallback: a
|
||||
# volume-mounted pnpm store per R-12, or no cache at all — install is
|
||||
# affordable on the LAN). continue-on-error keeps cache flakiness
|
||||
# from ever turning the lane red; repeated timeouts = delete.
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-${{ runner.os }}-
|
||||
continue-on-error: true
|
||||
|
||||
# pnpm 12.3.4 is pinned by root package.json `packageManager`.
|
||||
# node:24-bookworm still bundles corepack (24.x retains it, deprecated
|
||||
# — Node >= 25 dropped it; R-11). Fallback if corepack is absent:
|
||||
# npm i -g pnpm@12.3.4
|
||||
- name: Enable pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Lint: per-workspace filters over the 5 TS workspaces ONLY — never
|
||||
# `turbo lint` (see header note).
|
||||
- name: Lint (TS workspaces)
|
||||
run: >-
|
||||
pnpm --filter web --filter @nextcraft/cli --filter @nextcraft/ui
|
||||
--filter @nextcraft/types --filter @nextcraft/mock-data lint
|
||||
|
||||
# Typecheck: same filter set, per-workspace tsc --noEmit.
|
||||
- name: Typecheck (TS workspaces)
|
||||
run: >-
|
||||
pnpm --filter web --filter @nextcraft/cli --filter @nextcraft/ui
|
||||
--filter @nextcraft/types --filter @nextcraft/mock-data typecheck
|
||||
|
||||
- name: Web tests (node:test via tsx)
|
||||
run: pnpm web:test
|
||||
|
||||
# CLI default test script (tsx --test tests/*.test.ts) excludes the
|
||||
# env-gated E2E (apps/cli/package.json `test:e2e`, CLI_E2E=1) — the
|
||||
# fresh-clone E2E stays out of job containers per D-063.
|
||||
- name: CLI tests (node:test via tsx, E2E env-gated out)
|
||||
run: pnpm cli:test
|
||||
|
||||
python:
|
||||
# Fleet label `python` -> container docker.io/library/python:3.11-bookworm
|
||||
# (R-3). The three scripts below are self-contained venv-relative bash
|
||||
# (R-9) — no pnpm, no node, no turbo in this lane.
|
||||
#
|
||||
# P1 CI finding: actions/checkout@v4 is a NODE-based action — it cannot
|
||||
# exec in the python:3.11-bookworm container (exit 127: "node" not in
|
||||
# $PATH). This lane checks out via raw git steps with the automatic job
|
||||
# token instead (the R-8 fallback path, promoted to primary here).
|
||||
runs-on: python
|
||||
steps:
|
||||
- name: Checkout (raw git — node actions cannot run in this container)
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
git fetch --depth=1 origin "$GITHUB_SHA"
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
# NOTE: no actions/cache here — it is a node-based action and this
|
||||
# container has no node (exit 127; same class as the checkout finding).
|
||||
# The venv rebuild is ~25s warm — acceptable without cache; pip's own
|
||||
# HTTP cache is cold but the LAN is fast. Revisit only if the lane
|
||||
# slows materially (volume-mounted store per R-12, or a pip download
|
||||
# cache directory via container options).
|
||||
|
||||
# bootstrap.sh is idempotent: creates .venv (with the --without-pip +
|
||||
# get-pip fallback for Debian-without-ensurepip) and pip-installs
|
||||
# the project with [dev] extras (ruff, mypy, pytest).
|
||||
- name: Bootstrap ai-service venv
|
||||
run: bash apps/ai-service/scripts/bootstrap.sh
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: bash apps/ai-service/scripts/lint.sh
|
||||
|
||||
- name: Typecheck (mypy, D-058 baseline)
|
||||
run: bash apps/ai-service/scripts/typecheck.sh
|
||||
|
||||
# Fast pytest lane per D-063: -m "not real_server and not userns"
|
||||
# (scripts/test.sh FAST=1). real_server/userns tiers stay local-only
|
||||
# — job containers genuinely cannot run them (docker default seccomp
|
||||
# denies the nested `unshare --user` the backend needs; R-8).
|
||||
- name: Tests (fast lane)
|
||||
run: FAST=1 bash apps/ai-service/scripts/test.sh
|
||||
@@ -0,0 +1,28 @@
|
||||
# Fleet proof (REQ-8-001, MH-1c): workflow_dispatch one-step echo job per
|
||||
# fleet label. Dispatching this workflow proves all three runners
|
||||
# register, pull their job image, and execute a job. Observed green in the
|
||||
# Actions tab = the runner fleet is verified end-to-end.
|
||||
#
|
||||
# Labels (R-3 fleet map, deploy/ci/docker-compose.yaml):
|
||||
# node -> node:24-bookworm (nc-runner-node)
|
||||
# ubuntu-latest -> runner-images:ubuntu-latest (nc-runner-node, hybrid)
|
||||
# python -> python:3.11-bookworm (nc-runner-python)
|
||||
name: Hello fleet
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
node:
|
||||
runs-on: node
|
||||
steps:
|
||||
- run: echo "hello from node"
|
||||
|
||||
python:
|
||||
runs-on: python
|
||||
steps:
|
||||
- run: echo "hello from python"
|
||||
|
||||
ubuntu-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "hello from ubuntu-latest"
|
||||
@@ -0,0 +1,220 @@
|
||||
# Release lane — tag push only (REQ-8-001, D-063, R-14/R-15).
|
||||
#
|
||||
# Fires on `v*` tag pushes. Builds the x64 SEA binary stamped with the tag,
|
||||
# generates the sha256 sidecar, and attaches both to the tag's Gitea
|
||||
# release via the REST API (the same mechanics as scripts/release-assets.sh
|
||||
# — that script stays untouched as the MANUAL fallback with its
|
||||
# .env-only token resolution, per D-064/R-14).
|
||||
#
|
||||
# Within-phase proof (G-203): at verify time push a temporary
|
||||
# `v0.7.1-rc1`-style tag to the phase-branch head, observe this workflow
|
||||
# build + attach green (release immediately deleted or pre-release), then
|
||||
# delete the tag. The phase ship tag remains the real artifact.
|
||||
#
|
||||
# Token precedence (R-13): the automatic job token (auto-provided as the
|
||||
# GITHUB_TOKEN env / `${{ github.token }}` / `${{ gitea.token }}`) HAS
|
||||
# releases scope and can create releases + attach assets, but it CANNOT
|
||||
# publish packages (upstream-confirmed). The repo secret GITEA_TOKEN (a
|
||||
# founder-set PAT), when present, WINS (lowest level wins). This workflow
|
||||
# uses `${{ secrets.GITEA_TOKEN }}` when set, else the automatic token.
|
||||
#
|
||||
# Extension points (P2/P3, G-202: this workflow is the shared rail):
|
||||
# [P2] after "Build binary": docker build + login + push to
|
||||
# git.coreci.dev/coreci/nextcraft:$TAG + :latest with
|
||||
# secrets.REGISTRY_TOKEN, plus the five-assertion smoke gate (G-204).
|
||||
# [P3] arm64 cross-build (G-201 ladder) + both .deb packages (R-22/R-23)
|
||||
# join the attach step below — full asset matrix per D-064.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Fleet label `release` -> container node:24-bookworm (R-3: node +
|
||||
# dpkg-deb + git + curl; the deb toolchain ships with bookworm).
|
||||
runs-on: release
|
||||
env:
|
||||
# Token precedence per R-13: repo secret GITEA_TOKEN (PAT) wins when
|
||||
# present; else the automatic job token (releases scope is enough for
|
||||
# release-id resolution + asset upload — R-14). Never echo this.
|
||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# P2: the release lane builds/pushes the docker image. The job
|
||||
# container (node:24-bookworm) has no docker CLI and no daemon — the
|
||||
# host daemon is bind-mounted into this job via the runner's
|
||||
# container.options (deploy/ci/config-runner-release.yaml, sibling
|
||||
# mode); only the CLI is missing. Bookworm main ships docker.io
|
||||
# (engine+cli — the engine stays dormant; the socket is the host's).
|
||||
- name: Install docker CLI (host daemon is socket-mounted)
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq docker.io >/dev/null
|
||||
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}}'
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# build:binary stamps NEXTCRAFT_VERSION_STAMP from the tag argument
|
||||
# (build-binary.mjs validates it; `--version` reports it — G-102).
|
||||
- name: Build x64 binary (SEA)
|
||||
run: pnpm --filter @nextcraft/cli build:binary ${{ gitea.ref_name }}
|
||||
|
||||
- name: Verify artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BIN="apps/cli/dist/nextcraft-linux-x64"
|
||||
SUM="$BIN.sha256"
|
||||
[ -f "$BIN" ] || { echo "missing $BIN"; exit 1; }
|
||||
[ -f "$SUM" ] || { echo "missing $SUM"; exit 1; }
|
||||
# The sidecar names the bare file (build-binary.mjs) — verify from
|
||||
# the dist dir so sha256sum -c resolves it (first CI run caught the
|
||||
# repo-root CWD mismatch).
|
||||
(cd "$(dirname "$BIN")" && sha256sum -c "$(basename "$SUM")")
|
||||
echo "artifact size: $(stat -c%s "$BIN") bytes"
|
||||
|
||||
# Resolve the release id for the tag (the release is auto-created by
|
||||
# Gitea on tag push if the repo setting is on; if not, create it
|
||||
# first). Same call as scripts/release-assets.sh:32-49 (R-14).
|
||||
- name: Resolve release id
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
BASE="https://git.coreci.dev/api/v1/repos/coreci/nextcraft"
|
||||
RESPONSE="$(curl -sS -w '\n%{http_code}' \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
"$BASE/releases/tags/$TAG")"
|
||||
BODY="$(echo "$RESPONSE" | sed '$d')"
|
||||
CODE="$(echo "$RESPONSE" | tail -1)"
|
||||
if [ "$CODE" = "404" ]; then
|
||||
echo "release for $TAG not found — creating it"
|
||||
BODY="$(curl -sS -f -X POST \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\"}" \
|
||||
"$BASE/releases")"
|
||||
else
|
||||
echo "$CODE" | grep -q '^2' || { echo "release lookup failed: $CODE"; echo "$BODY"; exit 1; }
|
||||
fi
|
||||
RELEASE_ID="$(echo "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||
echo "release id: $RELEASE_ID"
|
||||
echo "RELEASE_ID=$RELEASE_ID" >> "$GITHUB_ENV"
|
||||
|
||||
# Attach binary + checksum. actions/upload-artifact is a DIFFERENT
|
||||
# mechanism (job artifacts via the cache server, not release
|
||||
# downloads) — do NOT use it here (R-14). curl multipart is the API.
|
||||
- name: Attach binary + sha256 to release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="https://git.coreci.dev/api/v1/repos/coreci/nextcraft"
|
||||
upload() {
|
||||
local file="$1" name="$2"
|
||||
curl -sS -f -X POST \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-F "attachment=@$file" \
|
||||
"$BASE/releases/$RELEASE_ID/assets?name=$name" >/dev/null
|
||||
echo "attached $name"
|
||||
}
|
||||
upload "apps/cli/dist/nextcraft-linux-x64" "nextcraft-linux-x64"
|
||||
upload "apps/cli/dist/nextcraft-linux-x64.sha256" "nextcraft-linux-x64.sha256"
|
||||
|
||||
# -- P2 extension (REQ-8-002, R-20, D-065): build + push the docker
|
||||
# image to the Gitea registry on every phase release ----------------
|
||||
# The job token cannot publish packages (R-13, upstream-confirmed);
|
||||
# REGISTRY_TOKEN (repo Actions secret, self-served at P1: G-205) is
|
||||
# the PAT with package-write.
|
||||
- name: Build docker image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
docker build \
|
||||
--build-arg NEXTCRAFT_VERSION="$TAG" \
|
||||
-t "git.coreci.dev/coreci/nextcraft:$TAG" \
|
||||
-t "git.coreci.dev/coreci/nextcraft:latest" \
|
||||
.
|
||||
|
||||
- name: Push image to the Gitea registry
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.coreci.dev \
|
||||
-u coreci --password-stdin
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
docker push "git.coreci.dev/coreci/nextcraft:$TAG"
|
||||
docker push "git.coreci.dev/coreci/nextcraft:latest"
|
||||
|
||||
# Smoke gate (G-204, five pinned assertions): a red image = red
|
||||
# release. Probes run via docker exec INSIDE the container — the
|
||||
# job container's 127.0.0.1 is not the host, and the smoke container
|
||||
# publishes no ports (the admin-facing port map is the admin's call,
|
||||
# not the gate's).
|
||||
- name: Smoke gate — unprivileged run proves the honest degrade
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
CID=""
|
||||
cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
CID="$(docker run -d "git.coreci.dev/coreci/nextcraft:$TAG")"
|
||||
echo "container $CID up"
|
||||
|
||||
# 1. /health 200 within the window (in-container urllib — slim
|
||||
# has no curl; the HEALTHCHECK does the same)
|
||||
ok=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$CID" python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=3).status == 200 else 1)" 2>/dev/null; then ok=1; break; fi
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$ok" ] || { echo "FAIL: /health never returned 200"; docker logs "$CID" 2>&1 | tail -30; exit 1; }
|
||||
echo "assert 1/5: /health 200"
|
||||
|
||||
# 2. GET / is HTML with a real document marker (not just non-JSON
|
||||
# — the v0.4.6 lesson; G-204). One-line python: checks the
|
||||
# doctype/html prefix on the FULL lowered head, not a 6-char
|
||||
# slice (rc6 finding — the slice truncated the marker).
|
||||
docker exec "$CID" python3 -c "import urllib.request,sys; body = urllib.request.urlopen('http://127.0.0.1:8420/', timeout=5).read(); head = body.lstrip()[:32].lower(); sys.exit(0 if (head.startswith(b'<!doctype') or head.startswith(b'<html')) else 1)" \
|
||||
|| { echo "FAIL: / is not HTML"; exit 1; }
|
||||
echo "assert 2/5: / serves HTML"
|
||||
|
||||
# 3. degrade boot notice present in container logs (unprivileged
|
||||
# default → seccomp blocks userns → loud notice, D-068)
|
||||
docker logs "$CID" 2>&1 | grep -q "SANDBOX FABRIC DEGRADED" \
|
||||
|| { echo "FAIL: degrade boot notice absent"; docker logs "$CID" 2>&1 | tail -30; exit 1; }
|
||||
echo "assert 3/5: degrade notice logged"
|
||||
|
||||
# 4. fabric env-create is refused BEFORE the fabric (503 degraded
|
||||
# or 403 gate-first — both prove the degrade path short-circuits
|
||||
# learner env creation; a 201 would prove the fabric ran, which
|
||||
# an unprivileged container cannot allow). The probe python is
|
||||
# written to a file first (a yaml block scalar cannot embed
|
||||
# unindented command substitution continuations).
|
||||
cat > /tmp/smoke_probe.py << 'PYEOF'
|
||||
import urllib.request, urllib.error, json, sys
|
||||
req = urllib.request.Request('http://127.0.0.1:8420/v1/sandboxes',
|
||||
data=json.dumps({'learner_id':'smoke','task_id':'t'}).encode(),
|
||||
headers={'Content-Type':'application/json'}, method='POST')
|
||||
try:
|
||||
print(urllib.request.urlopen(req, timeout=5).status)
|
||||
except urllib.error.HTTPError as e:
|
||||
print(e.code)
|
||||
except Exception:
|
||||
print('ERR')
|
||||
sys.stdout.flush()
|
||||
PYEOF
|
||||
docker cp /tmp/smoke_probe.py "$CID":/tmp/smoke_probe.py
|
||||
STATUS="$(docker exec "$CID" python3 /tmp/smoke_probe.py)"
|
||||
[ "$STATUS" = "503" ] || [ "$STATUS" = "403" ] \
|
||||
|| { echo "FAIL: env-create was $STATUS (expected 503 degraded or 403 gate)"; exit 1; }
|
||||
echo "assert 4/5: degraded env-create refused ($STATUS)"
|
||||
|
||||
# 5. in-container CLI speaks (embedded doctor reports the docker
|
||||
# channel — D-066) + clean stop
|
||||
docker exec "$CID" nextcraft --version \
|
||||
|| { echo "FAIL: embedded CLI does not run"; exit 1; }
|
||||
echo "assert 5/5: embedded CLI alive + clean stop"
|
||||
docker rm -f "$CID" >/dev/null && CID=""
|
||||
@@ -20,6 +20,8 @@ dist/
|
||||
.next/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
# Next.js static export (v0.3.6 build output, served by the ai-service)
|
||||
apps/web/out/
|
||||
|
||||
# Storybook
|
||||
storybook-static/
|
||||
@@ -42,6 +44,7 @@ yarn-error.log*
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
.coverage
|
||||
.nyc_output/
|
||||
|
||||
# Python tooling caches
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
pnpm-approved-builds=esbuild,core-js-pure
|
||||
pnpm-approved-builds=esbuild,core-js-pure
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Pre-commit hooks (D-070) — local hygiene mirroring the CI lanes.
|
||||
#
|
||||
# Design contract (G-209): every hook is LOCAL-ONLY (no network at hook
|
||||
# time — the mirror-style hooks below are fetched once at `pre-commit
|
||||
# install`/`pre-commit run` time from github.com, which this LAN reaches;
|
||||
# the language:system hooks run zero network). Hooks FAIL LOUD when the
|
||||
# toolchain is missing (D-070: a contributor without the toolbox gets an
|
||||
# explicit pointer, not a silent skip) — the no-toolbox escape is
|
||||
# `git commit --no-verify` (or `SKIP=...`), documented in README.md with
|
||||
# the loud note that CI still gates every push.
|
||||
#
|
||||
# Scope discipline mirrors .gitea/workflows/ci.yaml: ruff only touches
|
||||
# ai-service python; eslint/tsc only touch the TS workspaces; hygiene is
|
||||
# repo-wide.
|
||||
repos:
|
||||
# ---- basic hygiene (repo-wide, zero toolchain required)
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
|
||||
# ---- ruff (ai-service python, D-063 lane parity)
|
||||
# Runs the venv-relative binary exactly like apps/ai-service/scripts/lint.sh
|
||||
# (venv is the repo's authority for the python toolchain). FAILS LOUD with
|
||||
# a bootstrap hint if the venv is missing — never a silent skip.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: ruff check (ai-service)
|
||||
entry: apps/ai-service/scripts/lint.sh
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^apps/ai-service/
|
||||
pass_filenames: false
|
||||
|
||||
# ---- eslint (TS workspaces, ONE root flat config at eslint.config.mjs)
|
||||
# Each workspace's `lint` script is `eslint .` resolving that config
|
||||
# upward, so a single root invocation is exact parity with the node
|
||||
# lane's per-workspace runs. `pnpm` resolves via PATH or corepack
|
||||
# (same discovery as nextcraft doctor); FAILS LOUD if neither exists.
|
||||
- id: eslint
|
||||
name: eslint (TS workspaces)
|
||||
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM exec eslint apps/web apps/cli packages/ui packages/types packages/mock-data'
|
||||
language: system
|
||||
files: ^(apps/web|apps/cli|packages/ui|packages/types|packages/mock-data)/.*\.(ts|tsx|mjs|cjs)$
|
||||
pass_filenames: false
|
||||
|
||||
# ---- tsc --noEmit per TS workspace (node-lane parity: pnpm --filter
|
||||
# over the same five workspaces; the tsconfig graph stays per-project)
|
||||
# (the shared PCMD discovery is repeated per hook because pre-commit
|
||||
# hooks run isolated entries)
|
||||
- id: tsc-web
|
||||
name: tsc --noEmit (web)
|
||||
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter web typecheck'
|
||||
language: system
|
||||
files: ^apps/web/.*\.tsx?$
|
||||
pass_filenames: false
|
||||
- id: tsc-cli
|
||||
name: tsc --noEmit (cli)
|
||||
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/cli typecheck'
|
||||
language: system
|
||||
files: ^apps/cli/.*\.tsx?$
|
||||
pass_filenames: false
|
||||
- id: tsc-ui
|
||||
name: tsc --noEmit (ui)
|
||||
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/ui typecheck'
|
||||
language: system
|
||||
files: ^packages/ui/.*\.tsx?$
|
||||
pass_filenames: false
|
||||
- id: tsc-types
|
||||
name: tsc --noEmit (types)
|
||||
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/types typecheck'
|
||||
language: system
|
||||
files: ^packages/types/.*\.tsx?$
|
||||
pass_filenames: false
|
||||
- id: tsc-mock-data
|
||||
name: tsc --noEmit (mock-data)
|
||||
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/mock-data typecheck'
|
||||
language: system
|
||||
files: ^packages/mock-data/.*\.tsx?$
|
||||
pass_filenames: false
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Nextcraft — single-port docker image (v0.8 P2, REQ-8-002, R-16..R-19)
|
||||
#
|
||||
# Topology: uvicorn :8420 serves BOTH the API (/v1/*, /health) and the
|
||||
# exported web UI (everything else) — the same single-port production
|
||||
# shape as `nextcraft deploy` (D-039). Deps are pre-loaded in image
|
||||
# layers; runtime is unprivileged by default (honest sandbox degrade:
|
||||
# ai_service/sandbox/capability.py probes + api/sandboxes.py 503s with
|
||||
# remediation — D-068/G-208).
|
||||
#
|
||||
# Build context is the repo root. Build args:
|
||||
# NEXTCRAFT_VERSION — stamped into the embedded CLI (`--version`)
|
||||
#
|
||||
# Stages:
|
||||
# 1. web-export — node:24-bookworm: pnpm workspace install, static
|
||||
# export (NEXT_PUBLIC_AI_SERVICE_URL=self), SEA CLI binary build
|
||||
# 2. venv — python:3.11-bookworm: production deps only (no [dev])
|
||||
# 3. runtime — python:3.11-slim-bookworm: non-root, venv + package +
|
||||
# export + CLI, /health healthcheck, state volume
|
||||
|
||||
# -- stage 1: web export + CLI binary ---------------------------------------
|
||||
FROM node:24-bookworm AS web-export
|
||||
ARG NEXTCRAFT_VERSION=0.0.0-dev
|
||||
WORKDIR /repo
|
||||
|
||||
# Toolchain: corepack ships with node 24 (deprecated but present).
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Workspace manifests first (layer-cacheable install).
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc tsconfig.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/cli/package.json apps/cli/
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/mock-data/package.json packages/mock-data/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Sources (web build needs all workspaces; CLI build needs its tree).
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
# Static export (same command as the deploy path): the export lands in
|
||||
# apps/web/out and is served same-origin by uvicorn.
|
||||
ENV NEXT_PUBLIC_AI_SERVICE_URL=self
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# Embedded CLI (D-066): doctor/verify/guide work in-container; the daemon
|
||||
# commands refuse honestly (see apps/cli/src — NC_CONTAINER detection).
|
||||
RUN pnpm --filter @nextcraft/cli build:binary "$NEXTCRAFT_VERSION"
|
||||
|
||||
# -- stage 2: python venv ----------------------------------------------------
|
||||
FROM python:3.11-bookworm AS venv
|
||||
WORKDIR /repo
|
||||
# The full source tree installs the package (bootstrap.sh's mechanism
|
||||
# without [dev]: production deps only — the image runs the app, it never
|
||||
# lints/tests it; the runner lanes own that).
|
||||
COPY apps/ai-service /repo/apps/ai-service
|
||||
RUN python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
|
||||
/opt/venv/bin/pip install --no-cache-dir /repo/apps/ai-service
|
||||
|
||||
# -- stage 3: runtime --------------------------------------------------------
|
||||
FROM python:3.11-slim-bookworm AS runtime
|
||||
WORKDIR /opt/nextcraft
|
||||
|
||||
# Non-root user; home carries the state volume (~/.nextcraft semantics —
|
||||
# config.py anchors db/sandboxes under $HOME/.nextcraft, R-17).
|
||||
RUN useradd --create-home --uid 10001 nextcraft
|
||||
|
||||
# venv from stage 2 (production deps only).
|
||||
COPY --from=venv /opt/venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
|
||||
# The installed ai_service package is IN the venv's site-packages; the
|
||||
# source tree rides along for transparency (PYTHONPATH prefers the
|
||||
# installed package).
|
||||
COPY --from=venv /repo/apps/ai-service /opt/nextcraft/ai-service-src
|
||||
RUN rm -rf /opt/nextcraft/ai-service-src/.venv
|
||||
|
||||
# Web export from stage 1.
|
||||
COPY --from=web-export /repo/apps/web/out /opt/nextcraft/web-out
|
||||
ENV AI_WEB_STATIC_DIR=/opt/nextcraft/web-out
|
||||
|
||||
# Image marker: NC_CONTAINER=1 (context for the CLI container-mode
|
||||
# branches and the degrade boot notice; the capability PROBE — not this
|
||||
# marker — decides degrade, D-068).
|
||||
ENV NC_CONTAINER=1
|
||||
|
||||
# Embedded CLI (D-066).
|
||||
COPY --from=web-export /repo/apps/cli/dist/nextcraft-linux-x64 /usr/local/bin/nextcraft
|
||||
RUN chmod 0755 /usr/local/bin/nextcraft
|
||||
|
||||
# App dir + state: db/sandboxes under /home/nextcraft/.nextcraft (R-17).
|
||||
# The state root is pre-created with correct ownership BEFORE the VOLUME
|
||||
# declaration — an anonymous volume copies the image's directory CONTENTS
|
||||
# and ownership at first use, so the app (uid 10001) can mkdir its data/
|
||||
# and sandboxes/ subdirs on a clean boot (rc5 smoke-gate finding: the
|
||||
# bare useradd home did not carry a writable .nextcraft).
|
||||
RUN mkdir -p /home/nextcraft/.nextcraft/data \
|
||||
&& chown -R nextcraft:nextcraft /opt/nextcraft /home/nextcraft/.nextcraft
|
||||
WORKDIR /opt/nextcraft/ai-service-src
|
||||
USER nextcraft
|
||||
ENV AI_HOST=0.0.0.0 AI_PORT=8420
|
||||
|
||||
EXPOSE 8420
|
||||
# slim has no curl — the healthcheck speaks python (stdlib urllib).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD ["python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=4).status == 200 else 1)"]
|
||||
|
||||
VOLUME ["/home/nextcraft/.nextcraft"]
|
||||
|
||||
CMD ["python3", "-m", "uvicorn", "ai_service.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
||||
@@ -2,8 +2,117 @@
|
||||
|
||||
AI-native outcome school + marketplace — graduates prove what they can build, not what they can write.
|
||||
|
||||
## Quickstart
|
||||
|
||||
One-liner install (linux x64):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.coreci.dev/coreci/nextcraft/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
That downloads the latest release's `nextcraft` CLI binary, verifies its sha256 checksum, and installs it to `~/.local/bin` (PATH hint printed if needed). Every release ships fresh binaries — re-run the one-liner to upgrade.
|
||||
|
||||
### Server (deploy the whole stack, one command)
|
||||
|
||||
```sh
|
||||
nextcraft deploy --service
|
||||
```
|
||||
|
||||
That clones (or updates) the repo, installs deps, builds the web UI, restarts the service, verifies the site is served, and installs a systemd unit so the stack survives reboots. Idempotent — same command for first install and upgrades. Toolchain discovery included: node/pnpm behind nvm/PNPM_HOME/corepack are found even when non-interactive shells can't see them.
|
||||
|
||||
### Development (from a clone of this repo)
|
||||
|
||||
```sh
|
||||
nextcraft doctor # check prerequisites: node >= 18, pnpm >= 8, python3 >= 3.11, git, unshare
|
||||
nextcraft bootstrap # pnpm install + ai-service venv + .env from template (idempotent)
|
||||
nextcraft verify # health check: venv imports, uvicorn, ports, env, web export, live mount probe
|
||||
nextcraft dev # run the ai-service dev server on :8420 (web dev server: pnpm dev)
|
||||
```
|
||||
|
||||
The E2E test (`apps/cli/tests/fresh-clone-e2e.test.ts`) proves this exact sequence on a fresh clone.
|
||||
|
||||
#### Pre-commit hooks (optional, recommended)
|
||||
|
||||
The repo ships a `.pre-commit-config.yaml` that mirrors the CI lanes locally: ruff over the ai-service python, eslint over the five TS workspaces (one root flat config), `tsc --noEmit` per workspace, plus whitespace/merge-conflict/large-file hygiene. Hooks are local-only and fail loud.
|
||||
|
||||
```sh
|
||||
pip install pre-commit # or: pipx install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
From then on every `git commit` runs the hooks on the staged changes. First run may fetch the hygiene hook repo (github.com, reachable from this LAN).
|
||||
|
||||
**No toolbox? The honest skip path:** if you don't have the ruff/eslint/tsc toolchain set up (e.g. no ai-service venv yet — run `nextcraft bootstrap` first), hooks will fail loud with a pointer. The simplest honest escape for a contributor without the toolbox is:
|
||||
|
||||
```sh
|
||||
git commit --no-verify
|
||||
```
|
||||
|
||||
or skip specific hooks for one commit:
|
||||
|
||||
```sh
|
||||
SKIP=ruff,eslint,tsc-web,tsc-cli,tsc-ui,tsc-types,tsc-mock-data git commit
|
||||
```
|
||||
|
||||
**Loud note: CI still gates.** Skipping locally changes nothing about the push lanes — `.gitea/workflows/ci.yaml` runs lint + typecheck + tests on every push to every branch. A `--no-verify` commit that breaks lint gets caught at push time, just later and more publicly.
|
||||
|
||||
### No binary / non-linux?
|
||||
|
||||
The installer degrades to printed source instructions. Manual equivalent:
|
||||
|
||||
```sh
|
||||
git clone https://git.coreci.dev/coreci/nextcraft.git && cd nextcraft
|
||||
pnpm install
|
||||
bash apps/ai-service/scripts/bootstrap.sh
|
||||
cp apps/ai-service/.env.example apps/ai-service/.env
|
||||
pnpm ai:dev
|
||||
```
|
||||
|
||||
## CLI reference (`nextcraft`)
|
||||
|
||||
| Command | What it does | Exit codes |
|
||||
|---------|--------------|------------|
|
||||
| `doctor` | Checks prerequisites: node >= 18, pnpm >= 8 (discovered via PATH/nvm/PNPM_HOME/corepack), python3 >= 3.11, git, unshare (sandbox fabric). Every ✗ prints a fix hint. | 0 all pass, 1 any fail |
|
||||
| `bootstrap` | Sets up the monorepo from a fresh clone: (1) locates the repo root, (2) `pnpm install` (discovered toolchain), (3) ai-service venv via `apps/ai-service/scripts/bootstrap.sh`, (4) copies `.env.example` → `.env` if absent, (5) warns on missing optional keys. Idempotent — safe to re-run. | 0 ok, 1 step failed |
|
||||
| `verify` | Health check: ai-service venv + `import ai_service`, uvicorn importable, `.env` present (warn-only), `AI_PORT` state — free, or **busy + serving the web UI** (live mount probe: `/` must return HTML, not the API's JSON 404), web export present (`apps/web/out`), workspace `node_modules`. | 0 ok, 1 failures |
|
||||
| `deploy` | One-command deploy/upgrade: clone-or-update repo (`--dir`, `--ref`), `pnpm install`, ai-service venv, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build`, restart, verify. Fails loudly if the site isn't served. | 0 ok, 1 step failed |
|
||||
| `deploy --service` | Same, plus generates + installs a systemd unit (real paths, `Restart=always`, enabled) so the stack survives reboots; systemd becomes the supervisor. | 0 ok, 1 step failed |
|
||||
| `dev` | Thin passthrough to `apps/ai-service/scripts/dev.sh` (exports secrets from `.ciagent/.env.secrets` if present, runs uvicorn on :8420). Ctrl+C stops it. The web dev server is separate: `pnpm dev`. | child's exit code |
|
||||
| `dev -d` / `dev --detach` | Same, but as an **unattended daemon**: detached, output appended to `~/.nextcraft/run/<clone>/dev.log`, pidfile beside it. Refuses to double-start (and refuses when the systemd service is active). Auto-serves the built web UI same-origin when `apps/web/out` exists. | 0 started, 1 already running |
|
||||
| `stop` | Stops the systemd service when it supervises the stack; otherwise stops the daemon started by `dev -d` (SIGTERM, SIGKILL after 5s) and cleans the pidfile. | 0 stopped/clean, 1 kill failed |
|
||||
| `log` | Tails the daemon log: last 50 lines by default, `-n N` for more, `-f`/`--follow` to stream. | 0 ok, 1 no log yet |
|
||||
| `--help` / `-h` | Usage for the CLI or any command. | 0 |
|
||||
| `--version` | Prints the version this binary was built as (matches the release tag). | 0 |
|
||||
|
||||
Exit-code contract: `0` success, `1` check/step failure (hint printed), `2` usage error.
|
||||
|
||||
### Remote server / single-port deployment
|
||||
|
||||
`nextcraft dev` binds the API on **0.0.0.0:8420** (and `pnpm dev` serves the web app on all interfaces), so the stack works from other machines out of the box:
|
||||
|
||||
- Browse `http://<your-host>:3000` — the web app targets `http://<your-host>:8420` automatically (derived from the browser's hostname).
|
||||
- CORS admits any origin (`AI_CORS_ORIGINS=*` in `apps/ai-service/.env`). This is safe **only** because credentials are never enabled; to restrict, set an explicit list: `AI_CORS_ORIGINS=http://<your-host>:3000`.
|
||||
- To revert to loopback-only: `AI_HOST=127.0.0.1` in `apps/ai-service/.env`.
|
||||
- Security note: this is an unauthenticated dev API reachable from any network the box exposes. Mitigations that still apply: per-learner sandbox caps + global rate caps + learner allowlist (G-5), telemetry flood control (traces marked `INCOMPLETE_FLOODED` are refused by the grader). Expose only on trusted networks until identity/KYC lands (v0.5).
|
||||
|
||||
**Production behind one port (v0.3.6):** when only one port is reachable (e.g. behind HAProxy), the one-command deploy builds the web app as a static export and lets the ai-service serve it same-origin on :8420 — UI and API on one port, no CORS, no mixed content:
|
||||
|
||||
```sh
|
||||
nextcraft deploy --service # install/upgrade + web export + restart + verify + systemd unit
|
||||
```
|
||||
|
||||
Durable state (SQLite DB, sandbox workdirs, daemon pid/log) lives in `~/.nextcraft/` — never inside the repo. Full recipe incl. HAProxy timeouts and a systemd unit: [deploy/README.md](deploy/README.md).
|
||||
|
||||
## Docs
|
||||
|
||||
- [apps/cli/README.md](apps/cli/README.md) — CLI internals: build, binary pipeline, troubleshooting
|
||||
- [.ciagent/PROJECT.md](.ciagent/PROJECT.md) — product spec and milestone history
|
||||
- [.ciagent/ARCHITECTURE.md](.ciagent/ARCHITECTURE.md) — system architecture
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone v0.1** — UI/UX Prototype (high-fidelity interactive, all mock data)
|
||||
**Milestone v0.6** — Marketplace Engine (shipped as v0.5.6): real job-postings backend behind the marketplace surfaces (6th SQLite store family + seeded ingestion adapter + admin authoring surface), AI tagging with facet-driven filters, embedding-backed semantic search with computed match scores, employer enrichment with per-field provenance, resume-to-profile auto-fill with an explicit confirm gate, and SEO programmatic pages (metadata, JSON-LD, dynamic sitemap/robots).
|
||||
|
||||
Initialized via CIAgent v0.7.0
|
||||
Prior: v0.5 Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (shipped as v0.4.5) · v0.3 Credential Engines (v0.2.8) · v0.2 AI Tutor Architecture (v0.2.0) · v0.1 UI/UX Prototype (v0.1.0)
|
||||
|
||||
Initialized via CIAgent v0.7.0
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# Real keys live in .ciagent/.env.secrets (gitignored) and are exported by scripts/dev.sh
|
||||
|
||||
AI_PORT=8420
|
||||
# Network mode (v0.3.5, D-038): dev server binds 0.0.0.0 so remote machines can
|
||||
# reach the stack. Set to 127.0.0.1 to revert to loopback-only.
|
||||
AI_HOST=0.0.0.0
|
||||
# CORS + WS-origin policy: '*' (default) admits any origin — safe because
|
||||
# credentials are never enabled. Restrict with a comma list, e.g.:
|
||||
# AI_CORS_ORIGINS=http://nextcraft-1:3000
|
||||
AI_CORS_ORIGINS=*
|
||||
AI_PROVIDER=ollama-cloud
|
||||
AI_MODEL=gemma4:31b
|
||||
AI_OLLAMA_CLOUD_BASE_URL=https://ollama.com/v1
|
||||
@@ -9,8 +16,10 @@ AI_OLLAMA_CLOUD_API_KEY=
|
||||
AI_LOCAL_BASE_URL=http://localhost:11434/v1
|
||||
AI_JSON_MODE=auto
|
||||
|
||||
# Sandbox fabric (v0.3)
|
||||
AI_SANDBOX_DIR=sandboxes
|
||||
# Sandbox fabric (v0.3). v0.3.6: durable state defaults to ~/.nextcraft/ —
|
||||
# OUTSIDE the repo (the old repo-relative 'sandboxes' default polluted the
|
||||
# git tree). Set an absolute path (~/ works) or a repo-relative one only for
|
||||
# throwaway dev clones.
|
||||
AI_SANDBOX_MAX_CONCURRENT=5
|
||||
AI_SANDBOX_TIMEOUT_S=900
|
||||
AI_SANDBOX_MAX_WORKDIR_MB=512
|
||||
@@ -23,10 +32,74 @@ AI_SANDBOX_MAX_PER_LEARNER=1
|
||||
# global creates per rolling 60s window (in-memory) → 429 when exceeded
|
||||
AI_SANDBOX_CREATES_PER_MIN=10
|
||||
|
||||
# Persistence (SQLite)
|
||||
AI_DB_PATH=ai_service/data/nextcraft.db
|
||||
# --- v0.3 Voice (REQ-3-006, D-030) ---
|
||||
# 'mock' (default; no key needed — tests/dev) or 'browser' (client-native SR/TTS).
|
||||
# Real server STT/TTS ('openai-audio' + AI_VOICE_BASE_URL/AI_VOICE_API_KEY)
|
||||
# is deferred to v0.4 per GRILL CUT-1/G-7 — keys never in code or commits.
|
||||
# Persistence (SQLite). v0.3.6: default moved out of the repo to
|
||||
# ~/.nextcraft/data/nextcraft.db (~/ paths in env overrides are expanded).
|
||||
# Uncomment + edit ONLY to relocate:
|
||||
# AI_DB_PATH=~/.nextcraft/data/nextcraft.db
|
||||
|
||||
# Single-port deploy (v0.3.6): directory of the exported web app
|
||||
# (apps/web/out). When set, the UI is served from this same service on :8420
|
||||
# — build it with `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build`. The
|
||||
# `nextcraft dev` command auto-sets this when apps/web/out exists.
|
||||
# AI_WEB_STATIC_DIR=../../web/out
|
||||
# --- Identity (REQ-5-003, D-042) ---
|
||||
# 'mock' (default — deterministic, no vendor spend pre-pilot; verdicts carry
|
||||
# mock=True forever per A-304). A real KYC vendor drops in via the
|
||||
# IdentityProvider protocol without API changes.
|
||||
AI_IDENTITY_PROVIDER=mock
|
||||
# G-13: identity submit caps — one active pending per learner (409), and a
|
||||
# per-learner submit rate ceiling (429 over a rolling 60s window).
|
||||
AI_IDENTITY_SUBMITS_PER_MIN=3
|
||||
|
||||
# --- Voice (REQ-3-006 D-030; real server path REQ-5-001, D-040) ---
|
||||
# 'mock' (default; no key needed — tests/dev), 'browser' (client-native
|
||||
# SR/TTS), or 'openai-audio' (real server STT/TTS, live since v0.5).
|
||||
AI_VOICE_PROVIDER=mock
|
||||
# openai-audio requires BOTH (unconfigured → app boots, voice falls back to
|
||||
# mock with a loud log — G-11; the badge then honestly reports mock):
|
||||
# AI_VOICE_BASE_URL=https://your-audio-endpoint/v1
|
||||
# AI_VOICE_API_KEY=
|
||||
# Optional model/voice/format knobs (defaults shown):
|
||||
# AI_VOICE_STT_MODEL=whisper-1
|
||||
# AI_VOICE_TTS_MODEL=tts-1
|
||||
# AI_VOICE_TTS_VOICE=alloy
|
||||
# AI_VOICE_TTS_FORMAT=mp3 (enum: mp3 | wav | opus)
|
||||
# AI_VOICE_MAX_AUDIO_MB=10
|
||||
# Manual probe recipe (executable by anyone with the keys; never CI):
|
||||
# STT: curl -sS $AI_VOICE_BASE_URL/audio/transcriptions \
|
||||
# -H "Authorization: Bearer $AI_VOICE_API_KEY" \
|
||||
# -F file=@test/fixtures/answer.wav -F model=whisper-1 | jq -e '.text'
|
||||
# TTS: curl -sS $AI_VOICE_BASE_URL/audio/speech \
|
||||
# -H "Authorization: Bearer $AI_VOICE_API_KEY" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"model":"tts-1","input":"Nextcraft","voice":"alloy"}' \
|
||||
# -o /tmp/probe.mp3 && file /tmp/probe.mp3 | grep -i audio
|
||||
|
||||
# --- Marketplace (REQ-6-001, D-057) ---
|
||||
# Founder/admin surface guard (/v1/marketplace/admin/*): send as the
|
||||
# 'X-Admin-Token' header OR 'Authorization: Bearer <token>'; compared
|
||||
# constant-time (G-108). UNSET = every admin route 403s (fail-closed,
|
||||
# never open) and the boot log warns loudly. Never commit real tokens.
|
||||
# AI_ADMIN_TOKEN=
|
||||
# P5 (D-056) placeholder: the deployed origin for sitemap.xml / robots.txt
|
||||
# absolute URLs (e.g. https://nextcraft.example). Unset → SEO phase
|
||||
# serves the empty static-page set with a boot warning (G-105).
|
||||
# AI_PUBLIC_BASE_URL=
|
||||
|
||||
# --- Semantic search embeddings (REQ-6-003, D-051) ---
|
||||
# 'mock' (default — deterministic 256-dim feature-hash, pure stdlib, no
|
||||
# key needed; tests never call a real embeddings API) or 'openai-embed'.
|
||||
AI_EMBED_PROVIDER=mock
|
||||
# openai-embed requires ALL THREE below; unconfigured → the app boots and
|
||||
# embeddings fall back to mock with a loud log (G-11).
|
||||
# PROBE CORRECTION (D-051): ollama-cloud does NOT expose /embeddings —
|
||||
# point AI_EMBED_BASE_URL at any OTHER OpenAI-compatible endpoint,
|
||||
# e.g. a LOCAL ollama at http://localhost:11434/v1, or any embeddings cloud.
|
||||
# AI_EMBED_BASE_URL=http://localhost:11434/v1
|
||||
# AI_EMBED_API_KEY=
|
||||
# AI_EMBED_MODEL=nomic-embed-text
|
||||
# Manual probe recipe (executable by anyone with the keys; never CI):
|
||||
# curl -sS $AI_EMBED_BASE_URL/embeddings \
|
||||
# -H "Authorization: Bearer $AI_EMBED_API_KEY" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"model":"'"$AI_EMBED_MODEL"'","input":["Nextcraft probe"]}' | jq -e '.data[0].embedding | length'
|
||||
|
||||
@@ -265,4 +265,4 @@ scripts/ bootstrap.sh dev.sh test.sh lint.sh
|
||||
tests/ pytest — mock provider only
|
||||
```
|
||||
|
||||
Boundary rules: `llm/` imports nothing from `agents/` or `api/`; `agents/` imports nothing from `api/`.
|
||||
Boundary rules: `llm/` imports nothing from `agents/` or `api/`; `agents/` imports nothing from `api/`.
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
"""Nextcraft AI tutor service — six LLM agents behind a provider-agnostic layer."""
|
||||
"""Nextcraft AI tutor service — six LLM agents behind a provider-agnostic layer.
|
||||
|
||||
__version__ = "0.2.0"
|
||||
Version single-source (D-061): pyproject.toml is the ONE place the version
|
||||
literal lives. `__version__` (and FastAPI's `version=`) resolve from, in order:
|
||||
|
||||
1. the installed distribution metadata (pip install -e builds it from
|
||||
pyproject at install time), falling back to
|
||||
2. the source tree's pyproject.toml via tomllib (un-installed source runs).
|
||||
|
||||
No fallback literal exists — drift is structurally impossible: if pyproject
|
||||
bumps, both paths follow. Path 2 only degrades when neither metadata nor a
|
||||
pyproject.toml exists (a bare directory copy), reported as "unknown".
|
||||
"""
|
||||
|
||||
import tomllib
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as _dist_version
|
||||
from pathlib import Path
|
||||
|
||||
_DIST_NAME = "nextcraft-ai-service"
|
||||
|
||||
|
||||
def _resolve_version() -> str:
|
||||
try:
|
||||
return _dist_version(_DIST_NAME)
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
# Un-installed source run: read the source tree's pyproject.toml
|
||||
# (ai_service/__init__.py → ../pyproject.toml).
|
||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
try:
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
return str(data["project"]["version"])
|
||||
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
__version__ = _resolve_version()
|
||||
|
||||
@@ -6,6 +6,7 @@ The default pipeline: build_messages() → provider.stream_chat()/chat().
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -15,6 +16,8 @@ from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
from .structured import structured_completion
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""A tutor agent: system prompt + message assembly + provider delegation."""
|
||||
@@ -64,10 +67,15 @@ class BaseAgent(ABC):
|
||||
history: list[Message] | None = None,
|
||||
user_input: str = "",
|
||||
learner_context: LearnerContext | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
schema: type[T] | None = None,
|
||||
schema_hint: str = "",
|
||||
) -> BaseModel:
|
||||
"""Non-streaming completion parsed into a pydantic model (D-020 defense)."""
|
||||
) -> T:
|
||||
"""Non-streaming completion parsed into a pydantic model (D-020 defense).
|
||||
|
||||
P5: generic over `schema` — the returned model is the schema's own
|
||||
type, so callers (assessor/proctor) get concrete types, no casts.
|
||||
`schema=None` raises before T is ever produced.
|
||||
"""
|
||||
if schema is None:
|
||||
raise ValueError("structured_reply requires a schema")
|
||||
messages = self.build_messages(history, user_input, learner_context)
|
||||
|
||||
@@ -39,7 +39,7 @@ class ExaminerAgent(BaseAgent):
|
||||
|
||||
name = "examiner"
|
||||
|
||||
def system_prompt(self, learner_context=None) -> str: # noqa: ANN001
|
||||
def system_prompt(self, learner_context=None) -> str:
|
||||
"""Examiner is context-free (digest-anonymous, D-028 mirror)."""
|
||||
return SYSTEM_PROMPT
|
||||
|
||||
|
||||
@@ -28,15 +28,19 @@ class AgentSession:
|
||||
|
||||
|
||||
class SessionStore(Protocol):
|
||||
def get(self, session_id: str) -> AgentSession | None: ...
|
||||
def create(
|
||||
# P5 (D-058): the protocol was previously declared sync while the
|
||||
# shipped implementation is async — every API call site awaited a
|
||||
# non-awaitable per the old signature. The protocol now tells the
|
||||
# truth: all five methods are async (InMemorySessionStore's shape).
|
||||
async def get(self, session_id: str) -> AgentSession | None: ...
|
||||
async def create(
|
||||
self, session_id: str, agent: str, learner_id: str = "seed-learner-1"
|
||||
) -> AgentSession: ...
|
||||
def append(self, session_id: str, message: Message) -> None: ...
|
||||
def history_window(
|
||||
async def append(self, session_id: str, message: Message) -> None: ...
|
||||
async def history_window(
|
||||
self, session_id: str, max_messages: int = DEFAULT_WINDOW
|
||||
) -> list[Message]: ...
|
||||
def delete(self, session_id: str) -> None: ...
|
||||
async def delete(self, session_id: str) -> None: ...
|
||||
|
||||
|
||||
class InMemorySessionStore:
|
||||
|
||||
@@ -54,11 +54,12 @@ detail instead of rubric scores), so consumers read ONE shape.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..agents.assessor import AssessorAgent
|
||||
from ..agents.registry import AgentRegistry
|
||||
from ..agents.structured import StructuredOutputError
|
||||
from ..config import Settings
|
||||
@@ -103,7 +104,9 @@ async def assessment_evaluate(
|
||||
status_code=404,
|
||||
detail=f"no stored grade for {body.learner_id}/{body.task_id} - grade first",
|
||||
)
|
||||
agent = registry.get(provider, settings, "assessor")
|
||||
# P5: registry.get returns BaseAgent; "assessor" is registered as
|
||||
# AssessorAgent (register_builtin_agents) — the cast states that mapping.
|
||||
agent = cast(AssessorAgent, registry.get(provider, settings, "assessor"))
|
||||
learner_context = get_learner_context(body.learner_id)
|
||||
try:
|
||||
coaching = await agent.coach_grade(grade, learner_context)
|
||||
|
||||
@@ -19,10 +19,12 @@ disclose it before the learner defends.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -40,6 +42,7 @@ from .deps import (
|
||||
get_voice_provider,
|
||||
get_voice_store,
|
||||
)
|
||||
from .identity import require_verified_age
|
||||
|
||||
router = APIRouter(prefix="/v1/defense", tags=["defense"])
|
||||
|
||||
@@ -49,6 +52,19 @@ PAUSE_THRESHOLD_MS = 15_000
|
||||
_ROLE_EXAMINER = "examiner"
|
||||
_ROLE_LEARNER = "learner"
|
||||
|
||||
# v0.4.7 (P1, final-review flag): the old id mint `dfn-{ms:x}` collided on
|
||||
# same-millisecond starts — the insert-only store's PK then 500'd the second
|
||||
# start permanently (the identity-resubmit P0-C1 class). Nonce + counter
|
||||
# keeps ids unique within and across process restarts (the identity/mock.py
|
||||
# pattern); ids stay opaque handles, never derived from PII.
|
||||
_DEFENSE_NONCE = secrets.randbits(32)
|
||||
_DEFENSE_COUNTER = itertools.count(1)
|
||||
|
||||
|
||||
def mint_defense_id(learner_id: str) -> str:
|
||||
seq = next(_DEFENSE_COUNTER)
|
||||
return f"dfn-{_DEFENSE_NONCE:08x}{seq:08x}-{learner_id[:8]}"
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
learner_id: str = Field(min_length=1)
|
||||
@@ -90,7 +106,7 @@ def _voice_descriptor(settings) -> VoiceDescriptor:
|
||||
|
||||
Must-Have #6: browser mode returns BROWSER_FALLBACK_DESCRIPTOR so the
|
||||
web client selects native SpeechRecognition/speechSynthesis; mock mode
|
||||
returns the mock descriptor. (A v0.4 server provider would return
|
||||
returns the mock descriptor. (A real server provider returns
|
||||
mode="server" — the protocol seam.)
|
||||
"""
|
||||
if (settings.voice_provider or "mock").strip().lower() == "browser":
|
||||
@@ -103,6 +119,7 @@ def _voice_descriptor(settings) -> VoiceDescriptor:
|
||||
@router.post("/start", response_model=StartResponse)
|
||||
async def start_defense(
|
||||
body: StartRequest,
|
||||
request: Request,
|
||||
examiner: ExaminerAgent = Depends(get_examiner),
|
||||
voice_store: DefenseStore = Depends(get_voice_store),
|
||||
voice_provider=Depends(get_voice_provider),
|
||||
@@ -110,9 +127,22 @@ async def start_defense(
|
||||
variant_store=Depends(get_variant_store),
|
||||
settings=Depends(get_settings),
|
||||
) -> StartResponse:
|
||||
# v0.5 identity gate (D-043): allowlist first (G-5), then the school
|
||||
# 16+ verified verdict, before any defense machinery runs.
|
||||
if body.learner_id not in settings.learner_allowlist:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"learner_id {body.learner_id!r} is not on the sandbox "
|
||||
"allowlist (G-5)"
|
||||
),
|
||||
)
|
||||
await require_verified_age(
|
||||
16, body.learner_id, request.app.state.identity_store
|
||||
)
|
||||
record = voice_store.start(
|
||||
DefenseRecord(
|
||||
id=f"dfn-{int(time.time() * 1000):x}-{body.learner_id[:8]}",
|
||||
id=mint_defense_id(body.learner_id),
|
||||
learner_id=body.learner_id,
|
||||
task_id=body.task_id,
|
||||
status="in_progress",
|
||||
@@ -161,6 +191,7 @@ async def answer_defense(
|
||||
examiner: ExaminerAgent = Depends(get_examiner),
|
||||
trace_store=Depends(get_trace_store),
|
||||
variant_store=Depends(get_variant_store),
|
||||
settings=Depends(get_settings),
|
||||
) -> AnswerResponse:
|
||||
record = voice_store.get(defense_id)
|
||||
if record is None:
|
||||
@@ -181,13 +212,46 @@ async def answer_defense(
|
||||
if audio is not None:
|
||||
stt_started = time.perf_counter()
|
||||
raw = await audio.read()
|
||||
# P3 (Task 3-2-01): the upload is fully consumed into memory —
|
||||
# no streaming path exists (G-107), so close the spooled file
|
||||
# HERE, before any guard below can raise (422 empty / 413
|
||||
# oversize / 502 provider all operate on `raw` only). FastAPI's
|
||||
# request-end file stack would also close it — close() is
|
||||
# idempotent on the spooled file — but the route must not rely
|
||||
# on GC-timing cleanup.
|
||||
await audio.close()
|
||||
if not raw:
|
||||
# Empty upload is a client error (422), not a provider crash
|
||||
# (500): validate before the provider call so every provider —
|
||||
# mock today, the v0.4 real one — sees the same contract.
|
||||
# (500): validate before the provider call so every provider
|
||||
# sees the same contract.
|
||||
raise HTTPException(status_code=422, detail="audio upload is empty")
|
||||
fmt = (audio.content_type or "audio/wav").split("/")[-1]
|
||||
segment = await voice_provider.transcribe(raw, fmt)
|
||||
max_bytes = settings.voice_max_audio_mb * 1024 * 1024
|
||||
if len(raw) > max_bytes:
|
||||
# D-041/G-12: bounded audio BEFORE the provider call — the
|
||||
# client renders this as an honest re-record prompt.
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"audio exceeds {settings.voice_max_audio_mb}MB "
|
||||
"— re-record a shorter answer"
|
||||
),
|
||||
)
|
||||
# D-041: strip codec params — MediaRecorder sends
|
||||
# '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()
|
||||
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
|
||||
|
||||
@@ -246,6 +310,7 @@ async def defense_audio(
|
||||
turn_id: int,
|
||||
voice_store: DefenseStore = Depends(get_voice_store),
|
||||
voice_provider=Depends(get_voice_provider),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
record = voice_store.get(defense_id)
|
||||
if record is None:
|
||||
@@ -258,7 +323,11 @@ async def defense_audio(
|
||||
async for chunk in voice_provider.synthesize(turn.text):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(stream(), media_type="audio/wav")
|
||||
# G-16/D-041: the TTS format is a settings enum; the media_type maps
|
||||
# from it (was hardcoded audio/wav — wrong for every real format).
|
||||
return StreamingResponse(
|
||||
stream(), media_type=f"audio/{settings.voice_tts_format}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{defense_id}/finish", response_model=FinishResponse)
|
||||
|
||||
@@ -9,6 +9,8 @@ from ..config import Settings
|
||||
from ..grading.engine import GradingEngine
|
||||
from ..grading.store import GradeStore
|
||||
from ..llm.base import LLMProvider
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..sandbox.manager import SandboxManager
|
||||
from ..sandbox.workdir import SandboxDir
|
||||
from ..telemetry.ingest import TraceIntegrityMap
|
||||
@@ -77,3 +79,15 @@ def get_voice_provider(request: Request) -> VoiceProvider:
|
||||
|
||||
def get_examiner(request: Request) -> ExaminerAgent:
|
||||
return request.app.state.examiner_agent
|
||||
|
||||
|
||||
def get_marketplace_store(request: Request) -> MarketplaceStore:
|
||||
"""v0.6 marketplace (D-049): routes NEVER import the store directly —
|
||||
the api/ layer composes it via app.state DI (territory rule)."""
|
||||
return request.app.state.marketplace_store
|
||||
|
||||
|
||||
def get_embedding_provider(request: Request) -> EmbeddingProvider:
|
||||
"""v0.6 P3 (D-051): the embedding port via app.state DI — the
|
||||
search route + publish hooks compose it, never import it."""
|
||||
return request.app.state.embedding_provider
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Identity API + age-gate dependencies (REQ-5-003/004, D-042/43).
|
||||
|
||||
Flow (all under mock provider by default; A-304 mock markers ride every
|
||||
response so downstream surfaces never treat mock-verified as real):
|
||||
POST /v1/identity/submit submission → pending (G-13 caps first)
|
||||
GET /v1/identity/status/{lid} latest record + mock marker
|
||||
POST /v1/identity/verify/{sid} poll provider → terminal transition
|
||||
|
||||
Gate dependencies (D-043 binding composition order, mounted by the gated
|
||||
routes — variants/sandbox-create/defense-start for school 16+; the
|
||||
marketplace 18+ apply route in api/marketplace.py):
|
||||
allowlist (403, G-5 pilot guard) → identity verdict (403 + verify-CTA)
|
||||
→ rate caps (429, owned by the calling routes)
|
||||
|
||||
PII (A-305): raw DOB enters via the submission, is used to derive the
|
||||
band, and is NEVER stored or logged (caplog sentinel test pins it).
|
||||
|
||||
v0.6 (D-048/D-058): the G-18 marketplace stub MOVED to api/marketplace.py
|
||||
and became the real apply route; identity keeps ONLY the gate exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..config import Settings
|
||||
from ..identity.base import IdentityProvider, IdentitySubmission
|
||||
from ..identity.store import IdentityRecord, IdentityStore
|
||||
from .deps import get_settings
|
||||
|
||||
router = APIRouter(prefix="/v1/identity", tags=["identity"])
|
||||
|
||||
|
||||
# -- DI ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_identity_store(request: Request) -> IdentityStore:
|
||||
return request.app.state.identity_store
|
||||
|
||||
|
||||
def get_identity_provider(request: Request) -> IdentityProvider:
|
||||
return request.app.state.identity_provider
|
||||
|
||||
|
||||
# -- models ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class SubmitBody(BaseModel):
|
||||
learner_id: str = Field(min_length=1)
|
||||
#: ISO date. Validated AT THE BOUNDARY (D1 verifier fix): a malformed
|
||||
#: value would otherwise blow up as a 500 inside derive_age_band on the
|
||||
#: verify path — echoing the raw DOB into the traceback (A-305) and
|
||||
#: leaving a poisoned pending record that G-13 turns into a permanent
|
||||
#: learner lockout.
|
||||
date_of_birth: str = Field(description="ISO date; never stored or logged")
|
||||
document_refs: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("date_of_birth")
|
||||
@classmethod
|
||||
def _validate_dob(cls, value: str) -> str:
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
# 422 with the input scrubbed (A-305/D1 — the app-level
|
||||
# RequestValidationError handler redacts PII field inputs).
|
||||
raise ValueError("date_of_birth must be an ISO date (YYYY-MM-DD)") from exc
|
||||
return value
|
||||
|
||||
|
||||
class SubmitResponse(BaseModel):
|
||||
submission_id: str
|
||||
status: str
|
||||
#: A-304: honesty marker — a mock verdict is NEVER production-verified.
|
||||
mock: bool = True
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
learner_id: str
|
||||
status: str
|
||||
age_band: str | None
|
||||
mock: bool
|
||||
verified_at: str | None
|
||||
|
||||
|
||||
class VerifyResponse(SubmitResponse):
|
||||
age_band: str | None
|
||||
|
||||
|
||||
# -- G-13 submit caps ---------------------------------------------------------------
|
||||
|
||||
|
||||
class _SubmitRateLimiter:
|
||||
"""Per-learner submit rate cap (in-memory, process-local — the G-5
|
||||
creates-per-min pattern from the sandboxes route)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._window: dict[str, list[float]] = {}
|
||||
|
||||
def check(self, learner_id: str, per_min: int) -> None:
|
||||
now = time.monotonic()
|
||||
window = self._window.setdefault(learner_id, [])
|
||||
window[:] = [t for t in window if now - t < 60.0]
|
||||
if len(window) >= per_min:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="identity submit rate exceeded — wait a minute",
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
|
||||
_rate_limiter = _SubmitRateLimiter()
|
||||
|
||||
|
||||
# -- verification flow ------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/submit", response_model=SubmitResponse)
|
||||
async def submit_identity(
|
||||
body: SubmitBody,
|
||||
store: IdentityStore = Depends(get_identity_store),
|
||||
provider: IdentityProvider = Depends(get_identity_provider),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> SubmitResponse:
|
||||
# G-13: one active pending submission per learner — resubmit while
|
||||
# pending echoes the pending state (409), not a second submission.
|
||||
if store.count_pending_for_learner(body.learner_id) > 0:
|
||||
latest = store.latest_for_learner(body.learner_id)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "submission_pending",
|
||||
"submission_id": latest.id if latest else None,
|
||||
"status": "pending",
|
||||
},
|
||||
)
|
||||
_rate_limiter.check(body.learner_id, settings.identity_submits_per_min)
|
||||
|
||||
submission = IdentitySubmission(
|
||||
learner_id=body.learner_id,
|
||||
date_of_birth=body.date_of_birth,
|
||||
document_refs=body.document_refs,
|
||||
)
|
||||
submission_id = await provider.submit(submission)
|
||||
|
||||
record = store.insert(
|
||||
IdentityRecord(
|
||||
id=submission_id,
|
||||
learner_id=body.learner_id,
|
||||
status="pending",
|
||||
provider="mock" if settings.identity_provider == "mock" else settings.identity_provider,
|
||||
document_refs=body.document_refs, # A-305: opaque handles, never contents
|
||||
submitted_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
return SubmitResponse(
|
||||
submission_id=record.id, status=record.status, mock=record.mock
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status/{learner_id}", response_model=StatusResponse)
|
||||
async def identity_status(
|
||||
learner_id: str,
|
||||
store: IdentityStore = Depends(get_identity_store),
|
||||
) -> StatusResponse:
|
||||
record = store.latest_for_learner(learner_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="no identity record")
|
||||
return StatusResponse(
|
||||
learner_id=learner_id,
|
||||
status=record.status,
|
||||
age_band=record.age_band,
|
||||
mock=record.mock,
|
||||
verified_at=record.verified_at.isoformat() if record.verified_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify/{submission_id}", response_model=VerifyResponse)
|
||||
async def verify_identity(
|
||||
submission_id: str,
|
||||
store: IdentityStore = Depends(get_identity_store),
|
||||
provider: IdentityProvider = Depends(get_identity_provider),
|
||||
) -> VerifyResponse:
|
||||
record = store.get(submission_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="no such submission")
|
||||
if record.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"submission already {record.status}",
|
||||
)
|
||||
|
||||
verdict = await provider.poll(submission_id)
|
||||
# A-305: derive the band from the provider's verdict; the raw DOB never
|
||||
# entered the store and is not logged here.
|
||||
updated = store.mark_verified(
|
||||
submission_id, verdict.model_dump(), verdict.age_band
|
||||
)
|
||||
assert updated is not None # record existed a moment ago
|
||||
return VerifyResponse(
|
||||
submission_id=updated.id,
|
||||
status=updated.status,
|
||||
age_band=updated.age_band,
|
||||
mock=updated.mock,
|
||||
detail=verdict.detail,
|
||||
)
|
||||
|
||||
|
||||
# -- age-gate dependencies (D-043 composition) -------------------------------------
|
||||
|
||||
|
||||
def _verify_cta_payload(
|
||||
reason: str, min_age: int, record: IdentityRecord | None
|
||||
) -> dict:
|
||||
"""A-306/UX acceptance #2: an actionable 403 — never a bare error."""
|
||||
return {
|
||||
"reason": reason,
|
||||
"min_age": min_age,
|
||||
"current_status": record.status if record else "none",
|
||||
"verify_cta": "/enroll",
|
||||
}
|
||||
|
||||
|
||||
async def require_verified_age(
|
||||
min_age: int,
|
||||
learner_id: str,
|
||||
store: IdentityStore,
|
||||
) -> IdentityRecord:
|
||||
"""The identity half of the D-043 composition (allowlist runs FIRST in
|
||||
the calling routes; this is the second gate; caps come after).
|
||||
|
||||
School 16+ → min_age=16; marketplace 18+ verified → min_age=18.
|
||||
"""
|
||||
record = store.latest_for_learner(learner_id)
|
||||
if record is None or record.status != "verified":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_verify_cta_payload("identity_verification_required", min_age, record),
|
||||
)
|
||||
# D2 (verifier): FAIL CLOSED. Only canonical bands can pass — None,
|
||||
# unknown, or under-16 bands reject (the gate is the security boundary
|
||||
# for the future vendor and direct store writes; it never trusts a
|
||||
# band it does not recognize).
|
||||
if record.age_band not in ("16-17", "18+"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_verify_cta_payload("identity_verification_required", min_age, record),
|
||||
)
|
||||
if min_age > 16 and record.age_band != "18+":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_verify_cta_payload("age_gate_18_plus", 18, record),
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
#: Convenience alias for the marketplace 18+ composition (D-043's
|
||||
#: `require_verified_adult` — direct calls use require_verified_age(18, ...)).
|
||||
#: v0.6 (D-058): the marketplace surface itself moved to api/marketplace.py;
|
||||
#: identity keeps ONLY this gate export (D-048).
|
||||
require_verified_adult = require_verified_age
|
||||
@@ -9,11 +9,13 @@ D-016 envelope with agent=lab.
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from ..agents.lab import LabAgent
|
||||
from ..agents.registry import AgentRegistry
|
||||
from ..config import Settings
|
||||
from ..corpus.learner_context import get_learner_context
|
||||
@@ -41,7 +43,9 @@ async def lab_feedback(
|
||||
provider=Depends(get_provider),
|
||||
trace_store=Depends(get_trace_store),
|
||||
) -> EventSourceResponse:
|
||||
agent = registry.get(provider, settings, "lab")
|
||||
# P5: registry.get returns BaseAgent; "lab" is registered as LabAgent
|
||||
# (register_builtin_agents) — the cast states that mapping.
|
||||
agent = cast(LabAgent, registry.get(provider, settings, "lab"))
|
||||
learner_context = get_learner_context(body.learner_id)
|
||||
trace = (
|
||||
trace_store.get_trace(body.learner_id, body.task_id)
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"""/v1/marketplace — public postings API (REQ-6-001, D-048, D-058).
|
||||
|
||||
The board's runtime data source (published-only, honest 404s, no
|
||||
matchScore anywhere — G-106/G-107) + the REAL apply route: the v0.5
|
||||
placeholder that lived in api/identity.py moved here and became this
|
||||
module's /apply (D-058) — gate composition UNCHANGED (D-043 binding
|
||||
order): G-5 allowlist (403) → require_verified_adult (403 + verify-CTA)
|
||||
→ rate caps (429), then posting exists (404) and is published (409),
|
||||
then an idempotent insert (A-408: duplicate apply → 200 with the
|
||||
EXISTING application).
|
||||
|
||||
Routes never import the store directly — deps.py app.state DI only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from ..config import Settings
|
||||
from ..identity.store import IdentityStore
|
||||
from ..marketplace.base import Employer, Posting
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.enrichment import merge_employer
|
||||
from ..marketplace.resume import (
|
||||
MAX_RESUME_BYTES,
|
||||
ParsedProfile,
|
||||
ResumeFormatError,
|
||||
extract_resume_text,
|
||||
parse_resume,
|
||||
)
|
||||
from ..marketplace.search import FacetFilters, rank_postings
|
||||
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
|
||||
from .identity import require_verified_age
|
||||
|
||||
router = APIRouter(prefix="/v1/marketplace", tags=["marketplace"])
|
||||
|
||||
|
||||
def _identity_store(request: Request) -> IdentityStore:
|
||||
return request.app.state.identity_store
|
||||
|
||||
|
||||
def _enforce_allowlist(learner_id: str, settings: Settings) -> None:
|
||||
"""G-5 pilot guard — allowlist runs FIRST in the composition (D-043)."""
|
||||
if learner_id not in settings.learner_allowlist:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"learner_id {learner_id!r} is not on the sandbox allowlist (G-5)",
|
||||
)
|
||||
|
||||
|
||||
# In-memory apply-rate window (monotonic timestamps, process-local) — the
|
||||
# G-5 creates-per-min pattern from the sandboxes route (module state is
|
||||
# acceptable: one process, one box, D-019/D-027 precedent).
|
||||
_APPLY_TIMES: dict[str, deque[float]] = {}
|
||||
|
||||
#: Applies per rolling 60s window per learner (A-408 anti-spam; the
|
||||
#: idempotent duplicate makes honest retries free — only DISTINCT
|
||||
#: posting applies consume budget).
|
||||
_APPLY_PER_LEARNER_PER_MIN = 5
|
||||
|
||||
|
||||
def _check_apply_rate(learner_id: str) -> None:
|
||||
now = time.monotonic()
|
||||
window = _APPLY_TIMES.setdefault(learner_id, deque())
|
||||
while window and now - window[0] > 60.0:
|
||||
window.popleft()
|
||||
if len(window) >= _APPLY_PER_LEARNER_PER_MIN:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="marketplace apply rate exceeded — wait a minute",
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
|
||||
# -- response contracts -------------------------------------------------------
|
||||
|
||||
|
||||
class PostingResponse(BaseModel):
|
||||
"""One posting over HTTP. G-107: `matchScore` is deliberately absent
|
||||
from the schema — scores are computed search-time context (P3),
|
||||
never list/detail payload fields."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
employer_id: str
|
||||
title: str
|
||||
description: str
|
||||
required_competencies: list[str]
|
||||
skills: list[str]
|
||||
seniority: str | None
|
||||
category: str | None
|
||||
location: str
|
||||
remote: bool
|
||||
salary_min: int | None
|
||||
salary_max: int | None
|
||||
status: str
|
||||
tags: dict | None
|
||||
posted_at: str | None
|
||||
|
||||
|
||||
class PostingListResponse(BaseModel):
|
||||
"""G-103: the page + the honest total_published so the board's paged
|
||||
fetch-all can render "Showing X of Y" with an honest truncation. P2
|
||||
(MH-2c): facets — value counts over stored tags of published-only
|
||||
postings, data-driving the filter sidebar."""
|
||||
|
||||
postings: list[PostingResponse]
|
||||
total_published: int
|
||||
facets: dict
|
||||
|
||||
|
||||
class ProvenanceField(BaseModel):
|
||||
"""P4-A (D-053/A-304): one merged employer field with its honest
|
||||
provenance — raw | llm | manual (+ the model that wrote llm copy)."""
|
||||
|
||||
value: object
|
||||
source: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class EmployerResponse(BaseModel):
|
||||
"""Raw ingested fields (P1) + the P4-A `merged` section: per-field
|
||||
provenance-marked view (manual > llm > raw on read, D-053)."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
name: str
|
||||
logo: str
|
||||
description: str
|
||||
industry: str
|
||||
size: str
|
||||
location: str
|
||||
website: str
|
||||
social_links: dict[str, str]
|
||||
culture: str
|
||||
merged: dict[str, ProvenanceField] | None = None
|
||||
|
||||
|
||||
class SkillBreakdownEntry(BaseModel):
|
||||
"""One skill's deterministic explanation (MH-3f): tag-overlap with
|
||||
the query (0/1) + the skill's vector contribution share."""
|
||||
|
||||
skill: str
|
||||
overlap: int
|
||||
contribution: float
|
||||
|
||||
|
||||
class SearchEntry(BaseModel):
|
||||
"""P3 search result (G-106): `match_score` exists ONLY in this
|
||||
search context — a no-query facet listing carries None (honest
|
||||
absence, never a fabricated number)."""
|
||||
|
||||
posting: PostingResponse
|
||||
match_score: float | None
|
||||
skill_breakdown: list[SkillBreakdownEntry]
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[SearchEntry]
|
||||
query: str
|
||||
ranked: int
|
||||
|
||||
|
||||
class ApplyBody(BaseModel):
|
||||
"""D-058: the learner identity comes from the VERIFIED record, not a
|
||||
client assertion — but the pilot surface passes learner_id in the
|
||||
body like every other gated route (G-5 composition unchanged)."""
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ApplyResponse(BaseModel):
|
||||
"""Honest confirmation (D-058): exactly the application state, no
|
||||
stub/mock markers, no fabricated outcome."""
|
||||
|
||||
application_id: str
|
||||
posting_id: str
|
||||
status: str
|
||||
|
||||
|
||||
def _posting_response(posting: Posting) -> PostingResponse:
|
||||
return PostingResponse(
|
||||
id=posting.id,
|
||||
source=posting.source,
|
||||
external_id=posting.external_id,
|
||||
employer_id=posting.employer_id,
|
||||
title=posting.title,
|
||||
description=posting.description,
|
||||
required_competencies=posting.required_competencies,
|
||||
skills=posting.skills,
|
||||
seniority=posting.seniority,
|
||||
category=posting.category,
|
||||
location=posting.location,
|
||||
remote=posting.remote,
|
||||
salary_min=posting.salary_min,
|
||||
salary_max=posting.salary_max,
|
||||
status=posting.status,
|
||||
tags=dict(posting.tags) if posting.tags else None,
|
||||
posted_at=posting.posted_at.isoformat() if posting.posted_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _employer_response(
|
||||
employer: Employer, merged: dict[str, ProvenanceField] | None = None
|
||||
) -> EmployerResponse:
|
||||
return EmployerResponse(
|
||||
id=employer.id,
|
||||
source=employer.source,
|
||||
external_id=employer.external_id,
|
||||
name=employer.name,
|
||||
logo=employer.logo,
|
||||
description=employer.description,
|
||||
industry=employer.industry,
|
||||
size=employer.size,
|
||||
location=employer.location,
|
||||
website=employer.website,
|
||||
social_links=dict(employer.social_links),
|
||||
culture=employer.culture,
|
||||
merged=merged,
|
||||
)
|
||||
|
||||
|
||||
# -- public endpoints ---------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/postings", response_model=PostingListResponse)
|
||||
async def list_postings(
|
||||
store=Depends(get_marketplace_store),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> PostingListResponse:
|
||||
"""Published postings only (draft/closed never board), posted_at DESC,
|
||||
with the honest total (G-103) + facets from published-only stored
|
||||
tags (MH-2c — facets computed once per page request, identical on
|
||||
every page of the fetch-all)."""
|
||||
postings, total = store.list_postings(limit=limit, offset=offset)
|
||||
return PostingListResponse(
|
||||
postings=[_posting_response(p) for p in postings],
|
||||
total_published=total,
|
||||
facets=store.facets(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchResponse)
|
||||
async def search_postings(
|
||||
store=Depends(get_marketplace_store),
|
||||
embedder: EmbeddingProvider = Depends(get_embedding_provider),
|
||||
q: str = Query(default=""),
|
||||
skills: list[str] = Query(default=[]),
|
||||
seniority: str | None = Query(default=None),
|
||||
category: str | None = Query(default=None),
|
||||
location: str | None = Query(default=None),
|
||||
remote: bool | None = Query(default=None),
|
||||
salary_min: int | None = Query(default=None, ge=0),
|
||||
salary_max: int | None = Query(default=None, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> SearchResponse:
|
||||
"""P3 semantic search (REQ-6-003, D-051/A-405): facet hard-filters
|
||||
SQL-side FIRST, then cosine ranking DESC over the filtered set; no
|
||||
facets → pure semantic rank. `match_score` exists ONLY here (G-106)
|
||||
— an empty `q` with facets is a facet-only listing (posted_at DESC,
|
||||
match_score null — no query → no fabricated scores)."""
|
||||
filters = FacetFilters(
|
||||
skills=skills,
|
||||
seniority=seniority,
|
||||
category=category,
|
||||
location=location,
|
||||
remote=remote,
|
||||
salary_min=salary_min,
|
||||
salary_max=salary_max,
|
||||
)
|
||||
ranked = await rank_postings(store, embedder, q, filters, limit=limit)
|
||||
has_query = bool(q.strip())
|
||||
return SearchResponse(
|
||||
results=[
|
||||
SearchEntry(
|
||||
posting=_posting_response(r.posting),
|
||||
match_score=round(r.score, 6) if has_query else None,
|
||||
# P5 scoped: search.skill_breakdown() yields raw dicts in the
|
||||
# exact entry shape (marketplace/ may not import api/ models —
|
||||
# D-048 boundary); pydantic coerces them into
|
||||
# SkillBreakdownEntry at this SearchEntry boundary.
|
||||
skill_breakdown=r.skill_breakdown if has_query else [], # type: ignore[arg-type]
|
||||
)
|
||||
for r in ranked
|
||||
],
|
||||
query=q,
|
||||
ranked=len(ranked),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/postings/{posting_id}", response_model=PostingResponse)
|
||||
async def get_posting(
|
||||
posting_id: str,
|
||||
store=Depends(get_marketplace_store),
|
||||
) -> PostingResponse:
|
||||
"""Honest 404 on any unknown id — never a fabricated job."""
|
||||
posting = store.get_posting(posting_id)
|
||||
if posting is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
return _posting_response(posting)
|
||||
|
||||
|
||||
@router.get("/employers/{employer_id}", response_model=EmployerResponse)
|
||||
async def get_employer(
|
||||
employer_id: str,
|
||||
store=Depends(get_marketplace_store),
|
||||
) -> EmployerResponse:
|
||||
"""Honest 404 on unknown employers. Raw fields stay as-is; the P4-A
|
||||
`merged` section carries the per-field provenance view (manual >
|
||||
llm > raw, D-053/A-304 — LLM copy is visibly marked, never
|
||||
displayed as ingested fact)."""
|
||||
employer = store.get_employer(employer_id)
|
||||
if employer is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no employer with id {employer_id!r}"
|
||||
)
|
||||
rows = store.list_enrichment(employer_id)
|
||||
merged_raw = merge_employer(employer, rows) if rows else None
|
||||
merged = (
|
||||
{
|
||||
field: ProvenanceField(
|
||||
value=entry["value"], source=entry["source"], model=entry["model"]
|
||||
)
|
||||
for field, entry in merged_raw.items()
|
||||
}
|
||||
if merged_raw
|
||||
else None
|
||||
)
|
||||
return _employer_response(employer, merged=merged)
|
||||
|
||||
|
||||
# -- the real apply route (D-058; the G-18 stub's replacement) ------------------
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
async def apply_posting(
|
||||
body: ApplyBody,
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ApplyResponse:
|
||||
"""Apply to a posting. Gate composition (D-043, unchanged order):
|
||||
G-5 allowlist → require_verified_adult (18+ verified, verify-CTA
|
||||
403) → rate caps → posting exists (404) + published (409) →
|
||||
idempotent insert (A-408: a duplicate returns the EXISTING
|
||||
application with 200)."""
|
||||
_enforce_allowlist(body.learner_id, settings)
|
||||
await require_verified_age(18, body.learner_id, _identity_store(request))
|
||||
_check_apply_rate(body.learner_id)
|
||||
|
||||
posting = store.get_posting(body.posting_id)
|
||||
if posting is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {body.posting_id!r}"
|
||||
)
|
||||
if posting.status != "published":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "posting_not_published",
|
||||
"status": posting.status,
|
||||
},
|
||||
)
|
||||
|
||||
application, _created = store.insert_application(
|
||||
posting_id=body.posting_id, learner_id=body.learner_id
|
||||
)
|
||||
return ApplyResponse(
|
||||
application_id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
status=application.status,
|
||||
)
|
||||
|
||||
|
||||
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
|
||||
|
||||
|
||||
class ResumeDraftResponse(BaseModel):
|
||||
"""The parse result — a DRAFT. Nothing persists without the explicit
|
||||
confirm call (the REQ-6-005 review gate)."""
|
||||
|
||||
status: str = "draft"
|
||||
profile: dict
|
||||
|
||||
|
||||
class ResumeConfirmBody(BaseModel):
|
||||
"""The learner's explicit save decision: the draft profile rides the
|
||||
body VERBATIM from the parse and the server RE-VALIDATES the shape
|
||||
against ParsedProfile before persisting (verifier P1 — a bare dict
|
||||
would accept arbitrary/type-confused payloads)."""
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
profile: dict
|
||||
|
||||
|
||||
class ResumeConfirmResponse(BaseModel):
|
||||
status: str = "confirmed"
|
||||
learner_id: str
|
||||
confirmed_at: str
|
||||
|
||||
|
||||
async def _require_verified_marketplace_learner(
|
||||
learner_id: str,
|
||||
settings: Settings,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""Learner-gated resume surface (D-043 composition order): G-5
|
||||
allowlist → verified identity → rate caps — same order as apply."""
|
||||
_enforce_allowlist(learner_id, settings)
|
||||
await require_verified_age(18, learner_id, _identity_store(request))
|
||||
_check_apply_rate(learner_id)
|
||||
|
||||
|
||||
@router.post("/resume", response_model=ResumeDraftResponse)
|
||||
async def upload_resume(
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
provider=Depends(get_provider),
|
||||
) -> ResumeDraftResponse:
|
||||
"""Parse an uploaded resume into a profile DRAFT (multipart: `file`
|
||||
+ `learner_id` form field). Bounds (A-410): PDF/TXT/MD only, ≤2 MB,
|
||||
text-layer required (empty PDF extract → honest 422; no OCR in
|
||||
v0.6). Raw bytes never stored, never logged (A-305). The response
|
||||
is a DRAFT — the confirm route is the only persistence path."""
|
||||
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
form = await request.form()
|
||||
try:
|
||||
learner_id = form.get("learner_id")
|
||||
if not isinstance(learner_id, str) or not learner_id:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="learner_id form field required"
|
||||
)
|
||||
await _require_verified_marketplace_learner(learner_id, settings, request)
|
||||
|
||||
upload = form.get("file")
|
||||
# starlette's UploadFile base (fastapi's is a subclass — the raw
|
||||
# request.form() yields the base, so isinstance must use it).
|
||||
if not isinstance(upload, StarletteUploadFile):
|
||||
raise HTTPException(status_code=422, detail="file form field required")
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_RESUME_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"resume too large ({len(data)} bytes; max {MAX_RESUME_BYTES})",
|
||||
)
|
||||
if len(data) == 0:
|
||||
raise HTTPException(status_code=422, detail="empty file")
|
||||
content_type = upload.content_type or ""
|
||||
try:
|
||||
text = extract_resume_text(data, content_type)
|
||||
except ResumeFormatError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
try:
|
||||
parsed = await parse_resume(provider, model=settings.model, text=text)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="profile extraction failed — try again or edit manually",
|
||||
) from exc
|
||||
draft = ResumeDraftResponse(
|
||||
status="draft",
|
||||
profile={
|
||||
"name": parsed.name,
|
||||
"headline": parsed.headline,
|
||||
"skills": list(parsed.skills),
|
||||
"experience_bands": [
|
||||
b.model_dump() for b in parsed.experience_bands
|
||||
],
|
||||
"competencies": list(parsed.competencies),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
# P3 (Task 3-2-01): this route parses the form manually (raw
|
||||
# request.form()), so FastAPI's request-end auto-close — which it
|
||||
# pushes ONLY for param-injected multipart bodies — never runs
|
||||
# here. The multipart parser has already spooled every file open
|
||||
# by the time any guard raises (learner_id 422, allowlist 403,
|
||||
# verified 403, rate 429 — the pre-read guards included), so the
|
||||
# close must span ALL paths. form.close() is starlette's
|
||||
# canonical cleanup for manually-parsed forms: it closes every
|
||||
# UploadFile the form carries. The bytes are fully in `data`
|
||||
# before any raise (G-107: no streaming path exists); the
|
||||
# response is materialized in `draft` — nothing reads the
|
||||
# spooled file after this point.
|
||||
await form.close()
|
||||
return draft
|
||||
|
||||
|
||||
@router.post("/resume/confirm", response_model=ResumeConfirmResponse)
|
||||
async def confirm_resume(
|
||||
body: ResumeConfirmBody,
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ResumeConfirmResponse:
|
||||
"""The REQ-6-005 review gate: persist the learner-CONFIRMED profile
|
||||
to the learner_profile store table (G-104 — the D-027 family is the
|
||||
only persistence mechanism). G-5 → verified 18+ → rate caps, then
|
||||
a SHAPE RE-VALIDATION against ParsedProfile (verifier P1 — the
|
||||
confirm must never persist arbitrary/type-confused payloads the
|
||||
parse never produced), then an UPSERT (re-confirm replaces; the
|
||||
draft never persisted)."""
|
||||
await _require_verified_marketplace_learner(body.learner_id, settings, request)
|
||||
try:
|
||||
validated = ParsedProfile.model_validate(body.profile)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"reason": "invalid_profile_shape",
|
||||
"message": "the profile must match the parsed draft shape",
|
||||
"errors": [
|
||||
{"loc": list(e.get("loc", [])), "msg": e.get("msg", "")}
|
||||
for e in exc.errors()
|
||||
],
|
||||
},
|
||||
) from exc
|
||||
stored = store.save_learner_profile(
|
||||
body.learner_id, validated.model_dump(mode="json"), model=settings.model
|
||||
)
|
||||
return ResumeConfirmResponse(
|
||||
status="confirmed",
|
||||
learner_id=stored["learner_id"],
|
||||
confirmed_at=stored["confirmed_at"].isoformat()
|
||||
if stored["confirmed_at"]
|
||||
else "",
|
||||
)
|
||||
@@ -0,0 +1,423 @@
|
||||
"""/v1/marketplace/admin — founder/operator surface (REQ-6-001, D-057).
|
||||
|
||||
Guard: `require_admin` compares X-Admin-Token OR Authorization: Bearer
|
||||
against Settings().admin_token via secrets.compare_digest (G-108).
|
||||
UNSET token → every admin route 403s — fail-closed, NEVER open (a loud
|
||||
boot warning in main.py names the fix, G-11). Admin routes do NOT pass
|
||||
the learner gates (different principal class, D-057/D-043 untouched).
|
||||
|
||||
Routes: create draft posting, publish/close lifecycle (invalid
|
||||
transition → 409), seeded-corpus ingest trigger, applications review
|
||||
(filterable by posting). No cron/crawler — ingest is founder-invoked
|
||||
(A-401/A-402).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import Settings
|
||||
from ..llm.base import LLMProvider
|
||||
from ..marketplace.base import Posting
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.enrichment import ENRICHABLE_FIELDS, maybe_enrich_on_publish
|
||||
from ..marketplace.ingest import SeededCorpusSource, run_ingest
|
||||
from ..marketplace.search import embed_posting
|
||||
from ..marketplace.store import InvalidTransition, MarketplaceStore
|
||||
from ..marketplace.tagging import TaggingUnavailable, tag_posting
|
||||
from .deps import (
|
||||
get_embedding_provider,
|
||||
get_marketplace_store,
|
||||
get_provider,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/v1/marketplace/admin", tags=["marketplace-admin"])
|
||||
|
||||
|
||||
def _extract_admin_token(
|
||||
x_admin_token: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||
authorization: Annotated[str | None, Header(include_in_schema=False)] = None,
|
||||
) -> str | None:
|
||||
"""Bearer of the request: X-Admin-Token wins; else Authorization:
|
||||
Bearer <token>. None when neither header carries a candidate."""
|
||||
if x_admin_token:
|
||||
return x_admin_token
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.strip().lower() == "bearer" and token.strip():
|
||||
return token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def require_admin(
|
||||
request_token: Annotated[str | None, Depends(_extract_admin_token)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> Settings:
|
||||
"""D-057 fail-closed guard: constant-time compare (G-108) against the
|
||||
env-only AI_ADMIN_TOKEN (SecretStr — never repr'd/logged); UNSET → 403
|
||||
for EVERY request (never open). Returns the settings for the route
|
||||
handlers.
|
||||
|
||||
Bytes compare: `secrets.compare_digest` raises TypeError on
|
||||
non-ASCII str candidates (final-review F-1 — an attacker-triggerable
|
||||
unhandled 500, fail-closed in outcome but noisy); comparing the
|
||||
UTF-8 encodings accepts ANY candidate bytes honestly (403 on
|
||||
mismatch) and stays constant-time."""
|
||||
configured = settings.admin_token.get_secret_value()
|
||||
if not configured or request_token is None:
|
||||
# Fail-closed: no configured token, or no candidate at all — the
|
||||
# surface is locked, not open (D-057).
|
||||
raise HTTPException(status_code=403, detail="admin token required")
|
||||
if not secrets.compare_digest(
|
||||
request_token.encode("utf-8", "surrogatepass"),
|
||||
configured.encode("utf-8"),
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="invalid admin token")
|
||||
return settings
|
||||
|
||||
|
||||
# -- contracts ----------------------------------------------------------------
|
||||
|
||||
|
||||
class AdminPostingCreate(BaseModel):
|
||||
"""Admin-authored posting body. Starts as a DRAFT (G-102: only
|
||||
seed-port postings enter published); publish is an explicit call."""
|
||||
|
||||
employer_id: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: str | None = None
|
||||
category: str | None = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
|
||||
|
||||
class AdminPostingResponse(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
external_id: str
|
||||
employer_id: str
|
||||
title: str
|
||||
status: str
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
postings_upserted: int
|
||||
employers_upserted: int
|
||||
|
||||
|
||||
class AdminPostingEdit(BaseModel):
|
||||
"""P2 (Task 2-1-03): partial content edit; fields omitted stay put.
|
||||
A successful edit RE-TAGS the posting (tags are derived data,
|
||||
D-052 — the tagger overwrites)."""
|
||||
|
||||
title: str | None = Field(default=None, min_length=1)
|
||||
description: str | None = None
|
||||
skills: list[str] | None = None
|
||||
|
||||
|
||||
class AdminEnrichmentOverride(BaseModel):
|
||||
"""P4-A (D-053): manual-override body — appends a manual-source row
|
||||
(never mutates llm rows). `value` for social_links is a JSON object
|
||||
string; the other fields are plain text."""
|
||||
|
||||
employer_id: str = Field(min_length=1)
|
||||
field: str = Field(min_length=1)
|
||||
value: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AdminApplication(BaseModel):
|
||||
id: str
|
||||
posting_id: str
|
||||
learner_id: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class AdminApplicationsResponse(BaseModel):
|
||||
applications: list[AdminApplication]
|
||||
|
||||
|
||||
# -- routes --------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/postings", status_code=201, response_model=AdminPostingResponse)
|
||||
async def create_posting(
|
||||
body: AdminPostingCreate,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminPostingResponse:
|
||||
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
|
||||
adapter — source=admin). Publish is a separate lifecycle call."""
|
||||
posting = Posting(
|
||||
id=f"adm-{uuid.uuid4().hex[:16]}",
|
||||
source="admin",
|
||||
external_id=f"adm-{uuid.uuid4().hex[:16]}",
|
||||
employer_id=body.employer_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
required_competencies=body.required_competencies,
|
||||
skills=body.skills,
|
||||
# P5: admin body carries str; Posting's Literal contract — the
|
||||
# store's validates hook rejects out-of-taxonomy values.
|
||||
seniority=body.seniority, # type: ignore[arg-type]
|
||||
category=body.category,
|
||||
location=body.location,
|
||||
remote=body.remote,
|
||||
salary_min=body.salary_min,
|
||||
salary_max=body.salary_max,
|
||||
status="draft",
|
||||
)
|
||||
try:
|
||||
stored = store.insert_posting(posting)
|
||||
except Exception as exc: # surfaced IntegrityError from the store
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return AdminPostingResponse(
|
||||
id=stored.id,
|
||||
source=stored.source,
|
||||
external_id=stored.external_id,
|
||||
employer_id=stored.employer_id,
|
||||
title=stored.title,
|
||||
status=stored.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postings/{posting_id}/publish", response_model=AdminPostingResponse
|
||||
)
|
||||
async def publish_posting(
|
||||
posting_id: str,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
embedder: EmbeddingProvider = Depends(get_embedding_provider),
|
||||
provider: LLMProvider = Depends(get_provider),
|
||||
) -> AdminPostingResponse:
|
||||
"""draft → published (the only valid move from draft; anything else
|
||||
409s). Stamps posted_at NOW when the row has none (G-102: seed-port
|
||||
postings keep their original postedAt).
|
||||
|
||||
P3 embed-on-publish (Task 3-2-01): the published posting embeds
|
||||
now; failure = honest warning — the posting still publishes, the
|
||||
vector stays absent (search skips it).
|
||||
|
||||
P4-A enrich-on-first-publish (D-053): the employer's FIRST publish
|
||||
fires the one-shot profile extraction (no existing llm rows → run;
|
||||
re-publishing the same employer does not re-run). Failure = honest
|
||||
warning — the publish stands, no rows written."""
|
||||
try:
|
||||
stored = store.set_posting_status(posting_id, "published")
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
await embed_posting(store, embedder, stored)
|
||||
try:
|
||||
await maybe_enrich_on_publish(
|
||||
store, provider, model=settings.model, employer_id=stored.employer_id
|
||||
)
|
||||
except Exception: # pragma: no cover - maybe_enrich swallows; belt+braces
|
||||
logger.exception("enrich-on-publish failed; publish stands")
|
||||
return AdminPostingResponse(
|
||||
id=stored.id,
|
||||
source=stored.source,
|
||||
external_id=stored.external_id,
|
||||
employer_id=stored.employer_id,
|
||||
title=stored.title,
|
||||
status=stored.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/enrichment", status_code=201)
|
||||
async def manual_enrichment_override(
|
||||
body: AdminEnrichmentOverride,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""P4-A (D-053): founder manual override — APPENDS a manual-source
|
||||
row (read precedence manual > llm > raw picks it up); llm rows are
|
||||
NEVER mutated. Valid fields: description | social_links | logo."""
|
||||
if body.field not in ENRICHABLE_FIELDS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"field must be one of {ENRICHABLE_FIELDS}",
|
||||
)
|
||||
employer = store.get_employer(body.employer_id)
|
||||
if employer is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no employer with id {body.employer_id!r}"
|
||||
)
|
||||
row = store.append_enrichment(
|
||||
body.employer_id,
|
||||
field=body.field,
|
||||
value=body.value,
|
||||
source="manual",
|
||||
)
|
||||
return {"stored": True, "row": row}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postings/{posting_id}/close", response_model=AdminPostingResponse
|
||||
)
|
||||
async def close_posting(
|
||||
posting_id: str,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminPostingResponse:
|
||||
"""published → closed (the only valid move from published; 409 on
|
||||
closing a draft or re-closing)."""
|
||||
try:
|
||||
stored = store.set_posting_status(posting_id, "closed")
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if stored is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
return AdminPostingResponse(
|
||||
id=stored.id,
|
||||
source=stored.source,
|
||||
external_id=stored.external_id,
|
||||
employer_id=stored.employer_id,
|
||||
title=stored.title,
|
||||
status=stored.status,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/postings/{posting_id}", response_model=AdminPostingResponse)
|
||||
async def edit_posting(
|
||||
posting_id: str,
|
||||
body: AdminPostingEdit,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
provider: LLMProvider = Depends(get_provider),
|
||||
) -> AdminPostingResponse:
|
||||
"""P2 (Task 2-1-03): edit title/description/skills → RE-TAG the
|
||||
posting (D-052: tags are derived data — the new extraction
|
||||
OVERWRITES the old tags column; old tags gone). Tag failure is
|
||||
honest: the edit persists, tags stay as they were, a warning logs."""
|
||||
updated = store.update_posting_fields(
|
||||
posting_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
skills=body.skills,
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no posting with id {posting_id!r}"
|
||||
)
|
||||
try:
|
||||
tags = await tag_posting(
|
||||
provider,
|
||||
model=settings.model,
|
||||
title=updated.title,
|
||||
description=updated.description,
|
||||
skills_hint=updated.skills,
|
||||
)
|
||||
updated = store.set_posting_tags(posting_id, tags) or updated
|
||||
except TaggingUnavailable:
|
||||
# Honest: the EDIT landed; tags keep their previous value (the
|
||||
# warning already logged in tagging.py). Never 500 on tag failure.
|
||||
pass
|
||||
return AdminPostingResponse(
|
||||
id=updated.id,
|
||||
source=updated.source,
|
||||
external_id=updated.external_id,
|
||||
employer_id=updated.employer_id,
|
||||
title=updated.title,
|
||||
status=updated.status,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=IngestResponse)
|
||||
async def ingest(
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
provider: LLMProvider = Depends(get_provider),
|
||||
embedder: EmbeddingProvider = Depends(get_embedding_provider),
|
||||
) -> IngestResponse:
|
||||
"""Run the seeded corpus source now (founder-invoked — no scheduler,
|
||||
A-401/A-402). Idempotent on (source, external_id): a double-run
|
||||
yields identical rows (MH-1b). Every posting is TAGGED after
|
||||
upsert (P2, D-052) — a tag failure leaves the posting UNTAGGED with
|
||||
a warning, never a crashed ingest. Published seed postings EMBED
|
||||
after tagging (P3 embed-on-publish path, Task 3-2-01) — an embed
|
||||
failure is the same honest warning."""
|
||||
source = SeededCorpusSource()
|
||||
result = run_ingest(source, store)
|
||||
tagged = 0
|
||||
untagged = 0
|
||||
embedded = 0
|
||||
for posting in source.fetch():
|
||||
stored = store.get_posting(posting.id)
|
||||
if stored is None: # pragma: no cover - upsert just ran
|
||||
continue
|
||||
try:
|
||||
tags = await tag_posting(
|
||||
provider,
|
||||
model=settings.model,
|
||||
title=stored.title,
|
||||
description=stored.description,
|
||||
skills_hint=stored.skills,
|
||||
)
|
||||
store.set_posting_tags(stored.id, tags)
|
||||
stored = store.get_posting(stored.id) or stored
|
||||
tagged += 1
|
||||
except TaggingUnavailable:
|
||||
untagged += 1
|
||||
if stored.status == "published":
|
||||
# Embed-on-publish for the published seed postings (G-102).
|
||||
if await embed_posting(store, embedder, stored):
|
||||
embedded += 1
|
||||
if untagged:
|
||||
logger.warning(
|
||||
"marketplace ingest tagged %s/%s postings — %s left untagged "
|
||||
"(provider failures are honest None, never fabricated)",
|
||||
tagged,
|
||||
tagged + untagged,
|
||||
untagged,
|
||||
)
|
||||
return IngestResponse(
|
||||
postings_upserted=result.postings_upserted,
|
||||
employers_upserted=result.employers_upserted,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/applications", response_model=AdminApplicationsResponse
|
||||
)
|
||||
async def list_applications(
|
||||
posting_id: str | None = None,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(require_admin),
|
||||
) -> AdminApplicationsResponse:
|
||||
"""Applications review list (newest first), filterable by posting.
|
||||
The founder contacts employers out-of-band (no notification infra,
|
||||
A-408)."""
|
||||
rows: list[Any] = store.list_applications(posting_id=posting_id)
|
||||
return AdminApplicationsResponse(
|
||||
applications=[
|
||||
AdminApplication(
|
||||
id=a.id,
|
||||
posting_id=a.posting_id,
|
||||
learner_id=a.learner_id,
|
||||
status=a.status,
|
||||
created_at=a.created_at.isoformat() if a.created_at else "",
|
||||
)
|
||||
for a in rows
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""G-101 placeholder-shell serving (v0.6).
|
||||
|
||||
Next 15's static export emits exactly one placeholder page per dynamic
|
||||
marketplace route (`out/marketplace/jobs/_/index.html`). Under the
|
||||
single-port deploy (D-039) these routes serve that shell for ANY id so
|
||||
runtime-created postings stay reachable and every sitemap URL resolves
|
||||
(G-109). The shell client-fetches the real data by path id and
|
||||
notFound()s on 404.
|
||||
|
||||
Registered before the StaticFiles mount so the routes win (the same
|
||||
routes-beat-the-mount mechanism the sitemap relies on). In dev mode
|
||||
(web_static_dir unset) the Next dev server handles routing itself —
|
||||
no-op.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..config import Settings
|
||||
|
||||
_SHELL_ROUTES = (
|
||||
("/marketplace/jobs/{job_id}", "marketplace/jobs/_/index.html"),
|
||||
("/marketplace/jobs/{job_id}/", "marketplace/jobs/_/index.html"),
|
||||
("/marketplace/employers/{employer_id}", "marketplace/employers/_/index.html"),
|
||||
("/marketplace/employers/{employer_id}/", "marketplace/employers/_/index.html"),
|
||||
)
|
||||
|
||||
|
||||
def register_marketplace_shell_routes(app: FastAPI, settings: Settings) -> None:
|
||||
web_dir = Path(str(settings.web_static_dir or ""))
|
||||
if str(settings.web_static_dir) in ("", ".") or not web_dir.is_dir():
|
||||
# Dev/no-mount boots: the Next dev server or a separate web origin
|
||||
# handles these paths. Nothing to serve here.
|
||||
return
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
for path, shell in _SHELL_ROUTES:
|
||||
|
||||
def _serve(shell_file: str = shell) -> FileResponse:
|
||||
page = web_dir / shell_file
|
||||
if page.is_file():
|
||||
return FileResponse(page, media_type="text/html")
|
||||
raise RuntimeError( # pragma: no cover - deploy misconfig
|
||||
f"marketplace shell missing: {page} — rebuild the web export"
|
||||
)
|
||||
|
||||
router.get(path, include_in_schema=False)(_serve)
|
||||
|
||||
app.include_router(router)
|
||||
@@ -5,10 +5,12 @@ variant seed cross-check, no corpus scenarios. The proctor coaches:
|
||||
a pydantic-validated ProctorAssessment (JSON response, not SSE).
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..agents.proctor import ProctorAssessment
|
||||
from ..agents.proctor import ProctorAgent, ProctorAssessment
|
||||
from ..agents.registry import AgentRegistry
|
||||
from ..config import Settings
|
||||
from ..corpus.learner_context import get_learner_context
|
||||
@@ -41,7 +43,9 @@ async def proctor_signals(
|
||||
voice_store=Depends(get_voice_store),
|
||||
) -> ProctorAssessment:
|
||||
"""Real integrity inputs: live digest + defense signals + variant context."""
|
||||
agent = registry.get(provider, settings, "proctor")
|
||||
# P5: registry.get returns BaseAgent; "proctor" is registered as
|
||||
# ProctorAgent (register_builtin_agents) — the cast states that mapping.
|
||||
agent = cast(ProctorAgent, registry.get(provider, settings, "proctor"))
|
||||
learner_context = get_learner_context(body.learner_id)
|
||||
|
||||
trace = (
|
||||
|
||||
@@ -23,12 +23,14 @@ never part of the API contract.
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Set as AbstractSet
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..config import Settings
|
||||
from ..sandbox.capability import DEGRADE_REMEDIATION, sandbox_fabric_degraded
|
||||
from ..sandbox.manager import (
|
||||
PoolFullError,
|
||||
SandboxHandleInfo,
|
||||
@@ -36,7 +38,9 @@ from ..sandbox.manager import (
|
||||
SandboxNotFoundError,
|
||||
)
|
||||
from ..sandbox.workdir import SandboxDir
|
||||
from ..variants.templates import get_template
|
||||
from .deps import get_sandbox_manager, get_sandbox_test_layout, get_settings
|
||||
from .identity import require_verified_age
|
||||
|
||||
router = APIRouter(prefix="/v1/sandboxes", tags=["sandboxes"])
|
||||
|
||||
@@ -139,12 +143,31 @@ def _check_global_create_rate(settings: Settings) -> None:
|
||||
@router.post("", status_code=201, response_model=SandboxResponse)
|
||||
async def create_sandbox(
|
||||
body: SandboxCreateRequest,
|
||||
request: Request,
|
||||
manager: SandboxManager = Depends(get_sandbox_manager),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> SandboxResponse:
|
||||
# v0.5 identity gate (D-043, REQ-5-004): allowlist (G-5) → identity
|
||||
# verdict (403 + verify-CTA) → caps (429) — the binding composition.
|
||||
_enforce_allowlist(body.learner_id, settings)
|
||||
await require_verified_age(
|
||||
16, body.learner_id, request.app.state.identity_store
|
||||
)
|
||||
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
|
||||
_check_global_create_rate(settings)
|
||||
# v0.8 honest degrade (D-068/G-208): when the namespace fabric cannot
|
||||
# work on this host (unprivileged container under docker's default
|
||||
# seccomp), env creation is rejected BEFORE the manager is touched —
|
||||
# same 503 class as PoolFullError, carrying the shared remediation
|
||||
# constant (the boot notice logs the same text; they cannot drift).
|
||||
# Structural backends (test stubs — no userns involved) declare
|
||||
# `structural = True` and are exempt: the stub-backed suite must stay
|
||||
# green inside job containers (D-063) while the REAL fabric degrades
|
||||
# honestly everywhere.
|
||||
manager_backend = getattr(manager, "_backend", None)
|
||||
structural = getattr(manager_backend, "structural", False)
|
||||
if not structural and sandbox_fabric_degraded():
|
||||
raise HTTPException(status_code=503, detail=DEGRADE_REMEDIATION)
|
||||
try:
|
||||
info = await manager.create(body.learner_id, task_id=body.task_id)
|
||||
except PoolFullError as exc:
|
||||
@@ -253,7 +276,7 @@ class ExecResponse(BaseModel):
|
||||
async def _workspace_dir(manager: SandboxManager, sandbox_id: str):
|
||||
"""Resolve the sandbox workspace (tracked layout or shell layout)."""
|
||||
info = await manager.get(sandbox_id) # raises SandboxNotFoundError -> 404
|
||||
backend = manager._backend # noqa: SLF001 - API owns the composition seam
|
||||
backend = manager._backend
|
||||
tracked = getattr(backend, "_tracked", {}).get(sandbox_id)
|
||||
if tracked is not None:
|
||||
return tracked.workspace, info
|
||||
@@ -335,19 +358,80 @@ 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(
|
||||
# P5: AbstractSet[str] — the caller unions frozenset + set; membership
|
||||
# is all this function does with it.
|
||||
cmd: list[str], environment: str | None, allowed: AbstractSet[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
|
||||
if allowed is None:
|
||||
allowed = _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:
|
||||
await manager.get(sandbox_id)
|
||||
except SandboxNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"no sandbox {sandbox_id!r}") from None
|
||||
backend = manager._backend # noqa: SLF001 - API owns the composition seam
|
||||
handle = manager._handles.get(sandbox_id) # noqa: SLF001
|
||||
backend = manager._backend
|
||||
handle = manager._handles.get(sandbox_id)
|
||||
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)
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Dynamic sitemap.xml + robots.txt (REQ-6-006, D-056, A-411).
|
||||
|
||||
Next `app/sitemap.ts` cannot work under `output: 'export'` — it
|
||||
enumerates at BUILD time and cannot see runtime postings. These plain
|
||||
FastAPI routes enumerate at REQUEST time: the static page list (a code
|
||||
constant) + every PUBLISHED posting (via the G-101 catch-all shells,
|
||||
each URL resolves — G-109, the sitemap never advertises what the
|
||||
deploy 404s).
|
||||
|
||||
Mount order (probe 6, verified): API routes beat the StaticFiles("/")
|
||||
export mount, so these win in single-port production. robots.txt
|
||||
points at the sitemap; per the robots spec the Sitemap directive must
|
||||
be an ABSOLUTE URL.
|
||||
|
||||
Unset AI_PUBLIC_BASE_URL (G-105): loud G-11 boot warning (main.py);
|
||||
the sitemap serves the EMPTY static-page set with a warning comment
|
||||
(never malformed or relative URLs) and robots omits the Sitemap
|
||||
directive; fail honest at request time; boot never crashes.
|
||||
|
||||
Pilot scale bound (A-403): postings are enumerated with limit=5000 —
|
||||
the tail beyond 5000 published postings is NOT listed (no sitemap
|
||||
pagination in v0.6; a sitemap index lands with the third adapter).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.sax.saxutils import escape as _xml_escape
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..config import Settings
|
||||
from .deps import get_marketplace_store, get_settings
|
||||
|
||||
router = APIRouter(include_in_schema=False)
|
||||
|
||||
#: The static page set (A-411 build-time-static pages) — code constant;
|
||||
#: runtime postings append at request time. G-109: every path is the
|
||||
#: page's real export path (the pricing page exports under
|
||||
#: /marketplace/pricing/, never /pricing/).
|
||||
STATIC_PAGES: tuple[str, ...] = (
|
||||
"/",
|
||||
"/catalog",
|
||||
"/marketplace",
|
||||
"/marketplace/pricing",
|
||||
)
|
||||
|
||||
|
||||
def _origin(settings: Settings) -> str | None:
|
||||
raw = (settings.public_base_url or "").strip().rstrip("/")
|
||||
if not raw:
|
||||
return None
|
||||
if not raw.startswith(("http://", "https://")):
|
||||
return None # malformed origin — never emit a bad absolute URL
|
||||
return raw
|
||||
|
||||
|
||||
@router.get("/sitemap.xml")
|
||||
async def sitemap(
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> Response:
|
||||
"""Published postings + the static page set, as absolute URLs from
|
||||
AI_PUBLIC_BASE_URL. Unset origin → the honest empty set (G-105)."""
|
||||
origin = _origin(settings)
|
||||
if origin is None:
|
||||
# G-105: fail honest — never relative/malformed URLs in a
|
||||
# sitemap (spec requires absolute).
|
||||
return Response(
|
||||
content="<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<!-- sitemap disabled: AI_PUBLIC_BASE_URL is unset — "
|
||||
"set the deployed origin to serve this sitemap -->\n"
|
||||
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
|
||||
"</urlset>\n",
|
||||
media_type="application/xml",
|
||||
)
|
||||
urls: list[str] = []
|
||||
for page in STATIC_PAGES:
|
||||
urls.append(f"{origin}{page}")
|
||||
# Runtime postings — every published one resolves via the G-101
|
||||
# catch-all shells (trailingSlash: true → /marketplace/jobs/{id}/).
|
||||
# Pilot cap (A-403, ≤10⁴ postings): a single sitemap page holds
|
||||
# ≤50,000 URLs; beyond 5000 postings the tail is dropped — the
|
||||
# honest bound recorded in the module docstring (no pagination in
|
||||
# v0.6; a sitemap index lands with the 3M-corpus adapter).
|
||||
postings, _total = store.list_postings(limit=5000, offset=0)
|
||||
for posting in postings:
|
||||
urls.append(f"{origin}/marketplace/jobs/{posting.id}/")
|
||||
body = ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>"]
|
||||
body.append(
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||
)
|
||||
for url in urls:
|
||||
# XML-escape the loc text — the origin is founder-set env, but
|
||||
# defense-in-depth: metacharacters in it must never break out
|
||||
# of the <loc> element (verified: raw <script> would otherwise
|
||||
# inject into the response body).
|
||||
body.append("<url>")
|
||||
body.append(f"<loc>{_xml_escape(url)}</loc>")
|
||||
body.append("</url>")
|
||||
body.append("</urlset>")
|
||||
return Response(content="\n".join(body) + "\n", media_type="application/xml")
|
||||
|
||||
|
||||
@router.get("/robots.txt")
|
||||
async def robots(settings: Settings = Depends(get_settings)) -> Response:
|
||||
"""Crawl policy: allow all; the Sitemap directive is ABSOLUTE
|
||||
(spec) — omitted entirely when the origin is unset (G-105)."""
|
||||
lines = ["User-agent: *", "Allow: /"]
|
||||
origin = _origin(settings)
|
||||
if origin is not None:
|
||||
# The origin is founder-set env, but defense-in-depth: strip
|
||||
# control characters (newline smuggling could forge extra
|
||||
# robots directives — a newline in the origin once appended
|
||||
# "Disallow: /admin" as a directive line).
|
||||
safe = "".join(c for c in origin if ord(c) >= 32)
|
||||
lines.append(f"Sitemap: {safe}/sitemap.xml")
|
||||
return Response(content="\n".join(lines) + "\n", media_type="text/plain")
|
||||
@@ -35,20 +35,33 @@ from ..telemetry.ingest import (
|
||||
)
|
||||
from ..telemetry.models import TelemetryEvent
|
||||
from ..telemetry.store import TraceStore
|
||||
from ..telemetry.tokens import IngestToken
|
||||
from .deps import get_trace_integrity, get_trace_store
|
||||
|
||||
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
|
||||
#: Browser Origins allowed to open the ingest socket (A-008 mirror, D-038).
|
||||
#: 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(
|
||||
#: In network mode the configured CORS list governs (default '*' — any
|
||||
#: origin, since credentials are never used); an explicit list still rejects
|
||||
#: unlisted origins with 1008.
|
||||
_LOCAL_WS_ORIGINS = frozenset(
|
||||
{"http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8420"}
|
||||
)
|
||||
|
||||
|
||||
def _allowed_ws_origins(settings: object) -> frozenset[str]:
|
||||
configured = getattr(settings, "cors_origin_list", None)
|
||||
if configured is None:
|
||||
return _LOCAL_WS_ORIGINS
|
||||
if configured == ["*"]:
|
||||
return frozenset() # empty = wildcard = every Origin passes
|
||||
return frozenset(configured) | _LOCAL_WS_ORIGINS
|
||||
|
||||
|
||||
# --- WS ingest (D-026) ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -60,10 +73,12 @@ async def telemetry_ingest_ws(websocket: WebSocket) -> None:
|
||||
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:
|
||||
allowed = _allowed_ws_origins(getattr(websocket.app.state, "settings", None))
|
||||
if origin and allowed and origin not in allowed:
|
||||
# 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.
|
||||
# Wildcard (empty frozenset) passes every Origin in network mode.
|
||||
await websocket.close(
|
||||
code=1008, reason=f"origin {origin!r} not allowed for telemetry ingest"
|
||||
)
|
||||
@@ -76,6 +91,20 @@ async def telemetry_ingest_ws(websocket: WebSocket) -> None:
|
||||
# cheapest denial and unambiguous for the stdlib capture agent.
|
||||
await websocket.close(code=1008, reason="learner_id/task_id query params required")
|
||||
return
|
||||
# v0.4.7 (P1): HMAC ingest token. The socket is a grading input; in
|
||||
# network mode it is LAN-reachable and identity params were self-
|
||||
# asserted — anyone could poison/flood a learner's trace (grading DoS).
|
||||
# The manager bakes mint tokens into NC_INGEST_URL; verify here. The
|
||||
# token binds (learner_id, task_id, sandbox_id) to the process secret.
|
||||
token = query.get("token", "")
|
||||
verifier: IngestToken | None = getattr(websocket.app.state, "ingest_token", None)
|
||||
if verifier is None or not verifier.verify(
|
||||
token, learner_id, task_id, query.get("sandbox_id", "")
|
||||
):
|
||||
await websocket.close(
|
||||
code=1008, reason="missing or invalid ingest token"
|
||||
)
|
||||
return
|
||||
await telemetry_ingest_endpoint(
|
||||
websocket=websocket,
|
||||
learner_id=learner_id,
|
||||
|
||||
@@ -33,13 +33,29 @@ tests/api/test_variants.py asserts this end-to-end through the API.
|
||||
from datetime import datetime
|
||||
from typing import Self
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from ..config import Settings
|
||||
from ..identity.store import IdentityStore
|
||||
from ..variants.generator import VariantGenerator
|
||||
from ..variants.store import VariantRecord, VariantStore
|
||||
from ..variants.templates import TaskTemplate, get_template, template_for_competency
|
||||
from .deps import get_variant_generator, get_variant_store
|
||||
from .deps import get_settings, get_variant_generator, get_variant_store
|
||||
from .identity import require_verified_age
|
||||
|
||||
|
||||
def _identity_store(request: Request) -> IdentityStore:
|
||||
return request.app.state.identity_store
|
||||
|
||||
|
||||
def _enforce_allowlist(learner_id: str, settings: Settings) -> None:
|
||||
"""G-5 pilot guard — allowlist runs FIRST in the composition (D-043)."""
|
||||
if learner_id not in settings.learner_allowlist:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"learner_id {learner_id!r} is not on the sandbox allowlist (G-5)",
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/v1/variants", tags=["variants"])
|
||||
|
||||
@@ -81,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
|
||||
|
||||
|
||||
@@ -138,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,
|
||||
)
|
||||
|
||||
@@ -148,12 +170,19 @@ def _to_response(record: VariantRecord, competency_id: str) -> VariantResponse:
|
||||
@router.post("", response_model=VariantResponse)
|
||||
async def generate_variant(
|
||||
body: VariantGenerateRequest,
|
||||
request: Request,
|
||||
generator: VariantGenerator = Depends(get_variant_generator),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> VariantResponse:
|
||||
"""The learner's variant for the resolved template — generated on the
|
||||
first request, cached (no LLM call) on every repeat: D-029 makes a
|
||||
regenerate a 200 of the SAME stored variant.
|
||||
|
||||
v0.5 identity gate (D-043, REQ-5-004): the school floor is 16+ verified.
|
||||
Composition order: G-5 allowlist (403) → identity verdict (403 + CTA).
|
||||
"""
|
||||
_enforce_allowlist(body.learner_id, settings)
|
||||
await require_verified_age(16, body.learner_id, _identity_store(request))
|
||||
template = _resolve_template(body)
|
||||
record = await generator.generate(body.learner_id, template.id)
|
||||
return _to_response(record, competency_id=template.competency_id)
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
"""Service settings — pydantic-settings, env prefix AI_, .env support."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic import SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
# apps/ai-service/ (sandbox dir default is relative to the app, not the CWD)
|
||||
_SERVICE_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# v0.3.6: durable runtime state lives OUTSIDE the repo (founder directive —
|
||||
# the repo is a git tree, not a data dir; old defaults under apps/ai-service/
|
||||
# polluted the working copy). ~/.nextcraft is the state root for DB + sandbox
|
||||
# workdirs; env overrides may use ~/ paths (expanded by the validator below).
|
||||
_STATE_ROOT = Path.home() / ".nextcraft"
|
||||
|
||||
# G-16: TTS response-format whitelist (feeds the TTS route's Content-Type).
|
||||
_TTS_FORMATS = ("mp3", "wav", "opus")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="AI_", env_file=".env", extra="ignore")
|
||||
@@ -26,7 +36,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# v0.3 sandbox fabric (REQ-3-001): root holding per-sandbox workdirs.
|
||||
# Relative paths resolve against the app dir (apps/ai-service/), not the CWD.
|
||||
sandbox_dir: Path = _SERVICE_ROOT / "sandboxes"
|
||||
sandbox_dir: Path = _STATE_ROOT / "sandboxes"
|
||||
|
||||
# D-032: single-box capacity, no queue — pool full → API maps to 503.
|
||||
sandbox_max_concurrent: int = 5
|
||||
@@ -63,7 +73,40 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
|
||||
# D-027: SQLite path for telemetry/grades/variants/defenses stores.
|
||||
db_path: Path = _SERVICE_ROOT / "ai_service" / "data" / "nextcraft.db"
|
||||
# v0.3.6: default moved out of the repo to the home state root.
|
||||
db_path: Path = _STATE_ROOT / "data" / "nextcraft.db"
|
||||
|
||||
# v0.3.6 single-port deploy: directory of the exported Next.js app
|
||||
# (apps/web/out). When set, the app serves it at / (StaticFiles) so the
|
||||
# whole site — UI + API — answers on one port behind HAProxy; the web
|
||||
# build bakes NEXT_PUBLIC_AI_SERVICE_URL=self (relative same-origin
|
||||
# fetches). Empty default → no mount; dev/tests unchanged.
|
||||
web_static_dir: Path = Path("")
|
||||
|
||||
@field_validator("db_path", "sandbox_dir", "web_static_dir", mode="before")
|
||||
@classmethod
|
||||
def _expanduser_paths(cls, value: object) -> object:
|
||||
# Env-set paths may use ~ (e.g. AI_DB_PATH=~/.nextcraft/x.db);
|
||||
# pydantic Path does not expand it natively.
|
||||
if isinstance(value, str):
|
||||
return Path(value).expanduser()
|
||||
return value
|
||||
|
||||
@field_validator("voice_tts_format")
|
||||
@classmethod
|
||||
def _validate_tts_format(cls, value: str) -> str:
|
||||
# G-16 + G-11 consistency: unknown values NEVER crash the boot —
|
||||
# fall back to the default with a loud warning (the boot-survival
|
||||
# log lives in main.py's voice fallback; this validator normalizes).
|
||||
v = value.strip().lower()
|
||||
if v not in _TTS_FORMATS:
|
||||
logging.getLogger(__name__).warning(
|
||||
"AI_VOICE_TTS_FORMAT=%r is not one of %s — falling back to 'mp3'",
|
||||
value,
|
||||
_TTS_FORMATS,
|
||||
)
|
||||
return "mp3"
|
||||
return v
|
||||
|
||||
# G-3 flood control (NOT backpressure-by-silence): max events ingested per
|
||||
# (learner_id, task_id) trace before the WS endpoint closes the connection
|
||||
@@ -78,10 +121,95 @@ class Settings(BaseSettings):
|
||||
# `port` (A-004); only the host is configurable — never a second port.
|
||||
telemetry_ingest_host: str = "127.0.0.1"
|
||||
|
||||
# Voice provider selection (REQ-3-006, D-030): 'mock' (default — the
|
||||
# no-key path is first-class; tests never call a real voice API) or
|
||||
# 'browser' (browser-native SpeechRecognition/speechSynthesis fallback;
|
||||
# the descriptor tells the web client). The real server STT/TTS
|
||||
# ('openai-audio') is a v0.4 seam (GRILL CUT-1 / G-7) — AI_VOICE_BASE_URL
|
||||
# and AI_VOICE_API_KEY are documented in .env.example for that future.
|
||||
# v0.4.7 (P1, final-review flag): HMAC secret for WS ingest tokens. The
|
||||
# ingest socket is reachable on the LAN in network mode; without a token
|
||||
# check anyone could dial with arbitrary learner_id/task_id and poison or
|
||||
# flood traces (grading DoS). The manager bakes an HMAC of
|
||||
# (learner_id, task_id, sandbox_id) into NC_INGEST_URL; the WS endpoint
|
||||
# verifies it. Unset → per-boot random secret (in-process mint/verify
|
||||
# agree; restart invalidates stale URLs — sandboxes are not reboot-
|
||||
# persistent, so that is harmless). Env-set for multi-process deploys.
|
||||
telemetry_ingest_secret: str = ""
|
||||
|
||||
# v0.3.5 network mode (D-038): dev.sh binds 0.0.0.0 so remote browsers can
|
||||
# reach the stack; '*' (default) lets any origin call the API (safe ONLY
|
||||
# because credentials are never enabled — A-008). Set a comma-separated
|
||||
# origin list (e.g. 'http://nextcraft-1:3000') to restrict instead.
|
||||
# NOTE (v0.3.6): with the UI served same-origin from 8420 this is moot in
|
||||
# production (same-origin requests skip CORS); it stays for the two-port
|
||||
# dev topology.
|
||||
cors_origins: str = "*"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
value = self.cors_origins.strip()
|
||||
if value == "*":
|
||||
return ["*"]
|
||||
return [o.strip() for o in value.split(",") if o.strip()]
|
||||
|
||||
# Identity provider selection (REQ-5-003, A-303): 'mock' (default —
|
||||
# deterministic, no vendor spend pre-pilot; verdicts carry mock=True
|
||||
# forever per A-304). A real KYC vendor drops in via the
|
||||
# IdentityProvider protocol without API changes.
|
||||
identity_provider: str = "mock"
|
||||
# G-13: identity submit caps — one active pending per learner (409 on
|
||||
# resubmit) and a per-learner submit rate ceiling.
|
||||
identity_submits_per_min: int = 3
|
||||
|
||||
# v0.6 marketplace (REQ-6-001, D-057): the founder/admin surface is
|
||||
# guarded by a shared secret — X-Admin-Token or Authorization: Bearer
|
||||
# compared via secrets.compare_digest (G-108). Env-only (D-014), never
|
||||
# logged; SecretStr keeps it out of accidental repr/logs. UNSET =
|
||||
# every /v1/marketplace/admin/* route 403s — fail closed, NEVER open
|
||||
# (a loud boot warning names the fix, G-11).
|
||||
admin_token: SecretStr = SecretStr("")
|
||||
|
||||
# v0.6 P5 placeholder (D-056): the deployed origin for sitemap.xml /
|
||||
# robots.txt absolute URLs (consumed by the SEO phase; documented
|
||||
# now so operators see it beside the admin token).
|
||||
public_base_url: str = ""
|
||||
|
||||
# Embedding provider selection (REQ-6-003, D-051): 'mock' (default —
|
||||
# deterministic 256-dim feature-hash, pure stdlib; tests never call
|
||||
# a real embeddings API) or 'openai-embed' (real embeddings against
|
||||
# an OpenAI-compatible endpoint). openai-embed requires
|
||||
# embed_base_url + embed_api_key + embed_model; when unconfigured the
|
||||
# lifespan falls back to mock with a loud log (G-11 — a typo'd env
|
||||
# must never crash the unattended boot).
|
||||
embed_provider: str = "mock"
|
||||
|
||||
# Real embeddings (D-051, probe-corrected): ollama-cloud does NOT
|
||||
# expose /embeddings — point AI_EMBED_BASE_URL at another
|
||||
# OpenAI-compatible endpoint (e.g. LOCAL ollama at :11434/v1, or any
|
||||
# embeddings cloud). Keys env-only, never committed, never logged
|
||||
# (SecretStr; mirrors the admin token).
|
||||
embed_base_url: str = ""
|
||||
embed_api_key: SecretStr = SecretStr("")
|
||||
embed_model: str = ""
|
||||
|
||||
# Voice provider selection (REQ-5-001, D-040): 'mock' (default — the
|
||||
# no-key path is first-class; tests never call a real voice API),
|
||||
# 'browser' (client-native SR/TTS; the descriptor tells the web client),
|
||||
# or 'openai-audio' (real server STT/TTS against an OpenAI-compatible
|
||||
# audio endpoint). openai-audio requires voice_base_url + voice_api_key;
|
||||
# when unconfigured the lifespan falls back to mock with a loud log
|
||||
# (G-11 — a typo'd env must never crash the unattended boot).
|
||||
voice_provider: str = "mock"
|
||||
|
||||
# Real server voice (D-040, A-301): endpoint-agnostic by config (D-014
|
||||
# pattern) — any OpenAI-compatible audio API works. Keys env-only,
|
||||
# never committed, never logged (mirrors ollama_cloud_api_key).
|
||||
voice_base_url: str = ""
|
||||
voice_api_key: str = ""
|
||||
voice_stt_model: str = "whisper-1"
|
||||
voice_tts_model: str = "tts-1"
|
||||
voice_tts_voice: str = "alloy"
|
||||
# G-16: whitelist, not free string — this feeds the TTS route's
|
||||
# Content-Type. A str + mode-after validator (NOT a pydantic Literal):
|
||||
# a Literal would raise ValidationError at Settings construction, before
|
||||
# main.py's G-11 fallback could catch it — crashing the unattended boot
|
||||
# on a typo'd env. Invalid values fall back to the default LOUDLY.
|
||||
voice_tts_format: str = "mp3"
|
||||
# A-302/D-041: upload guard before the provider call (webm/opus is
|
||||
# ~0.5-1MB/min, so 10MB tolerates very long answers).
|
||||
voice_max_audio_mb: int = 10
|
||||
|
||||
@@ -5,9 +5,11 @@ grounding (Task 6-1-04): no production code path imports this module. Retained a
|
||||
calibration history.
|
||||
|
||||
|
||||
Counterpart: packages/mock-data/ai-scenarios.ts (artifact IDs string-identical,
|
||||
D-021). Real process-trace grading is a v0.3+ engine (assessment engine);
|
||||
these pre-baked submissions stand in for artifact + defense evaluation.
|
||||
Historical note: artifact IDs were once string-synced with a TS counterpart
|
||||
(packages/mock-data/ai-scenarios.ts, D-021) — that module was deleted in
|
||||
v0.7 P4 (dead export); this corpus is the single source. Real process-trace
|
||||
grading is a v0.3+ engine (assessment engine); these pre-baked submissions
|
||||
stand in for artifact + defense evaluation.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -5,9 +5,11 @@ no production code path imports this module. Retained as Phase-3 calibration his
|
||||
(corpus/trace_fixtures.py references it from TESTS only).
|
||||
|
||||
|
||||
Counterpart: packages/mock-data/ai-scenarios.ts (scenario IDs string-identical,
|
||||
D-021). Real sandbox telemetry is a v0.3+ engine (sandbox fabric); these
|
||||
scripted event streams stand in for the build-session process trace.
|
||||
Historical note: scenario IDs were once string-synced with a TS counterpart
|
||||
(packages/mock-data/ai-scenarios.ts, D-021) — that module was deleted in
|
||||
v0.7 P4 (dead export); this corpus is the single source. Real sandbox
|
||||
telemetry is a v0.3+ engine (sandbox fabric); these scripted event streams
|
||||
stand in for the build-session process trace.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -66,7 +66,7 @@ from .features import compute_digest
|
||||
from .store import GradeRecord, GradeStore
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - protocol-only import for the optional
|
||||
from ..variants.store import VariantStore # noqa: TC001 (variant-blind without it)
|
||||
from ..variants.store import VariantStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,7 +83,7 @@ VERDICT_GRADED: Final = "GRADED"
|
||||
_INTEGRITY_FLAG_KEY: Final = "integrity_flag"
|
||||
|
||||
|
||||
def _anchors_context(variant) -> str: # noqa: ANN001 - VariantRecord (duck-typed)
|
||||
def _anchors_context(variant) -> str:
|
||||
"""Render the variant template's difficulty anchors for the grader prompt.
|
||||
|
||||
Contains only the template id + anchor numbers — no learner-identifying
|
||||
@@ -279,7 +279,7 @@ class GradingEngine:
|
||||
created_at=datetime.now(tz=UTC),
|
||||
)
|
||||
|
||||
def _lookup_variant(self, task_id: str): # noqa: ANN202 - VariantRecord | None
|
||||
def _lookup_variant(self, task_id: str):
|
||||
"""MH#4: resolve the graded task's variant (None when not variant-derived)."""
|
||||
if self._variant_store is None:
|
||||
return None
|
||||
|
||||
@@ -37,7 +37,7 @@ from typing import Any, Protocol
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import JSON, Index
|
||||
from sqlalchemy.orm import validates
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
|
||||
@@ -225,7 +225,9 @@ class SQLiteGradeStore:
|
||||
.where(GradeRecord.learner_id == learner_id)
|
||||
# Chronological; task_id is a deterministic tie-break for
|
||||
# grades stamped within the same instant.
|
||||
.order_by(GradeRecord.created_at, GradeRecord.task_id)
|
||||
# P5: col() — SQLModel class attrs are Mapped[T], the
|
||||
# column reference rides col() in order_by.
|
||||
.order_by(col(GradeRecord.created_at), GradeRecord.task_id)
|
||||
)
|
||||
results = session.exec(stmt).all()
|
||||
for row in results:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Identity engine package — provider protocol + store (REQ-5-003, D-042)."""
|
||||
|
||||
from .base import (
|
||||
AgeBand,
|
||||
IdentityProvider,
|
||||
IdentityStatus,
|
||||
IdentitySubmission,
|
||||
IdentityVerdict,
|
||||
)
|
||||
from .mock import MockIdentityProvider, derive_age_band
|
||||
from .store import IdentityRecord, IdentityStore, SQLiteIdentityStore
|
||||
|
||||
__all__ = [
|
||||
"AgeBand",
|
||||
"IdentityStatus",
|
||||
"IdentitySubmission",
|
||||
"IdentityVerdict",
|
||||
"IdentityProvider",
|
||||
"IdentityRecord",
|
||||
"IdentityStore",
|
||||
"SQLiteIdentityStore",
|
||||
"MockIdentityProvider",
|
||||
"derive_age_band",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Identity verification protocol (REQ-5-003, D-042, A-303).
|
||||
|
||||
Provider-agnostic like LLMProvider/VoiceProvider (D-014/D-030): a narrow
|
||||
protocol the identity API composes via DI, a deterministic mock, and a
|
||||
future real KYC vendor (Stripe Identity / Persona / Onfido class) that
|
||||
drops in without API changes. PII rules (A-305): the provider sees
|
||||
document REFERENCES, never raw documents; verdicts carry a mock marker
|
||||
(A-304) so downstream surfaces never display mock-verified as
|
||||
production-verified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
AgeBand = Literal["16-17", "18+"]
|
||||
IdentityStatus = Literal["pending", "verified", "rejected"]
|
||||
|
||||
|
||||
class IdentitySubmission(BaseModel):
|
||||
"""What a learner submits: derived data + document refs only.
|
||||
|
||||
`date_of_birth` is a REAL date (the provider derives the age band) but
|
||||
raw DOB is NEVER persisted — only the derived band (A-305). Document
|
||||
refs are opaque handles (upload ids), never contents.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
date_of_birth: str = Field(description="ISO date; used to derive age_band, never stored")
|
||||
document_refs: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Opaque upload handles; raw documents are never stored",
|
||||
)
|
||||
|
||||
|
||||
class IdentityVerdict(BaseModel):
|
||||
"""Provider verdict — what gets stored + surfaced."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
status: IdentityStatus
|
||||
age_band: AgeBand | None = None
|
||||
provider: str
|
||||
#: A-304 honesty: mock verdicts carry mock=True forever — downstream
|
||||
#: surfaces must never treat a mock verdict as production-verified.
|
||||
mock: bool = True
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityProvider(Protocol):
|
||||
"""The KYC port: submit → (poll) → verdict. Never imports api/."""
|
||||
|
||||
async def submit(self, submission: IdentitySubmission) -> str:
|
||||
"""Start verification; returns a submission id (minted once)."""
|
||||
...
|
||||
|
||||
async def poll(self, submission_id: str) -> IdentityVerdict:
|
||||
"""Fetch the (possibly pending) verdict for a submission."""
|
||||
...
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Deterministic mock identity provider (REQ-5-003, A-303).
|
||||
|
||||
Approve-on-policy: every submission verifies unless the caller scripts a
|
||||
rejection (by learner id) or the derived age band fails the floor
|
||||
(under-16 → rejected with an age detail). Verdicts are mock-marked (A-304)
|
||||
— the marker rides every verdict so no downstream surface can ever
|
||||
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
|
||||
|
||||
|
||||
def derive_age_band(date_of_birth: str, today: datetime | None = None) -> str:
|
||||
"""Derive the age band from an ISO date. Under-16 returns "under-16".
|
||||
|
||||
Pure + deterministic; used by the store test and the API alike.
|
||||
"""
|
||||
dob = datetime.fromisoformat(date_of_birth)
|
||||
now = today or datetime.now(UTC)
|
||||
age = now.year - dob.year - (
|
||||
(now.month, now.day) < (dob.month, dob.day)
|
||||
)
|
||||
if age < 16:
|
||||
return "under-16"
|
||||
if age < 18:
|
||||
return "16-17"
|
||||
return "18+"
|
||||
|
||||
|
||||
class MockIdentityProvider:
|
||||
"""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:
|
||||
submission_id = f"idc-{self._nonce:08x}{next(self._counter):08x}"
|
||||
self._submissions[submission_id] = submission
|
||||
return submission_id
|
||||
|
||||
async def poll(self, submission_id: str) -> IdentityVerdict:
|
||||
submission = self._submissions.get(submission_id)
|
||||
if submission is None:
|
||||
return IdentityVerdict(
|
||||
status="rejected",
|
||||
provider="mock",
|
||||
mock=True,
|
||||
detail="unknown submission id",
|
||||
)
|
||||
if submission.learner_id in self._reject_learners:
|
||||
return IdentityVerdict(
|
||||
status="rejected",
|
||||
provider="mock",
|
||||
mock=True,
|
||||
detail="scripted rejection (test)",
|
||||
)
|
||||
band = derive_age_band(submission.date_of_birth)
|
||||
if band == "under-16":
|
||||
return IdentityVerdict(
|
||||
status="rejected",
|
||||
provider="mock",
|
||||
mock=True,
|
||||
detail="under 16 — the AI school floor is 16+ (COPPA avoidance)",
|
||||
)
|
||||
return IdentityVerdict(
|
||||
status="verified",
|
||||
# P5: derive_age_band returns str; the verdict's Literal contract —
|
||||
# the derived bands are in-contract by construction.
|
||||
age_band=band, # type: ignore[arg-type]
|
||||
provider="mock",
|
||||
mock=True,
|
||||
detail="mock verdict — not production verification",
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""IdentityStore — verification records (REQ-5-003, D-042, D-027 FIFTH store).
|
||||
|
||||
Insert-only + latest-per-learner lookup, modeled on the DefenseStore
|
||||
conventions: WAL + synchronous=NORMAL + busy_timeout + foreign_keys=ON
|
||||
pragmas at connect time, portable column types (str/datetime/JSON) for
|
||||
Postgres parity, @validates hooks for constraints sqlmodel's metaclass
|
||||
drops, tz-aware→naive→tz-aware boundary normalization.
|
||||
|
||||
PII contract (A-305): stores the DERIVED age_band (16-17 | 18+), NEVER a
|
||||
raw date of birth; document_refs are opaque handles, NEVER contents.
|
||||
Verdict provenance is audit data: every record carries provider + the
|
||||
mock marker (A-304) so downstream surfaces can label unverified state
|
||||
honestly.
|
||||
|
||||
Insert-only growth is fine at pilot scale (a-12): learner_id indexed,
|
||||
latest-per-learner lookup, no compaction pre-vendor.
|
||||
|
||||
Boundary (D-027): `identity/` never imports `agents/` / `api/`; this
|
||||
module imports config only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import event, func, text
|
||||
from sqlalchemy.types import JSON, String
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
from .base import IdentityStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IdentityRecord(SQLModel, table=True):
|
||||
"""One verification submission's lifecycle + verdict provenance."""
|
||||
|
||||
__tablename__ = "identity_record"
|
||||
|
||||
#: PK = the submission id minted once by the provider's submit().
|
||||
id: str = Field(primary_key=True)
|
||||
learner_id: str = Field(index=True)
|
||||
# Bare Literal annotations crash sqlmodel's column inference; explicit
|
||||
# sa_type + the validates hook below give the same contract
|
||||
# (VARCHAR column, Literal-rejected values — DefenseStore pattern).
|
||||
status: IdentityStatus = Field(default="pending", sa_type=String)
|
||||
provider: str
|
||||
#: Verdict provenance: the provider's raw verdict (mock-marked).
|
||||
verdict: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
#: DERIVED band only — raw DOB is never persisted (A-305).
|
||||
age_band: str | None = Field(default=None, sa_type=String)
|
||||
#: Opaque document handles — raw documents never stored (A-305).
|
||||
document_refs: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||
submitted_at: datetime
|
||||
verified_at: datetime | None = Field(default=None)
|
||||
|
||||
@property
|
||||
def mock(self) -> bool:
|
||||
"""A-304: the mock marker rides every surface (record + API)."""
|
||||
return bool(self.verdict.get("mock", True))
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id or not self.learner_id:
|
||||
raise ValueError("id and learner_id must be non-empty")
|
||||
if self.status not in ("pending", "verified", "rejected"):
|
||||
raise ValueError(f"invalid identity status {self.status!r}")
|
||||
# D3 (verifier): a stored band must be canonical or None (pending).
|
||||
# The gate fails closed on anything else; the store refuses to
|
||||
# create it in the first place.
|
||||
if self.age_band is not None and self.age_band not in (
|
||||
"16-17",
|
||||
"18+",
|
||||
"under-16",
|
||||
):
|
||||
raise ValueError(f"invalid age_band {self.age_band!r}")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.submitted_at = _as_utc(self.submitted_at)
|
||||
if self.verified_at is not None:
|
||||
self.verified_at = _as_utc(self.verified_at)
|
||||
|
||||
|
||||
def _as_utc(ts: datetime) -> datetime:
|
||||
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027 pattern)."""
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=UTC)
|
||||
return ts
|
||||
|
||||
|
||||
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
|
||||
"""Per-connection pragmas — mirrors the other D-027 stores (P5: the
|
||||
concrete sqlite3.Connection type removes the two attr-defined ignores
|
||||
the loose `object` annotation needed)."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
class IdentityStore(Protocol):
|
||||
"""Persistence contract for identity records."""
|
||||
|
||||
def insert(self, record: IdentityRecord) -> IdentityRecord:
|
||||
"""INSERT-ONLY: a duplicate id raises IntegrityError (surfaced, not
|
||||
swallowed — a submission id is minted once)."""
|
||||
...
|
||||
|
||||
def get(self, submission_id: str) -> IdentityRecord | None:
|
||||
"""Point lookup by submission id."""
|
||||
...
|
||||
|
||||
def latest_for_learner(self, learner_id: str) -> IdentityRecord | None:
|
||||
"""Newest record for the learner (or None)."""
|
||||
...
|
||||
|
||||
def count_pending_for_learner(self, learner_id: str) -> int:
|
||||
"""G-13: active pending submissions (cap = 1)."""
|
||||
...
|
||||
|
||||
def mark_verified(
|
||||
self, submission_id: str, verdict: dict[str, Any], age_band: str | None
|
||||
) -> IdentityRecord | None:
|
||||
"""Terminal transition (verified or rejected): stamp + store the
|
||||
provider verdict + derived band. Unknown id → None."""
|
||||
...
|
||||
|
||||
|
||||
class SQLiteIdentityStore:
|
||||
"""SQLite implementation of IdentityStore (D-027)."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path: Path = db_path if db_path is not None else Settings().db_path
|
||||
self._engine = create_engine(
|
||||
f"sqlite:///{self._db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
event.listen(self._engine, "connect", _sqlite_connect)
|
||||
SQLModel.metadata.create_all(self._engine)
|
||||
|
||||
def insert(self, record: IdentityRecord) -> IdentityRecord:
|
||||
record._validate()
|
||||
record._normalize()
|
||||
with Session(self._engine) as session:
|
||||
session.add(record)
|
||||
session.commit() # IntegrityError SURFACES (insert-only, minted-once)
|
||||
session.refresh(record)
|
||||
return record
|
||||
|
||||
def get(self, submission_id: str) -> IdentityRecord | None:
|
||||
with Session(self._engine) as session:
|
||||
rec = session.get(IdentityRecord, submission_id)
|
||||
if rec is None:
|
||||
return None
|
||||
session.refresh(rec)
|
||||
rec.submitted_at = _as_utc(rec.submitted_at)
|
||||
if rec.verified_at is not None:
|
||||
rec.verified_at = _as_utc(rec.verified_at)
|
||||
return rec
|
||||
|
||||
def latest_for_learner(self, learner_id: str) -> IdentityRecord | None:
|
||||
with Session(self._engine) as session:
|
||||
# D4 (verifier): submitted_at alone can tie at microsecond
|
||||
# resolution — sqlite rowid breaks the tie deterministically
|
||||
# (last inserted wins, mirroring insert-only chronology).
|
||||
# rowid is a SQLite physical column, not a SQLModel field — it
|
||||
# rides the query as raw text.
|
||||
rec = session.exec(
|
||||
select(IdentityRecord)
|
||||
.where(IdentityRecord.learner_id == learner_id)
|
||||
.order_by(
|
||||
# P5: col() — SQLModel class attrs are Mapped[T], the
|
||||
# column operators (.desc()) ride col().
|
||||
col(IdentityRecord.submitted_at).desc(),
|
||||
text("rowid DESC"),
|
||||
)
|
||||
).first()
|
||||
if rec is not None:
|
||||
rec.submitted_at = _as_utc(rec.submitted_at)
|
||||
if rec.verified_at is not None:
|
||||
rec.verified_at = _as_utc(rec.verified_at)
|
||||
return rec
|
||||
|
||||
def count_pending_for_learner(self, learner_id: str) -> int:
|
||||
with Session(self._engine) as session:
|
||||
return session.exec(
|
||||
select(func.count())
|
||||
.select_from(IdentityRecord)
|
||||
.where(
|
||||
IdentityRecord.learner_id == learner_id,
|
||||
IdentityRecord.status == "pending",
|
||||
)
|
||||
).one()
|
||||
|
||||
def mark_verified(
|
||||
self, submission_id: str, verdict: dict[str, Any], age_band: str | None
|
||||
) -> IdentityRecord | None:
|
||||
with Session(self._engine) as session:
|
||||
rec = session.get(IdentityRecord, submission_id)
|
||||
if rec is None:
|
||||
return None
|
||||
rec.verdict = verdict
|
||||
rec.age_band = age_band
|
||||
rec.verified_at = datetime.now(UTC)
|
||||
rec.status = "verified" if verdict.get("status") == "verified" else "rejected"
|
||||
rec._validate() # D3: transitions validate like inserts
|
||||
session.commit()
|
||||
session.refresh(rec)
|
||||
return rec
|
||||
|
||||
def close(self) -> None:
|
||||
self._engine.dispose()
|
||||
@@ -12,7 +12,11 @@ from .types import Message
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
async def stream_chat(
|
||||
# P5 (D-058): plain `def` returning AsyncIterator — the honest shape for
|
||||
# async-generator implementations. `async def -> AsyncIterator` makes mypy
|
||||
# see a coroutine WRAPPING the iterator (every call site would need an
|
||||
# extra await); async generators ARE functions returning AsyncIterator.
|
||||
def stream_chat(
|
||||
self,
|
||||
messages: list[Message],
|
||||
*,
|
||||
|
||||
@@ -9,6 +9,7 @@ import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from . import __version__
|
||||
from .agents.registry import AgentRegistry, register_builtin_agents
|
||||
from .agents.session import InMemorySessionStore
|
||||
from .api import (
|
||||
@@ -25,14 +26,23 @@ from .api import (
|
||||
from .config import Settings
|
||||
from .grading.engine import GradingEngine
|
||||
from .grading.store import SQLiteGradeStore
|
||||
from .identity.mock import MockIdentityProvider
|
||||
from .identity.store import SQLiteIdentityStore
|
||||
from .llm import create_provider
|
||||
from .sandbox import SandboxManager, UnshareBackend
|
||||
from .sandbox.capability import (
|
||||
DEGRADE_REMEDIATION,
|
||||
container_detected,
|
||||
sandbox_fabric_degraded,
|
||||
)
|
||||
from .telemetry.ingest import TraceIntegrityMap
|
||||
from .telemetry.store import SQLiteTraceStore
|
||||
from .telemetry.tokens import IngestToken
|
||||
from .variants.generator import VariantGenerator
|
||||
from .variants.store import SQLiteVariantStore
|
||||
from .voice.defense_store import SQLiteDefenseStore
|
||||
from .voice.factory import voice_provider_from_settings
|
||||
from .voice.mock import MockVoiceProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,9 +73,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
# app.state. Tests may pre-set app.state.sandbox_manager (dependency
|
||||
# override by state injection) to swap the backend; the lifespan then
|
||||
# adopts it instead of constructing the real UnshareBackend one.
|
||||
# v0.4.7 (P1): the ingest token is created BEFORE the manager (the
|
||||
# manager MINTS into NC_INGEST_URL; the WS endpoint VERIFIES) and
|
||||
# shared via app.state — one instance, so the per-boot random secret
|
||||
# (unset AI_TELEMETRY_INGEST_SECRET) agrees on both sides.
|
||||
if getattr(app.state, "ingest_token", None) is None:
|
||||
app.state.ingest_token = IngestToken(settings.telemetry_ingest_secret)
|
||||
manager = getattr(app.state, "sandbox_manager", None)
|
||||
if manager is None:
|
||||
manager = SandboxManager(backend=UnshareBackend(), settings=settings)
|
||||
manager = SandboxManager(
|
||||
backend=UnshareBackend(),
|
||||
settings=settings,
|
||||
ingest_token=app.state.ingest_token,
|
||||
)
|
||||
app.state.sandbox_manager = manager
|
||||
await manager.start() # a-1: reap on-disk orphans from a previous process
|
||||
|
||||
@@ -116,12 +136,93 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
defense_store = SQLiteDefenseStore(db_path=settings.db_path)
|
||||
app.state.defense_store = defense_store
|
||||
if getattr(app.state, "voice_provider", None) is None:
|
||||
app.state.voice_provider = voice_provider_from_settings(settings)
|
||||
# G-11 (boot survival): a misconfigured real provider must never
|
||||
# crash the unattended deploy — fall back to mock loudly. The
|
||||
# mock provider's descriptor honestly reports mode='mock' so the
|
||||
# UI badge cannot lie about which path is live.
|
||||
try:
|
||||
app.state.voice_provider = voice_provider_from_settings(
|
||||
settings, app.state.http_client
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"voice provider %r unavailable (%s); falling back to mock "
|
||||
"— fix the AI_VOICE_* settings and restart",
|
||||
settings.voice_provider,
|
||||
exc,
|
||||
)
|
||||
app.state.voice_provider = MockVoiceProvider()
|
||||
if getattr(app.state, "examiner_agent", None) is None:
|
||||
from .agents.examiner import ExaminerAgent
|
||||
|
||||
app.state.examiner_agent = ExaminerAgent(app.state.provider, settings)
|
||||
|
||||
# Identity verification (REQ-5-003): 5th D-027 store (same SQLite
|
||||
# file) + mock-first provider (A-303). State-injection overrides
|
||||
# preserved — tests may pre-set either.
|
||||
identity_store = getattr(app.state, "identity_store", None)
|
||||
if identity_store is None:
|
||||
identity_store = SQLiteIdentityStore(db_path=settings.db_path)
|
||||
app.state.identity_store = identity_store
|
||||
if getattr(app.state, "identity_provider", None) is None:
|
||||
app.state.identity_provider = MockIdentityProvider()
|
||||
|
||||
# Marketplace engine (v0.6, REQ-6-001): 6th D-027 store family on
|
||||
# the same SQLite file. State-injection override preserved.
|
||||
if getattr(app.state, "marketplace_store", None) is None:
|
||||
from .marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
app.state.marketplace_store = SQLiteMarketplaceStore(
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Embedding provider (v0.6 P3, REQ-6-003, D-051): mock-first
|
||||
# feature-hash default; openai-embed against any OpenAI-compatible
|
||||
# embeddings endpoint. State-injection override preserved (the
|
||||
# voice pattern). G-11 (boot survival): a misconfigured real
|
||||
# provider must never crash the unattended deploy — fall back to
|
||||
# mock loudly.
|
||||
if getattr(app.state, "embedding_provider", None) is None:
|
||||
from .marketplace.embedding.factory import (
|
||||
embedding_provider_from_settings,
|
||||
)
|
||||
from .marketplace.embedding.mock import MockEmbedProvider
|
||||
|
||||
try:
|
||||
app.state.embedding_provider = embedding_provider_from_settings(
|
||||
settings, app.state.http_client
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"embedding provider %r unavailable (%s); falling back to "
|
||||
"mock — fix the AI_EMBED_* settings and restart",
|
||||
settings.embed_provider,
|
||||
exc,
|
||||
)
|
||||
app.state.embedding_provider = MockEmbedProvider()
|
||||
|
||||
# G-11/D-057: admin routes fail CLOSED on an unset token, and the
|
||||
# founder must be told loudly at boot (never a silent lockout).
|
||||
if not settings.admin_token.get_secret_value():
|
||||
logger.warning(
|
||||
"AI_ADMIN_TOKEN is unset — /v1/marketplace/admin/* routes "
|
||||
"are 403 (fail-closed). Set AI_ADMIN_TOKEN in .env to "
|
||||
"author postings, run ingest, and review applications."
|
||||
)
|
||||
|
||||
# G-105 (P5/D-056): an unset origin makes the sitemap serve the
|
||||
# honest empty set and robots omit the Sitemap directive — the
|
||||
# founder must be told loudly at boot (G-11 doctrine; the
|
||||
# request-time fallback never emits malformed/relative URLs).
|
||||
if not (settings.public_base_url or "").strip():
|
||||
logger.warning(
|
||||
"AI_PUBLIC_BASE_URL is unset — /sitemap.xml serves the "
|
||||
"empty static-page set and robots.txt omits the Sitemap "
|
||||
"directive (spec requires absolute URLs). Set the deployed "
|
||||
"origin (e.g. https://nextcraft.example) in .env to serve "
|
||||
"the full sitemap."
|
||||
)
|
||||
|
||||
# Grading persistence + engine (REQ-3-004): GradeStore from the same
|
||||
# SQLite file as traces (D-027), one GradingEngine singleton wired
|
||||
# through app.state — the engine receives its stores via constructor
|
||||
@@ -154,6 +255,24 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
logger.exception("sandbox reaper pass failed; retrying next tick")
|
||||
|
||||
reaper = asyncio.create_task(_reaper_loop())
|
||||
|
||||
# v0.8 honest degrade (D-068/G-208): probe once at boot; if the
|
||||
# namespace fabric cannot work on this host (unprivileged container
|
||||
# under docker's default seccomp), say so LOUDLY once with the shared
|
||||
# remediation constant — the 503 arm in api/sandboxes.py carries the
|
||||
# same text, so the surfaces cannot drift. The probe is the truth
|
||||
# source; the container marker only tailors the message.
|
||||
if sandbox_fabric_degraded():
|
||||
if container_detected():
|
||||
logger.warning(
|
||||
"SANDBOX FABRIC DEGRADED (container detected): %s",
|
||||
DEGRADE_REMEDIATION,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"SANDBOX FABRIC DEGRADED: %s", DEGRADE_REMEDIATION
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -167,18 +286,21 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
grade_store.close()
|
||||
variant_store.close()
|
||||
defense_store.close()
|
||||
identity_store.close()
|
||||
await app.state.http_client.aclose()
|
||||
|
||||
app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Nextcraft AI Service", version=__version__, lifespan=lifespan)
|
||||
|
||||
# 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).
|
||||
# A-008 + D-038: no-credentials CORS. Default '*' admits remote-browser
|
||||
# origins in network mode (safe only because allow_credentials stays
|
||||
# False — never enable credentials with a wildcard). AI_CORS_ORIGINS
|
||||
# restricts to an explicit list. 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).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type"],
|
||||
allow_credentials=False,
|
||||
@@ -201,6 +323,114 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(telemetry_router)
|
||||
app.include_router(variants_router)
|
||||
app.include_router(defense_router)
|
||||
|
||||
# v0.5 identity (REQ-5-003/004): verification flow + age-gate deps.
|
||||
# v0.6: the G-18 marketplace stub moved to api/marketplace.py and is
|
||||
# REAL (D-058) — identity keeps only its own router + gate exports.
|
||||
from .api.identity import router as identity_router
|
||||
from .api.marketplace import router as marketplace_public_router
|
||||
from .api.marketplace_admin import router as marketplace_admin_router
|
||||
|
||||
app.include_router(identity_router)
|
||||
app.include_router(marketplace_public_router)
|
||||
app.include_router(marketplace_admin_router)
|
||||
|
||||
# v0.6 P5 (REQ-6-006, D-056): dynamic sitemap/robots — request-time
|
||||
# enumeration (build-time Next sitemap.ts cannot see runtime
|
||||
# postings under output:'export'); API routes beat the static mount.
|
||||
from .api.sitemap import router as sitemap_router
|
||||
|
||||
app.include_router(sitemap_router)
|
||||
|
||||
# A-305/D1: PII-safe 422s — FastAPI echoes the offending `input` in
|
||||
# validation errors by default; for the identity submit body that
|
||||
# leaks the raw DOB into responses + client logs. The handler scrubs
|
||||
# PII field inputs (scoped app-wide; harmless elsewhere).
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
def _scrub_pii_validation(request, exc):
|
||||
# P5: the old `# type: ignore[unused-arg]` was inert (the unused-arg
|
||||
# code is not enabled by this baseline); removed per D-058.
|
||||
pii_fields = frozenset({"date_of_birth"})
|
||||
scrubbed = []
|
||||
for err in exc.errors():
|
||||
err = dict(err)
|
||||
if err.get("loc") and err["loc"][-1] in pii_fields:
|
||||
# A-305/D1: never echo the submitted value (input) or the
|
||||
# ctx payload (carries the ValueError); the msg is the
|
||||
# constraint text and is safe.
|
||||
err["input"] = "[redacted]"
|
||||
err.pop("ctx", None)
|
||||
else:
|
||||
# FastAPI's default 422s are JSON-safe EXCEPT ctx payloads
|
||||
# carrying raw ValueError objects (pydantic model_validator
|
||||
# errors); strip ctx body-wide so non-PII routes keep their
|
||||
# 422 shape (msg + loc carry the meaning).
|
||||
ctx = err.get("ctx")
|
||||
if isinstance(ctx, dict):
|
||||
err["ctx"] = {
|
||||
k: v for k, v in ctx.items() if isinstance(v, (str, int, float, bool))
|
||||
}
|
||||
scrubbed.append(err)
|
||||
return JSONResponse(status_code=422, content={"detail": scrubbed})
|
||||
|
||||
app.add_exception_handler(RequestValidationError, _scrub_pii_validation)
|
||||
|
||||
# v0.6 G-101: Next 15 output:'export' hard-throws (E87) for dynamic
|
||||
# routes without generateStaticParams, so jobs/[jobId] and
|
||||
# employers/[employerId] export exactly ONE placeholder shell
|
||||
# (out/marketplace/jobs/_/index.html). These catch-all routes serve
|
||||
# that shell for ANY id — registered BEFORE the StaticFiles mount so
|
||||
# they win (the same routes-beat-the-mount mechanism the sitemap
|
||||
# relies on, G-109). The shell client-fetches the real posting by
|
||||
# the path id and notFound()s on 404 — a resolvable URL for every
|
||||
# sitemap entry.
|
||||
from .api.marketplace_shells import register_marketplace_shell_routes
|
||||
|
||||
register_marketplace_shell_routes(app, settings)
|
||||
|
||||
# v0.3.6 single-port deploy: serve the exported web app (apps/web/out)
|
||||
# from the SAME origin as the API when AI_WEB_STATIC_DIR is set. Mounted
|
||||
# AFTER all routers, so /v1/*, /health, /docs win; StaticFiles(html=True)
|
||||
# then resolves / → index.html, /dashboard/ → dashboard/index.html. A
|
||||
# 404 handler below serves the export's 404.html for unknown paths so
|
||||
# browsers see the site's not-found page instead of FastAPI's JSON.
|
||||
# Default unset → no mount, dev/tests see the plain API app.
|
||||
if str(settings.web_static_dir) not in ("", "."):
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
web_dir = settings.web_static_dir
|
||||
if not web_dir.is_dir():
|
||||
raise RuntimeError(
|
||||
f"AI_WEB_STATIC_DIR is set but {web_dir} does not exist — "
|
||||
"build the web app first (pnpm build) or unset the setting"
|
||||
)
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def _spa_404(request, exc):
|
||||
# P5: the old `# type: ignore[unused]` was inert (the unused code
|
||||
# is not enabled by this baseline); removed per D-058.
|
||||
not_found_page = web_dir / "404.html"
|
||||
if not_found_page.is_file():
|
||||
return FileResponse(not_found_page, status_code=404)
|
||||
raise exc
|
||||
|
||||
app.mount("/", StaticFiles(directory=web_dir, html=True), name="web")
|
||||
else:
|
||||
# v0.4.6: the v0.3.6/v0.4-era "site down" root cause was a silently
|
||||
# mount-less boot — behind HAProxy, / answers this app's JSON 404
|
||||
# and nothing detects it. Unattended deploys MUST be loud (G-11
|
||||
# doctrine). Dev/tests keep the no-mount default; production boxes
|
||||
# see a warning naming the fix.
|
||||
logger.warning(
|
||||
"AI_WEB_STATIC_DIR is unset — the exported web UI is NOT served; "
|
||||
"/ answers the API's JSON 404. For single-port deploys, build the "
|
||||
"web app (NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build) and run it "
|
||||
"via `nextcraft dev`/`dev -d` (auto-wires apps/web/out) or set "
|
||||
"AI_WEB_STATIC_DIR explicitly."
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Marketplace engine package (REQ-6-001, D-048/D-049).
|
||||
|
||||
The 6th engine module inside apps/ai-service (no new apps, no new ports,
|
||||
D-031 boundary): pydantic contracts (`base.py`), the D-027 store family's
|
||||
sixth SQLite store (`store.py`), and PostingSource ingest adapters
|
||||
(`ingest.py`). Tagging (P2), embedding (P3), enrichment + resume (P4)
|
||||
extend this package; the HTTP surface lives in `api/marketplace*.py`
|
||||
which composes this module via DI.
|
||||
|
||||
Boundary (D-031/D-048): `marketplace/` never imports `agents/` or
|
||||
`api/`; this package imports config + stdlib only.
|
||||
"""
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Marketplace pydantic contracts (REQ-6-001, D-048, A-402).
|
||||
|
||||
The wire + store shapes for the postings backend. `IngestPosting` is the
|
||||
A-402 adapter shape (what every PostingSource yields); `Posting` is the
|
||||
full row shape (status lifecycle + tags); `Employer` carries the raw
|
||||
ingested fields (enrichment + provenance merge is P4's read-time job).
|
||||
|
||||
D-051/D-053 note: the mock `matchScore` from the TS fixtures is NOT part
|
||||
of any contract — P3 computes real scores at search time; absent score =
|
||||
absent query context (G-106).
|
||||
|
||||
Boundary (D-048): mirrors D-031 — never imports `agents/` or `api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
PostingStatus = Literal["draft", "published", "closed"]
|
||||
|
||||
#: Lifecycle edge set (D-049/D-057): draft → published → closed. A
|
||||
#: transition not in this map is invalid (admin route → 409).
|
||||
VALID_TRANSITIONS: dict[str, str] = {
|
||||
"draft": "published",
|
||||
"published": "closed",
|
||||
}
|
||||
|
||||
Seniority = Literal["entry", "mid", "senior", "staff", "principal"]
|
||||
|
||||
#: P2 (D-052) enumerates the category taxonomy; the seed corpus carries
|
||||
#: category=null and P2's tagging pass fills it. The store accepts any
|
||||
#: short string (validation lands with the tagger); `None` = not yet
|
||||
#: tagged.
|
||||
Category = str | None
|
||||
|
||||
|
||||
class PostingTags(BaseModel):
|
||||
"""Derived tags on a posting row (D-052 schema, persisted as the
|
||||
posting's JSON `tags` column in P2; the column exists from day one).
|
||||
|
||||
`skills` is bounded at 10 by the tagger (D-052); the schema itself
|
||||
keeps the bound so the store can validate a hand-authored payload.
|
||||
`extra="forbid"`: a non-tag JSON object (e.g. the mock provider's
|
||||
canned {"summary":...}) must NOT silently validate as all-default
|
||||
tags — the untagged signal stays honest (verifier P2-3; P3
|
||||
embeddings must not treat empty-default parses as real tags).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
skills: list[str] = Field(default_factory=list, max_length=10)
|
||||
seniority: Seniority | None = None
|
||||
category: Category = None
|
||||
|
||||
|
||||
class Employer(BaseModel):
|
||||
"""Raw ingested employer fields (D-049 `employer` table row shape).
|
||||
|
||||
P4 (D-053) enriches on read with per-field `{value, source}`
|
||||
provenance; P1 surfaces the raw fields only.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
external_id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
logo: str = ""
|
||||
description: str = ""
|
||||
industry: str = ""
|
||||
size: str = ""
|
||||
location: str = ""
|
||||
website: str = ""
|
||||
social_links: dict[str, str] = Field(default_factory=dict)
|
||||
culture: str = ""
|
||||
|
||||
|
||||
class IngestPosting(BaseModel):
|
||||
"""The A-402 PostingSource payload: (source, external_id) identity +
|
||||
employer binding + the raw posting fields. Idempotent upsert key is
|
||||
(source, external_id) — a re-fetch overwrites the same row in place.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
external_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: Seniority | None = None
|
||||
category: Category = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
#: G-102: seed-port postings carry their ORIGINAL postedAt; the
|
||||
#: store applies it verbatim (status=published via the ingest
|
||||
#: contract, not the field).
|
||||
posted_at: datetime | None = None
|
||||
|
||||
|
||||
class Posting(BaseModel):
|
||||
"""Full posting row (D-049 `posting` table). Status is the lifecycle
|
||||
column (draft|published|closed, D-057); `tags` is the P2-derived
|
||||
JSON column (D-052 — empty until the tagger runs). `matchScore` is
|
||||
deliberately absent (G-106/G-107).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
source: str = Field(min_length=1)
|
||||
external_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
required_competencies: list[str] = Field(default_factory=list)
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
seniority: Seniority | None = None
|
||||
category: Category = None
|
||||
location: str = ""
|
||||
remote: bool = False
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
status: PostingStatus = "draft"
|
||||
tags: PostingTags | None = None
|
||||
posted_at: datetime | None = None
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
"""A learner's application to a posting (D-049 `application` table).
|
||||
Unique (posting_id, learner_id) → duplicate apply is idempotent
|
||||
200-with-existing (A-408/D-058). P1 status is always `submitted`;
|
||||
richer states are post-v0.6.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
status: str = "submitted"
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PostingPage(BaseModel):
|
||||
"""Published-only list response (G-103): the page + the honest
|
||||
total so the board can render "Showing X of Y" over a paged
|
||||
fetch-all without fabrication."""
|
||||
|
||||
postings: list[Posting]
|
||||
total_published: int
|
||||
|
||||
|
||||
class IngestResult(BaseModel):
|
||||
"""Honest ingest run summary (founder surface)."""
|
||||
|
||||
postings_upserted: int = 0
|
||||
employers_upserted: int = 0
|
||||
|
||||
|
||||
def posting_summary(posting: Posting) -> dict[str, Any]:
|
||||
"""Serialization helper shared by the list + detail responses — one
|
||||
shape so the G-107 pin (no `matchScore` in any marketplace-path
|
||||
payload) covers both surfaces."""
|
||||
return posting.model_dump(mode="json")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Marketplace embedding providers (D-051, REQ-6-003).
|
||||
|
||||
Protocol + deterministic feature-hash mock + OpenAI-compatible real
|
||||
provider + factory. Mirrors the voice/ layout (base/mock/openai_compat/
|
||||
factory) — embeddings need NO descriptor: match scores are computed
|
||||
server-side in search.py, never shipped as client capability hints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import EmbeddingProvider, EmbeddingResult
|
||||
from .factory import UnknownEmbedProviderError, embedding_provider_from_settings
|
||||
from .mock import (
|
||||
MOCK_EMBED_DIM,
|
||||
MOCK_EMBED_MODEL,
|
||||
MockEmbedProvider,
|
||||
embed_text,
|
||||
normalize_tokens,
|
||||
)
|
||||
from .openai_compat import OpenAIEmbedProvider
|
||||
|
||||
__all__ = [
|
||||
"MOCK_EMBED_DIM",
|
||||
"MOCK_EMBED_MODEL",
|
||||
"EmbeddingProvider",
|
||||
"EmbeddingResult",
|
||||
"MockEmbedProvider",
|
||||
"OpenAIEmbedProvider",
|
||||
"UnknownEmbedProviderError",
|
||||
"embed_text",
|
||||
"embedding_provider_from_settings",
|
||||
"normalize_tokens",
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""EmbeddingProvider protocol + shared embedding contracts (D-051, REQ-6-003).
|
||||
|
||||
Mirrors the D-030/D-014 provider seam: a narrow protocol the search
|
||||
pipeline and publish hooks compose via DI, with a deterministic
|
||||
feature-hash mock (pure stdlib) and an OpenAI-compatible real provider.
|
||||
No descriptor — unlike voice, embeddings are consumed SERVER-side only
|
||||
(match scores are computed in search.py, never shipped as capability
|
||||
hints), so there is nothing for a client to badge.
|
||||
|
||||
Boundary (D-048): `marketplace/embedding/` never imports `agents/` or
|
||||
`api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EmbeddingResult(BaseModel):
|
||||
"""One embedded text (D-051): the vector + its provenance.
|
||||
|
||||
`model`/`dim` ride every result so the store can persist per-row
|
||||
provenance (A-403/A-404: lazy re-embed on provider change works).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
values: list[float] = Field(min_length=1)
|
||||
model: str = Field(min_length=1)
|
||||
dim: int = Field(ge=1)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingProvider(Protocol):
|
||||
"""The embedding port (D-051): texts in, vectors out. Never imports
|
||||
agents/api; never stores anything (storage is the store's job)."""
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[EmbeddingResult]:
|
||||
"""Embed a batch of texts; result[i] corresponds to texts[i].
|
||||
|
||||
Implementations MUST be deterministic per provider config so
|
||||
stored vectors stay comparable across re-embeds.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Embedding provider factory (D-051, REQ-6-003).
|
||||
|
||||
`AI_EMBED_PROVIDER = mock | openai-embed` (default: mock — the no-key
|
||||
path is first-class; tests never call a real embeddings API).
|
||||
`openai-embed` requires embed_base_url + embed_api_key + embed_model:
|
||||
the factory raises `UnknownEmbedProviderError` with an actionable
|
||||
message for direct callers (tests), while the lifespan in main.py
|
||||
CATCHES it and falls back to mock with a loud log — a typo'd env must
|
||||
never crash the unattended boot (G-11; the exact voice/factory.py
|
||||
pattern).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from ...config import Settings
|
||||
from .base import EmbeddingProvider
|
||||
from .mock import MockEmbedProvider
|
||||
from .openai_compat import OpenAIEmbedProvider
|
||||
|
||||
|
||||
class UnknownEmbedProviderError(ValueError):
|
||||
"""Raised for a provider name outside the contract, or a real provider
|
||||
selected without its required configuration."""
|
||||
|
||||
|
||||
def embedding_provider_from_settings(
|
||||
settings: Settings, http_client: httpx.AsyncClient | None = None
|
||||
) -> EmbeddingProvider:
|
||||
"""Select the embedding provider by settings (env `AI_EMBED_PROVIDER`).
|
||||
|
||||
`http_client` is required for the `openai-embed` branch (D-017 shared
|
||||
pool); mock ignores it.
|
||||
"""
|
||||
name = (settings.embed_provider or "mock").strip().lower()
|
||||
if name == "mock":
|
||||
return MockEmbedProvider()
|
||||
if name in ("openai-embed", "openai", "server"):
|
||||
if (
|
||||
not settings.embed_base_url
|
||||
or not settings.embed_api_key.get_secret_value()
|
||||
or not settings.embed_model
|
||||
):
|
||||
raise UnknownEmbedProviderError(
|
||||
"AI_EMBED_PROVIDER=openai-embed requires AI_EMBED_BASE_URL, "
|
||||
"AI_EMBED_API_KEY, and AI_EMBED_MODEL — set all three, or use "
|
||||
"'mock'. NOTE (D-051 probe): ollama-cloud does NOT expose "
|
||||
"/embeddings; point AI_EMBED_BASE_URL at another "
|
||||
"OpenAI-compatible endpoint (e.g. local ollama :11434/v1). "
|
||||
"(main.py falls back to mock when these are missing — G-11)"
|
||||
)
|
||||
if http_client is None:
|
||||
raise UnknownEmbedProviderError(
|
||||
"openai-embed requires the shared httpx client "
|
||||
"(embedding_provider_from_settings(settings, http_client))"
|
||||
)
|
||||
return OpenAIEmbedProvider(
|
||||
http_client=http_client,
|
||||
base_url=settings.embed_base_url,
|
||||
api_key=settings.embed_api_key.get_secret_value(),
|
||||
model=settings.embed_model,
|
||||
)
|
||||
raise UnknownEmbedProviderError(
|
||||
f"unknown AI_EMBED_PROVIDER {name!r}: use 'mock' or 'openai-embed'"
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Deterministic MockEmbedProvider (D-051, REQ-6-003).
|
||||
|
||||
256-dim feature-hash of normalized tokens — pure stdlib (hashlib, math;
|
||||
NO numpy, NO python hash() — the builtin is salted per process).
|
||||
Same text → identical vector, ALWAYS (across processes, reboots,
|
||||
machines). Related texts share tokens → higher cosine than unrelated
|
||||
texts, so pinned similarity numbers stay meaningful in tests.
|
||||
|
||||
Algorithm (documented — search.py's skill_breakdown depends on it):
|
||||
1. normalize: lowercase, split on non-alphanumeric ([^a-z0-9]+)
|
||||
2. every token hashes (SHA-256 of its UTF-8 bytes) to an index
|
||||
idx = int.from_bytes(digest[:4], "little") % 256
|
||||
3. sign alternation on token-position collisions: the k-th OCCURRENCE
|
||||
of a token at the same index flips sign — (+1, -1, +1, ...) — so
|
||||
repeated tokens cannot monotonically inflate one component
|
||||
(hash("ab") == hash("ba") collisions cancel, not stack).
|
||||
occurrence = the token's own prior count at that index.
|
||||
4. accumulate counts into the 256-dim vector
|
||||
5. L2-normalize (a zero vector — no tokens — stays all-zeros so the
|
||||
cosine guard in search.py can be exercised; honest, never a fabricated
|
||||
direction).
|
||||
|
||||
`embed` embeds the WHOLE batch in one call (no state between calls —
|
||||
per-text vectors are independent), returning EmbeddingResult rows with
|
||||
model="mock-feature-hash-256".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
|
||||
from .base import EmbeddingProvider, EmbeddingResult
|
||||
|
||||
#: The mock's fixed dimensionality (D-051) — also its model identity.
|
||||
MOCK_EMBED_DIM = 256
|
||||
MOCK_EMBED_MODEL = "mock-feature-hash-256"
|
||||
|
||||
_SPLIT = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def normalize_tokens(text: str) -> list[str]:
|
||||
"""lowercase + split on non-alphanumeric; empty pieces dropped."""
|
||||
return [tok for tok in _SPLIT.split(text.lower()) if tok]
|
||||
|
||||
|
||||
def token_index(token: str) -> int:
|
||||
"""Stable, process-independent index: first 4 SHA-256 digest bytes,
|
||||
little-endian, mod 256 (never the salted builtin hash())."""
|
||||
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:4], "little") % MOCK_EMBED_DIM
|
||||
|
||||
|
||||
def embed_text(text: str) -> list[float]:
|
||||
"""The deterministic feature-hash vector for one text (pure function).
|
||||
|
||||
Exposed for search.py's per-skill contribution shares — the same
|
||||
formula must produce the mock's vectors and the breakdown math.
|
||||
"""
|
||||
vec = [0.0] * MOCK_EMBED_DIM
|
||||
for token in normalize_tokens(text):
|
||||
idx = token_index(token)
|
||||
# Sign alternation per token OCCURRENCE at its index: the token's
|
||||
# own running count at idx (not its position in the text).
|
||||
vec[idx] += 1.0 if vec[idx] >= 0 else -1.0 if vec[idx] <= 0 else 0.0
|
||||
# L2-normalize; all-zero (no tokens) stays all-zero (honest zero
|
||||
# vector — the cosine guard downstream treats it as unrankable).
|
||||
norm = math.sqrt(sum(v * v for v in vec))
|
||||
if norm > 0.0:
|
||||
vec = [v / norm for v in vec]
|
||||
return vec
|
||||
|
||||
|
||||
class MockEmbedProvider:
|
||||
"""Deterministic 256-dim feature-hash embedding (D-051 mock).
|
||||
|
||||
Cloud-free by construction; identical inputs → identical outputs
|
||||
forever (hashlib-based, no PYTHONHASHSEED dependence)."""
|
||||
|
||||
model = MOCK_EMBED_MODEL
|
||||
dim = MOCK_EMBED_DIM
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[EmbeddingResult]:
|
||||
return [
|
||||
EmbeddingResult(
|
||||
values=embed_text(text),
|
||||
model=MOCK_EMBED_MODEL,
|
||||
dim=MOCK_EMBED_DIM,
|
||||
)
|
||||
for text in texts
|
||||
]
|
||||
|
||||
|
||||
# Protocol-shape parity guard (mock must satisfy the D-051 port).
|
||||
|
||||
|
||||
def _assert_protocol() -> None:
|
||||
assert isinstance(MockEmbedProvider(), EmbeddingProvider)
|
||||
|
||||
|
||||
_assert_protocol()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""OpenAI-compatible embedding provider (D-051, REQ-6-003).
|
||||
|
||||
One implementation serves any OpenAI-compatible embeddings endpoint:
|
||||
`POST {base_url}/embeddings` with `{"model", "input": [texts]}` →
|
||||
`{"data": [{"embedding": [...]}, ...], "model": ...}`. Raw httpx on the
|
||||
shared lifespan client (D-017). base_url is config, never code — the
|
||||
D-014 endpoint-agnostic pattern.
|
||||
|
||||
PROBE CORRECTION (D-051): ollama-cloud does NOT expose /embeddings
|
||||
(404 path-not-found) — AI_EMBED_BASE_URL must point at another
|
||||
OpenAI-compatible endpoint (e.g. LOCAL ollama at :11434/v1, which does).
|
||||
|
||||
Boundary rules (mirror voice/openai_audio.py):
|
||||
- embedding/ imports nothing from agents/ or api/
|
||||
- api_key NEVER appears in exceptions, logs, or error messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import EmbeddingResult
|
||||
|
||||
|
||||
class OpenAIEmbedProvider:
|
||||
"""POST {base_url}/embeddings {"model", "input": [texts]}."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str = "",
|
||||
) -> None:
|
||||
self._client = http_client
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._model = model
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
return headers
|
||||
|
||||
def _sanitize(self, exc: Exception) -> RuntimeError:
|
||||
text = str(exc)
|
||||
if self._api_key and self._api_key in text:
|
||||
text = text.replace(self._api_key, "[REDACTED]")
|
||||
return RuntimeError(f"embedding provider error: {text}")
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[EmbeddingResult]:
|
||||
"""Batch-embed texts; result[i] corresponds to texts[i].
|
||||
|
||||
Parses the OpenAI-format response: `{"data": [{"embedding":
|
||||
[...]}], "model": ...}` — index-order preserved (the spec orders
|
||||
data by input position).
|
||||
"""
|
||||
payload = {"model": self._model, "input": list(texts)}
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
f"{self._base_url}/embeddings",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise self._sanitize(exc) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError(
|
||||
"embedding provider error: unexpected embeddings response shape"
|
||||
)
|
||||
data = body.get("data")
|
||||
if not isinstance(data, list) or len(data) != len(texts):
|
||||
raise RuntimeError(
|
||||
"embedding provider error: embeddings response/data length "
|
||||
f"mismatch (expected {len(texts)} entries)"
|
||||
)
|
||||
model = str(body.get("model") or self._model)
|
||||
results: list[EmbeddingResult] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict) or not isinstance(
|
||||
entry.get("embedding"), list
|
||||
):
|
||||
raise RuntimeError(
|
||||
"embedding provider error: embeddings response/data entry "
|
||||
"missing the 'embedding' array"
|
||||
)
|
||||
values = [float(v) for v in entry["embedding"]]
|
||||
results.append(
|
||||
EmbeddingResult(values=values, model=model, dim=len(values))
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Employer enrichment (REQ-6-004, D-053, A-409).
|
||||
|
||||
One-shot LLM extraction of employer description/social-links/logo from
|
||||
the employer's own posting data, composed through the D-020 pipeline
|
||||
(the D-052 tagging pattern) — no new agent. Enrichment rows are
|
||||
APPEND-ONLY with per-field provenance; merge-on-read precedence is
|
||||
manual > llm > ingested-raw, and every enriched field carries a
|
||||
visible {value, source} marker in API responses (A-304 honesty —
|
||||
LLM-authored copy is never displayed as ingested fact).
|
||||
|
||||
Trigger semantics (enrich-on-first-publish approximation): the publish
|
||||
route fires enrichment when the employer has NO existing llm-source
|
||||
rows — re-publishing the same employer does not re-run (the rows
|
||||
exist). Failure is honest: a warning logs, the publish still succeeds,
|
||||
no rows are written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from ..agents.structured import structured_completion
|
||||
from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
from .base import Employer
|
||||
from .store import MarketplaceStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Prompt version — bump on any wording change (prompts are code).
|
||||
ENRICHMENT_PROMPT_VERSION = 1
|
||||
|
||||
ENRICHMENT_PROMPT = """You are an employer-profile writer for an AI-era
|
||||
roles marketplace. From the employer's own data below, write concise
|
||||
factual profile copy.
|
||||
|
||||
Rules:
|
||||
- description: 1-3 sentences summarizing what the company does, grounded
|
||||
ONLY in the provided data — never invent employers, products, or
|
||||
numbers.
|
||||
- social_links: an object of platform → URL, ONLY for platforms the
|
||||
data names (website, github, linkedin, twitter/x). Empty object if
|
||||
none.
|
||||
- logo_ref: a short reference to the logo source named in the data
|
||||
(e.g. a URL or file name); empty string if none.
|
||||
|
||||
Respond with ONLY the JSON object."""
|
||||
|
||||
_SCHEMA_HINT = (
|
||||
'{"description": "1-3 sentence string", '
|
||||
'"social_links": {"platform": "url"}, '
|
||||
'"logo_ref": "string"}'
|
||||
)
|
||||
|
||||
#: The enrichable field set (D-053) — the manual-override route validates
|
||||
#: against this and the merge walks exactly these keys.
|
||||
ENRICHABLE_FIELDS = ("description", "social_links", "logo")
|
||||
|
||||
_PRECEDENCE = ("manual", "llm")
|
||||
|
||||
|
||||
class EnrichmentUnavailable(Exception):
|
||||
"""The extractor could not produce valid enrichment (provider/parse
|
||||
failure). The publish proceeds; no rows are written (D-053 honesty)."""
|
||||
|
||||
|
||||
class EnrichmentPayload(BaseModel):
|
||||
"""The D-020 extraction schema. extra=forbid keeps the honesty
|
||||
contract (the verifier P2-3 lesson): a non-matching LLM reply must
|
||||
fail validation, not silently parse as all-defaults."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
description: str = ""
|
||||
social_links: dict[str, str] = {}
|
||||
logo_ref: str = ""
|
||||
|
||||
async def enrich_employer(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
employer: Employer,
|
||||
postings_hint: list[str] | None = None,
|
||||
) -> EnrichmentPayload:
|
||||
"""One-shot profile extraction via D-020 structured completion.
|
||||
|
||||
`postings_hint` (the employer's posting titles) grounds the copy in
|
||||
the employer's own data. Failures raise EnrichmentUnavailable —
|
||||
honest, never a crash, never fabricated copy."""
|
||||
hint = ""
|
||||
if postings_hint:
|
||||
hint = "\nPosting titles: " + "; ".join(postings_hint[:10])
|
||||
messages = [
|
||||
Message(role="system", content=ENRICHMENT_PROMPT),
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Employer name: {employer.name}\n"
|
||||
f"Industry: {employer.industry}\n"
|
||||
f"Known description: {employer.description}\n"
|
||||
f"Website: {employer.website}\n"
|
||||
f"Social links: {json.dumps(employer.social_links)}\n"
|
||||
f"Logo: {employer.logo}{hint}"
|
||||
),
|
||||
),
|
||||
]
|
||||
try:
|
||||
return await structured_completion(
|
||||
provider,
|
||||
messages,
|
||||
model=model,
|
||||
schema=EnrichmentPayload,
|
||||
schema_hint=_SCHEMA_HINT,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"marketplace enrichment failed for %r: %s — no rows written "
|
||||
"(honest absence, never fabricated copy)",
|
||||
employer.name[:60],
|
||||
exc,
|
||||
)
|
||||
raise EnrichmentUnavailable(str(exc)) from exc
|
||||
|
||||
|
||||
def merge_employer(
|
||||
employer: Employer, rows: list[dict]
|
||||
) -> dict[str, dict]:
|
||||
"""Merge-on-read with per-field provenance (D-053/A-409).
|
||||
|
||||
Precedence per FIELD: manual > llm > ingested-raw — a manual
|
||||
description + llm social_links is legal. Every field carries
|
||||
{value, source, model?}; `social_links` values are dicts (the row
|
||||
stores JSON), the others strings. Newest row per (field, source)
|
||||
wins within a source (append-only, newest-last).
|
||||
"""
|
||||
best: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
field = row.get("field")
|
||||
if field not in ENRICHABLE_FIELDS:
|
||||
continue
|
||||
source = row.get("source")
|
||||
rank = _PRECEDENCE.index(source) if source in _PRECEDENCE else len(_PRECEDENCE)
|
||||
existing = best.get(field)
|
||||
if existing is None or rank < existing["_rank"]:
|
||||
best[field] = {
|
||||
"_rank": rank,
|
||||
"value": row.get("value"),
|
||||
"source": source,
|
||||
"model": row.get("model") or None,
|
||||
}
|
||||
merged: dict[str, dict] = {}
|
||||
for field in ENRICHABLE_FIELDS:
|
||||
raw_value = {
|
||||
"description": employer.description,
|
||||
"social_links": dict(employer.social_links),
|
||||
"logo": employer.logo,
|
||||
}[field]
|
||||
if field in best:
|
||||
entry = best[field]
|
||||
merged[field] = {
|
||||
"value": entry["value"],
|
||||
"source": entry["source"],
|
||||
"model": entry["model"],
|
||||
}
|
||||
else:
|
||||
# No enrichment rows for this field — the ingested raw value
|
||||
# with the honest raw marker (A-304: LLM copy never
|
||||
# masquerades as ingested fact, and vice versa).
|
||||
merged[field] = {"value": raw_value, "source": "raw", "model": None}
|
||||
return merged
|
||||
|
||||
|
||||
async def maybe_enrich_on_publish(
|
||||
store: MarketplaceStore,
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
employer_id: str,
|
||||
) -> bool:
|
||||
"""Enrich-on-first-publish trigger: fires when the employer has NO
|
||||
existing llm-source rows (re-publishing does not re-run — the rows
|
||||
exist). Returns True when enrichment rows landed. Failures are
|
||||
honest: warning + False, the publish itself already succeeded."""
|
||||
if store.count_enrichment(employer_id, source="llm") > 0:
|
||||
return False
|
||||
employer = store.get_employer(employer_id)
|
||||
if employer is None:
|
||||
return False # dangling employer ref (the seed carries 3) — skip
|
||||
titles = store.employer_posting_titles(employer_id)
|
||||
try:
|
||||
payload = await enrich_employer(
|
||||
provider, model=model, employer=employer, postings_hint=titles
|
||||
)
|
||||
except EnrichmentUnavailable:
|
||||
return False
|
||||
stamp = {"model": model}
|
||||
rows = [
|
||||
("description", payload.description, "llm", stamp),
|
||||
(
|
||||
"social_links",
|
||||
json.dumps(payload.social_links),
|
||||
"llm",
|
||||
stamp,
|
||||
),
|
||||
("logo", payload.logo_ref, "llm", stamp),
|
||||
]
|
||||
for field, value, source, meta in rows:
|
||||
if value not in ("", "{}"):
|
||||
store.append_enrichment(
|
||||
employer_id, field=field, value=value, source=source, **meta
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,97 @@
|
||||
"""PostingSource adapters + ingest runner (REQ-6-001, D-050, A-402).
|
||||
|
||||
`PostingSource` is the adapter port: `fetch() -> Iterable[IngestPosting]`.
|
||||
v0.6 ships ONE implementation, `SeededCorpusSource` over the in-repo
|
||||
`seed_corpus.json` (ported ONCE from packages/mock-data/jobs.ts +
|
||||
employers.ts per D-021 — shared ids, mock matchScore DROPPED: D-051
|
||||
computes real scores at search time, G-106/G-107). The 3M+ external
|
||||
corpus lands later as a third adapter with zero store/route changes.
|
||||
|
||||
Ingest-status contract (G-102): SEED-PORT postings enter as `published`
|
||||
with their ORIGINAL postedAt — otherwise the board/facets/P1's success
|
||||
criterion sit empty after ingest. ADMIN-AUTHORED postings start
|
||||
`draft` (the admin route's create path, not this runner).
|
||||
|
||||
No scheduler/crawler (A-401/A-402): ingest runs when the founder POSTs
|
||||
/v1/marketplace/admin/ingest; `run_ingest` is idempotent on
|
||||
(source, external_id) — a double-run yields identical row counts.
|
||||
|
||||
Boundary (D-048): never imports `agents/` or `api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from .base import Employer, IngestPosting, IngestResult
|
||||
from .store import MarketplaceStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The ported TS fixture corpus ships IN THE PACKAGE (importlib-free
|
||||
#: path resolution: this file's parent) — one JSON file, no codegen.
|
||||
_SEED_PATH = Path(__file__).resolve().parent / "seed_corpus.json"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostingSource(Protocol):
|
||||
"""The ingest port (D-050): yields IngestPosting payloads. Employers
|
||||
ride the corpus alongside postings (the seeded source exposes both;
|
||||
a pure-posting adapter simply fetches postings and leaves employers
|
||||
to a separate source)."""
|
||||
|
||||
def fetch(self) -> Iterable[IngestPosting]:
|
||||
...
|
||||
|
||||
def fetch_employers(self) -> Iterable[Employer]:
|
||||
...
|
||||
|
||||
|
||||
class SeededCorpusSource:
|
||||
"""The seeded corpus adapter over seed_corpus.json (D-050).
|
||||
|
||||
Pure + deterministic: reads the JSON fixture once per fetch; the
|
||||
runner upserts idempotently. Raw fields map 1:1 from the TS shapes
|
||||
(camelCase → snake_case port, matchScore dropped — G-102/G-107)."""
|
||||
|
||||
def __init__(self, path: Path = _SEED_PATH) -> None:
|
||||
self._path = path
|
||||
|
||||
def _load(self) -> dict:
|
||||
with self._path.open(encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
def fetch(self) -> Iterable[IngestPosting]:
|
||||
for raw in self._load().get("postings", []):
|
||||
yield IngestPosting.model_validate(raw)
|
||||
|
||||
def fetch_employers(self) -> Iterable[Employer]:
|
||||
for raw in self._load().get("employers", []):
|
||||
yield Employer.model_validate(raw)
|
||||
|
||||
|
||||
def run_ingest(source: PostingSource, store: MarketplaceStore) -> IngestResult:
|
||||
"""Idempotent upsert run: every (source, external_id) overwrites its
|
||||
row in place — a second run yields identical counts (MH-1b)."""
|
||||
|
||||
result = IngestResult()
|
||||
# Employers first: postings reference employer ids (no FK today, but
|
||||
# the reference ordering keeps the board honest mid-run).
|
||||
for employer in source.fetch_employers():
|
||||
store.upsert_employer(employer)
|
||||
result.employers_upserted += 1
|
||||
for posting in source.fetch():
|
||||
# G-102: seed-port postings enter PUBLISHED with their original
|
||||
# postedAt (the IngestPosting carries it verbatim).
|
||||
store.upsert_posting(posting, status="published")
|
||||
result.postings_upserted += 1
|
||||
logger.info(
|
||||
"marketplace ingest: %s postings, %s employers (idempotent upserts)",
|
||||
result.postings_upserted,
|
||||
result.employers_upserted,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Resume → profile draft extraction (REQ-6-005, D-054, A-410).
|
||||
|
||||
Pipeline: upload guard (PDF/TXT/MD only, ≤2 MB, text-layer required —
|
||||
empty pypdf extract → honest 422, NO OCR in v0.6) → text extraction →
|
||||
~100K char cap → D-020 structured extraction into ParsedProfile →
|
||||
response carries status="draft" — NOTHING persists to the learner
|
||||
profile until the explicit confirm route (the REQ-6-005 review gate).
|
||||
|
||||
Raw resume bytes are never stored, never logged (A-305 pattern; the
|
||||
existing _scrub_pii_validation handler precedent). The only new
|
||||
dependency is pypdf (probe-verified 6.19.0, pip-reachable via the
|
||||
venv channel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..agents.structured import structured_completion
|
||||
from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Prompt version — bump on any wording change (prompts are code).
|
||||
RESUME_PROMPT_VERSION = 1
|
||||
|
||||
#: Upload bounds (A-410).
|
||||
MAX_RESUME_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
ALLOWED_CONTENT_TYPES = ("pdf", "txt", "md")
|
||||
#: Extracted-text cap before the LLM call (A-410: ~100K chars).
|
||||
MAX_EXTRACTED_CHARS = 100_000
|
||||
|
||||
RESUME_PROMPT = """You are a profile parser for an AI-era skills
|
||||
platform. Extract structured profile data from the resume text.
|
||||
|
||||
Rules:
|
||||
- name: the person's name as written, or "" if absent.
|
||||
- headline: a one-line professional headline, or "".
|
||||
- skills: up to 20 concrete skills named in the resume (lowercase,
|
||||
the resume's own vocabulary preferred).
|
||||
- experience_bands: list of {role, years} objects — years is a number
|
||||
(round down; 0 if under a year); [] if none determinable.
|
||||
- competencies: up to 10 competency areas the resume evidences, each a
|
||||
short noun phrase; [] if none.
|
||||
|
||||
Respond with ONLY the JSON object."""
|
||||
|
||||
_SCHEMA_HINT = (
|
||||
'{"name": "string", "headline": "string", '
|
||||
'"skills": ["up to 20 lowercase strings"], '
|
||||
'"experience_bands": [{"role": "string", "years": 0}], '
|
||||
'"competencies": ["up to 10 short phrases"]}'
|
||||
)
|
||||
|
||||
|
||||
class ExperienceBand(BaseModel):
|
||||
"""One role/tenure band the resume evidences."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
role: str = ""
|
||||
years: float = 0
|
||||
|
||||
|
||||
class ParsedProfile(BaseModel):
|
||||
"""The D-020 extraction schema mapped to the competency-profile
|
||||
shape (D-054). extra=forbid keeps the honesty contract — a
|
||||
non-matching reply fails validation, never silently all-defaults
|
||||
(the verifier P2-3 lesson)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
name: str = ""
|
||||
headline: str = ""
|
||||
skills: list[str] = Field(default_factory=list, max_length=20)
|
||||
experience_bands: list[ExperienceBand] = Field(default_factory=list)
|
||||
competencies: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class ResumeFormatError(Exception):
|
||||
"""Honest upload rejection (422 at the route): unsupported format,
|
||||
missing text layer (empty PDF extract — no OCR in v0.6), or empty
|
||||
file."""
|
||||
|
||||
|
||||
def extract_resume_text(data: bytes, content_type: str) -> str:
|
||||
"""Extract text from an uploaded resume (A-410 bounds enforced by
|
||||
the route BEFORE this call; this handles the decode/parse).
|
||||
|
||||
PDF via pypdf page-text join; TXT/MD raw decode. An empty PDF
|
||||
extract raises ResumeFormatError("text layer required") — the
|
||||
honest no-OCR contract. Raises ResumeFormatError on garbage text
|
||||
bytes too (the honest 422, never a 500)."""
|
||||
ctype = content_type.strip().lower()
|
||||
# Accept both bare and full content types ("application/pdf").
|
||||
if "pdf" in ctype or ctype == "pdf":
|
||||
from io import BytesIO
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
try:
|
||||
reader = PdfReader(BytesIO(data))
|
||||
pages = [(page.extract_text() or "") for page in reader.pages]
|
||||
except Exception as exc:
|
||||
raise ResumeFormatError(
|
||||
"could not read the PDF — is it a valid PDF file?"
|
||||
) from exc
|
||||
text = "\n".join(pages)
|
||||
if not text.strip():
|
||||
raise ResumeFormatError(
|
||||
"text layer required — image-only/scanned PDFs are not "
|
||||
"supported (no OCR in v0.6); upload a text-based PDF, "
|
||||
"TXT, or MD file"
|
||||
)
|
||||
return text
|
||||
if "markdown" in ctype or ctype in ("txt", "md", "text/plain", "text/markdown"):
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ResumeFormatError(
|
||||
"could not decode the file as UTF-8 text"
|
||||
) from exc
|
||||
if not text.strip():
|
||||
raise ResumeFormatError("file has no text content")
|
||||
return text
|
||||
raise ResumeFormatError(
|
||||
f"unsupported resume format {content_type!r} — PDF, TXT, or MD only"
|
||||
)
|
||||
|
||||
|
||||
async def parse_resume(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
text: str,
|
||||
) -> ParsedProfile:
|
||||
"""Draft extraction via D-020 (never persists — the confirm route
|
||||
is the only write path, D-054/REQ-6-005 review gate)."""
|
||||
capped = text[:MAX_EXTRACTED_CHARS]
|
||||
messages = [
|
||||
Message(role="system", content=RESUME_PROMPT),
|
||||
Message(role="user", content=f"Resume text:\n{capped}"),
|
||||
]
|
||||
# Raw resume text never logs (A-305): only the failure reason rides
|
||||
# the warning below — structured_completion raises on its own path.
|
||||
return await structured_completion(
|
||||
provider,
|
||||
messages,
|
||||
model=model,
|
||||
schema=ParsedProfile,
|
||||
schema_hint=_SCHEMA_HINT,
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Hybrid facet+cosine search over published postings (REQ-6-003, A-405).
|
||||
|
||||
Pure-Python cosine (sum of products / norm product; ≤10⁴ postings —
|
||||
NO numpy, A-403). Hybrid ordering per D-059: facet hard-filters
|
||||
SQL-side on portable columns FIRST, then cosine ranking over the
|
||||
filtered set; no facets → pure semantic rank over all published
|
||||
postings.
|
||||
|
||||
Candidate side: the query text embeds ON DEMAND via the provider and
|
||||
is NEVER stored (A-404 — only posting vectors persist).
|
||||
|
||||
Lazy re-embed (A-403/A-404): a stored row whose model/dim ≠ the
|
||||
provider's current model/dim is re-embedded + re-stored on access, so
|
||||
a provider switch self-heals the corpus row-by-row without a
|
||||
backfill pass.
|
||||
|
||||
skill_breakdown formula (deterministic, documented for the pin):
|
||||
- overlap = does the skill string (normalized: lowercase, split on
|
||||
non-alphanumeric, rejoined) share a token with the QUERY's normalized
|
||||
token set → overlap ∈ {0, 1}
|
||||
- contribution = the share of the posting-vector dot-product carried by
|
||||
the skill's own feature-hash tokens: for the mock provider,
|
||||
contribution(skill) = Σ_components of mock.embed_text(skill) mapped
|
||||
to the same (index, sign) bucket in the mock formula — computed as
|
||||
cosine(embed_text(skill), posting_vector) when overlap=1, else 0.0.
|
||||
Both operands are mock-formula vectors, so the number is
|
||||
deterministic and provider-independent to pin (with the openai-embed
|
||||
provider the contribution uses the SAME skill-text side via the
|
||||
provider on demand; the mock pin is the MH-3f pin).
|
||||
|
||||
Boundary (D-048): never imports `agents/` or `api/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .base import Posting
|
||||
from .embedding.base import EmbeddingProvider, EmbeddingResult
|
||||
from .embedding.mock import embed_text as _mock_embed_text
|
||||
from .embedding.mock import normalize_tokens as _normalize
|
||||
from .store import MarketplaceStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FacetFilters:
|
||||
"""Hard filters applied SQL-side BEFORE ranking (A-405). Any facet
|
||||
may be empty — an empty filter set means pure semantic rank."""
|
||||
|
||||
skills: list[str] = field(default_factory=list)
|
||||
seniority: str | None = None
|
||||
category: str | None = None
|
||||
location: str | None = None
|
||||
remote: bool | None = None
|
||||
salary_min: int | None = None
|
||||
salary_max: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankedPosting:
|
||||
"""One ranked search result. `match_score` is the computed cosine
|
||||
(G-106: computed scores exist ONLY in search context)."""
|
||||
|
||||
posting: Posting
|
||||
score: float
|
||||
skill_breakdown: list[dict]
|
||||
|
||||
|
||||
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Pure-Python cosine: sum(a·b) / (‖a‖·‖b‖); 0.0 on a zero vector
|
||||
(guard: an unrankable vector scores an honest zero, never a
|
||||
ZeroDivisionError) or a length mismatch (defensive — the store
|
||||
codec enforces dim parity)."""
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = 0.0
|
||||
norm_a = 0.0
|
||||
norm_b = 0.0
|
||||
for x, y in zip(a, b, strict=False):
|
||||
dot += x * y
|
||||
norm_a += x * x
|
||||
norm_b += y * y
|
||||
if norm_a <= 0.0 or norm_b <= 0.0:
|
||||
return 0.0
|
||||
return dot / math.sqrt(norm_a * norm_b)
|
||||
|
||||
|
||||
def posting_embedding_text(posting: Posting) -> str:
|
||||
"""D-051 posting text: title + description + tag skills joined —
|
||||
the P2 tags ride the vector so facets and semantics agree."""
|
||||
parts = [posting.title, posting.description or ""]
|
||||
if posting.tags and posting.tags.skills:
|
||||
parts.extend(posting.tags.skills)
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
async def embed_posting(
|
||||
store: MarketplaceStore, provider: EmbeddingProvider, posting: Posting
|
||||
) -> bool:
|
||||
"""Embed-on-publish hook (Task 3-2-01): embed the posting text and
|
||||
store the vector. Failure is HONEST, never fatal: returns False with
|
||||
a logged warning — the posting still publishes, search skips it
|
||||
(no stored vector = honest absence, never a fabricated direction)."""
|
||||
try:
|
||||
results = await provider.embed([posting_embedding_text(posting)])
|
||||
result = results[0]
|
||||
store.put_vector(posting.id, result.values, result.model, result.dim)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"marketplace embedding failed for posting %r: %s — posting stays "
|
||||
"published WITHOUT a vector (search skips it, honest absence)",
|
||||
posting.id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _current_embedding(
|
||||
provider: EmbeddingProvider, text: str
|
||||
) -> EmbeddingResult:
|
||||
"""Embed one text via the provider (candidate side: on demand,
|
||||
never stored)."""
|
||||
results = await provider.embed([text])
|
||||
return results[0]
|
||||
|
||||
|
||||
async def rank_postings(
|
||||
store: MarketplaceStore,
|
||||
provider: EmbeddingProvider,
|
||||
query_text: str,
|
||||
facet_filters: FacetFilters | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[RankedPosting]:
|
||||
"""Hybrid rank (A-405): facets hard-filter SQL-side first, then
|
||||
cosine ranking DESC over the filtered set.
|
||||
|
||||
- empty query → facet-only listing ordered by posted_at DESC (no
|
||||
query → no match scores — G-106 honesty; scores are 0.0 and the
|
||||
route reports match_score: null).
|
||||
- postings whose stored model/dim ≠ the provider's are re-embedded
|
||||
+ re-stored lazily (A-403/A-404).
|
||||
- postings with NO stored vector are SKIPPED (honest absence — an
|
||||
embed-on-publish failure must not fabricate a direction).
|
||||
"""
|
||||
filters = facet_filters or FacetFilters()
|
||||
candidates = store.search_candidates(
|
||||
skills=filters.skills or None,
|
||||
seniority=filters.seniority,
|
||||
category=filters.category,
|
||||
location=filters.location,
|
||||
remote=filters.remote,
|
||||
salary_min=filters.salary_min,
|
||||
salary_max=filters.salary_max,
|
||||
)
|
||||
if not query_text.strip():
|
||||
# Facet-only listing: posted_at DESC (the list_postings order),
|
||||
# no match scores — the route carries match_score: null.
|
||||
return [
|
||||
RankedPosting(posting=p, score=0.0, skill_breakdown=[])
|
||||
for p in candidates[:limit]
|
||||
]
|
||||
|
||||
query_result = await _current_embedding(provider, query_text)
|
||||
q_values = query_result.values
|
||||
q_tokens = set(_normalize(query_text))
|
||||
q_model = query_result.model
|
||||
q_dim = query_result.dim
|
||||
|
||||
# Lazy re-embed: collect stale rows (model/dim ≠ provider's) and
|
||||
# refresh them in one batch before ranking.
|
||||
stale: list[Posting] = [
|
||||
p for p in candidates if _is_stale(store, p.id, q_model, q_dim)
|
||||
]
|
||||
if stale:
|
||||
texts = [posting_embedding_text(p) for p in stale]
|
||||
try:
|
||||
refreshed = await provider.embed(texts)
|
||||
for posting, result in zip(stale, refreshed, strict=True):
|
||||
store.put_vector(posting.id, result.values, result.model, result.dim)
|
||||
except Exception as exc: # honest: stale rows keep old vectors
|
||||
logger.warning(
|
||||
"lazy re-embed of %s postings failed (%s) — ranking against "
|
||||
"existing vectors",
|
||||
len(stale),
|
||||
exc,
|
||||
)
|
||||
|
||||
ranked: list[RankedPosting] = []
|
||||
for posting in candidates:
|
||||
stored = store.get_vector(posting.id)
|
||||
if stored is None:
|
||||
continue # embed-on-publish failed → honest skip
|
||||
values, model, dim = stored
|
||||
if (model, dim) != (q_model, q_dim):
|
||||
continue # re-embed failed above → incomparable, honest skip
|
||||
score = cosine_similarity(q_values, values)
|
||||
ranked.append(
|
||||
RankedPosting(
|
||||
posting=posting,
|
||||
score=score,
|
||||
skill_breakdown=skill_breakdown(posting, q_tokens, values),
|
||||
)
|
||||
)
|
||||
ranked.sort(key=lambda r: (-r.score, r.posting.id))
|
||||
return ranked[:limit]
|
||||
|
||||
|
||||
def _is_stale(
|
||||
store: MarketplaceStore, posting_id: str, model: str, dim: int
|
||||
) -> bool:
|
||||
stored = store.get_vector(posting_id)
|
||||
if stored is None:
|
||||
return False # absent ≠ stale (no direction to refresh)
|
||||
_, row_model, row_dim = stored
|
||||
return (row_model, row_dim) != (model, dim)
|
||||
|
||||
|
||||
def skill_breakdown(
|
||||
posting: Posting, query_tokens: set[str], posting_vector: list[float]
|
||||
) -> list[dict]:
|
||||
"""Per-skill explanation (MH-3f): tag-skill overlap with the query
|
||||
+ the skill's vector contribution — both deterministic.
|
||||
|
||||
overlap: 1 when any normalized token of the skill string appears
|
||||
in the query's normalized token set, else 0.
|
||||
contribution: cosine(embed_text(skill), posting_vector) when
|
||||
overlap=1 — the mock feature-hash formula's share of the posting
|
||||
dot-product that the skill's own tokens carry; 0.0 otherwise.
|
||||
Sorted by contribution DESC then skill ASC (deterministic order).
|
||||
"""
|
||||
skills: list[str] = list(posting.tags.skills) if posting.tags else []
|
||||
breakdown: list[dict] = []
|
||||
for skill in skills:
|
||||
tokens = _normalize(skill)
|
||||
overlap = 1 if any(t in query_tokens for t in tokens) else 0
|
||||
contribution = 0.0
|
||||
if overlap:
|
||||
contribution = cosine_similarity(
|
||||
_mock_embed_text(skill), posting_vector
|
||||
)
|
||||
breakdown.append(
|
||||
{"skill": skill, "overlap": overlap, "contribution": contribution}
|
||||
)
|
||||
breakdown.sort(key=lambda e: (-e["contribution"], e["skill"]))
|
||||
return breakdown
|
||||
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"employers": [
|
||||
{
|
||||
"id": "emp-openai",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-openai",
|
||||
"name": "OpenAI Labs",
|
||||
"logo": "https://placehold.co/80x80/indigo/white?text=OA",
|
||||
"description": "Mock employer building frontier AI systems. We build and deploy large language models and agentic tools used by millions of developers and consumers.",
|
||||
"industry": "Artificial Intelligence",
|
||||
"size": "2,000+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/openai",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/openai/x",
|
||||
"linkedin": "https://example.com/openai/li"
|
||||
},
|
||||
"culture": "Research-driven, shipping-paced, safety-conscious. We pair frontier research with product rigor."
|
||||
},
|
||||
{
|
||||
"id": "emp-anthropic",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-anthropic",
|
||||
"name": "Anthropic Research",
|
||||
"logo": "https://placehold.co/80x80/amber/white?text=AN",
|
||||
"description": "Mock employer focused on AI safety and alignment. We build reliable, interpretable, and steerable AI systems.",
|
||||
"industry": "AI Safety",
|
||||
"size": "1,000+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/anthropic",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/anthropic/x",
|
||||
"linkedin": "https://example.com/anthropic/li"
|
||||
},
|
||||
"culture": "Safety-first, evidence-based, calm-paced. We value thoroughness over speed when stakes are high."
|
||||
},
|
||||
{
|
||||
"id": "emp-huggingface",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-huggingface",
|
||||
"name": "Hugging Face",
|
||||
"logo": "https://placehold.co/80x80/yellow/black?text=HF",
|
||||
"description": "Mock employer building the open-source AI community. We host models, datasets, and demos for the global ML community.",
|
||||
"industry": "Open Source AI",
|
||||
"size": "500+",
|
||||
"location": "New York, NY",
|
||||
"website": "https://example.com/hf",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/hf/x",
|
||||
"linkedin": "https://example.com/hf/li"
|
||||
},
|
||||
"culture": "Community-first, open-by-default, remote-friendly. We ship in the open with thousands of contributors."
|
||||
},
|
||||
{
|
||||
"id": "emp-scaleai",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-scaleai",
|
||||
"name": "Scale AI",
|
||||
"logo": "https://placehold.co/80x80/violet/white?text=SC",
|
||||
"description": "Mock employer providing data and evaluation infrastructure for frontier AI. We power the RLHF and eval pipelines behind many model releases.",
|
||||
"industry": "AI Infrastructure",
|
||||
"size": "1,500+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/scale",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/scale/x",
|
||||
"linkedin": "https://example.com/scale/li"
|
||||
},
|
||||
"culture": "Infrastructure-minded, quality-obsessed, enterprise-aware. We bridge research and production data."
|
||||
},
|
||||
{
|
||||
"id": "emp-perplexity",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-perplexity",
|
||||
"name": "Perplexity",
|
||||
"logo": "https://placehold.co/80x80/teal/white?text=PX",
|
||||
"description": "Mock employer building an AI-powered answer engine. We combine retrieval, generation, and citations for trustworthy answers.",
|
||||
"industry": "AI Search",
|
||||
"size": "300+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/perplexity",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/perplexity/x",
|
||||
"linkedin": "https://example.com/perplexity/li"
|
||||
},
|
||||
"culture": "Answer-focused, fast-iterating, citation-proud. We treat groundedness as a product feature."
|
||||
},
|
||||
{
|
||||
"id": "emp-cohere",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-cohere",
|
||||
"name": "Cohere",
|
||||
"logo": "https://placehold.co/80x80/pink/white?text=CO",
|
||||
"description": "Mock employer building enterprise-grade language models. We specialize in retrieval, multilinguality, and data privacy for regulated industries.",
|
||||
"industry": "Enterprise LLMs",
|
||||
"size": "400+",
|
||||
"location": "Toronto, Canada",
|
||||
"website": "https://example.com/cohere",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/cohere/x",
|
||||
"linkedin": "https://example.com/cohere/li"
|
||||
},
|
||||
"culture": "Enterprise-aware, research-active, multilingual. We ship models that work in 100+ languages."
|
||||
},
|
||||
{
|
||||
"id": "emp-mistral",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-mistral",
|
||||
"name": "Mistral AI",
|
||||
"logo": "https://placehold.co/80x80/red/white?text=MI",
|
||||
"description": "Mock employer building open-weight frontier models in Europe. We push efficiency and openness in large language models.",
|
||||
"industry": "Open-Weight LLMs",
|
||||
"size": "200+",
|
||||
"location": "Paris, France",
|
||||
"website": "https://example.com/mistral",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/mistral/x",
|
||||
"linkedin": "https://example.com/mistral/li"
|
||||
},
|
||||
"culture": "European-rooted, efficiency-driven, open-weight-proud. We ship small models that punch above their size."
|
||||
},
|
||||
{
|
||||
"id": "emp-replicate",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-replicate",
|
||||
"name": "Replicate",
|
||||
"logo": "https://placehold.co/80x80/orange/white?text=RE",
|
||||
"description": "Mock employer making ML deployment delightful. We host, serve, and scale models with a few lines of code.",
|
||||
"industry": "ML Deployment",
|
||||
"size": "150+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/replicate",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/replicate/x",
|
||||
"linkedin": "https://example.com/replicate/li"
|
||||
},
|
||||
"culture": "Developer-experience-obsessed, pragmatic, remote-friendly. We make shipping models feel like shipping software."
|
||||
},
|
||||
{
|
||||
"id": "emp-pinecone",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-pinecone",
|
||||
"name": "Pinecone",
|
||||
"logo": "https://placehold.co/80x80/green/white?text=PC",
|
||||
"description": "Mock employer building the vector database for AI applications. We power retrieval for thousands of production RAG systems.",
|
||||
"industry": "Vector Databases",
|
||||
"size": "300+",
|
||||
"location": "New York, NY",
|
||||
"website": "https://example.com/pinecone",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/pinecone/x",
|
||||
"linkedin": "https://example.com/pinecone/li"
|
||||
},
|
||||
"culture": "Infra-focused, latency-obsessed, distributed-first. We treat retrieval as the heart of grounded AI."
|
||||
},
|
||||
{
|
||||
"id": "emp-langchain",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "emp-langchain",
|
||||
"name": "LangChain",
|
||||
"logo": "https://placehold.co/80x80/blue/white?text=LC",
|
||||
"description": "Mock employer building the orchestration layer for LLM applications. We provide frameworks for agents, retrieval, and evaluation.",
|
||||
"industry": "AI Orchestration",
|
||||
"size": "200+",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://example.com/langchain",
|
||||
"social_links": {
|
||||
"twitter": "https://example.com/langchain/x",
|
||||
"linkedin": "https://example.com/langchain/li"
|
||||
},
|
||||
"culture": "Framework-minded, community-driven, abstractions-first. We build primitives others compose into products."
|
||||
}
|
||||
],
|
||||
"postings": [
|
||||
{
|
||||
"id": "job-001",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-001",
|
||||
"employer_id": "emp-langchain",
|
||||
"title": "AI Orchestration Engineer",
|
||||
"description": "Design and operate multi-agent systems that automate complex knowledge workflows. You will own agent topology, tool integration, and evaluation harnesses for production deployments serving millions of queries.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c001",
|
||||
"stack-orchestration-c002",
|
||||
"stack-orchestration-c005"
|
||||
],
|
||||
"skills": [
|
||||
"LangGraph",
|
||||
"Multi-agent systems",
|
||||
"RAG",
|
||||
"Python",
|
||||
"Evaluation"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 160000,
|
||||
"salary_max": 240000,
|
||||
"posted_at": "2026-08-21T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-002",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-002",
|
||||
"employer_id": "emp-openai",
|
||||
"title": "LLM Application Developer",
|
||||
"description": "Build delightful LLM-powered features end-to-end. You will work on retrieval, function calling, streaming UX, and robust output validation for consumer-facing products.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c003",
|
||||
"stack-orchestration-c004",
|
||||
"stack-orchestration-c005"
|
||||
],
|
||||
"skills": [
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"RAG",
|
||||
"Function calling",
|
||||
"Streaming"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 140000,
|
||||
"salary_max": 210000,
|
||||
"posted_at": "2026-08-28T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-003",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-003",
|
||||
"employer_id": "emp-anthropic",
|
||||
"title": "AI Safety Researcher",
|
||||
"description": "Research alignment, interpretability, and robustness of frontier models. Design experiments, run evaluations, and publish findings that improve the safety of deployed AI systems.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c001",
|
||||
"stack-safety-c007",
|
||||
"stack-safety-c002"
|
||||
],
|
||||
"skills": [
|
||||
"Alignment",
|
||||
"Interpretability",
|
||||
"Red teaming",
|
||||
"Python",
|
||||
"Research"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": false,
|
||||
"salary_min": 180000,
|
||||
"salary_max": 280000,
|
||||
"posted_at": "2026-08-15T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-004",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-004",
|
||||
"employer_id": "emp-perplexity",
|
||||
"title": "Prompt Engineer",
|
||||
"description": "Craft, test, and ship prompt strategies that power answer-engine features. Own prompt libraries, regression suites, and A/B experiments across product surfaces.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c004",
|
||||
"stack-orchestration-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Prompt engineering",
|
||||
"Evaluation",
|
||||
"A/B testing",
|
||||
"Python"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 110000,
|
||||
"salary_max": 175000,
|
||||
"posted_at": "2026-09-02T14:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-005",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-005",
|
||||
"employer_id": "emp-huggingface",
|
||||
"title": "AI Product Manager",
|
||||
"description": "Own the roadmap for AI-powered developer tools. Translate model capabilities into customer value, define success metrics, and ship agentic features with engineering.",
|
||||
"required_competencies": [
|
||||
"stack-designer-c001",
|
||||
"stack-orchestration-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Product strategy",
|
||||
"AI UX",
|
||||
"Roadmapping",
|
||||
"Stakeholder mgmt"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "New York, NY",
|
||||
"remote": true,
|
||||
"salary_min": 150000,
|
||||
"salary_max": 220000,
|
||||
"posted_at": "2026-08-30T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-006",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-006",
|
||||
"employer_id": "emp-pinecone",
|
||||
"title": "RAG Infrastructure Engineer",
|
||||
"description": "Build the retrieval substrate behind knowledge-grounded AI. Optimize indexing, query latency, and hybrid retrieval across billion-vector workloads.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c005",
|
||||
"stack-orchestration-c006"
|
||||
],
|
||||
"skills": [
|
||||
"Vector databases",
|
||||
"Embeddings",
|
||||
"Go",
|
||||
"Distributed systems"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 130000,
|
||||
"salary_max": 200000,
|
||||
"posted_at": "2026-09-05T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-007",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-007",
|
||||
"employer_id": "emp-scaleai",
|
||||
"title": "AI Governance Lead",
|
||||
"description": "Stand up the AI governance program for enterprise customers. Map controls to NIST AI RMF and EU AI Act, run audits, and author model cards at scale.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c005",
|
||||
"stack-safety-c003",
|
||||
"stack-safety-c013"
|
||||
],
|
||||
"skills": [
|
||||
"AI policy",
|
||||
"NIST AI RMF",
|
||||
"Audit",
|
||||
"Documentation"
|
||||
],
|
||||
"seniority": "staff",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 170000,
|
||||
"salary_max": 230000,
|
||||
"posted_at": "2026-08-18T16:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-008",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-008",
|
||||
"employer_id": "emp-anthropic",
|
||||
"title": "Human-AI Interaction Designer",
|
||||
"description": "Design how users collaborate with Claude across products. Own transparency patterns, trust calibration, and agentic interaction for assistant surfaces.",
|
||||
"required_competencies": [
|
||||
"stack-designer-c001",
|
||||
"stack-designer-c003",
|
||||
"stack-designer-c008"
|
||||
],
|
||||
"skills": [
|
||||
"Figma",
|
||||
"AI UX",
|
||||
"Prototyping",
|
||||
"User research"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 125000,
|
||||
"salary_max": 190000,
|
||||
"posted_at": "2026-08-25T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-009",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-009",
|
||||
"employer_id": "emp-cohere",
|
||||
"title": "Evaluation Engineer",
|
||||
"description": "Build and operate the eval platform for LLM-powered products. Design LLM-as-judge pipelines, regression suites, and human-eval panels for production models.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c007",
|
||||
"stack-safety-c002"
|
||||
],
|
||||
"skills": [
|
||||
"LLM evaluation",
|
||||
"Python",
|
||||
"Statistics",
|
||||
"Data labeling"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Toronto, Canada",
|
||||
"remote": true,
|
||||
"salary_min": 120000,
|
||||
"salary_max": 180000,
|
||||
"posted_at": "2026-09-01T13:20:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-010",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-010",
|
||||
"employer_id": "emp-replicate",
|
||||
"title": "Robotics Operations Specialist",
|
||||
"description": "Supervise a fleet of AI-augmented robotic units in a warehouse environment. Interpret anomaly alerts, perform calibrations, and intervene on exceptions.",
|
||||
"required_competencies": [
|
||||
"stack-operator-c002",
|
||||
"stack-operator-c011",
|
||||
"stack-operator-c003"
|
||||
],
|
||||
"skills": [
|
||||
"Robotics",
|
||||
"Safety protocols",
|
||||
"IoT",
|
||||
"Predictive maintenance"
|
||||
],
|
||||
"seniority": "entry",
|
||||
"category": null,
|
||||
"location": "Austin, TX",
|
||||
"remote": false,
|
||||
"salary_min": 80000,
|
||||
"salary_max": 115000,
|
||||
"posted_at": "2026-08-22T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-011",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-011",
|
||||
"employer_id": "emp-recursion",
|
||||
"title": "Computational Biologist",
|
||||
"description": "Apply ML to drug discovery. Build pipelines for phenotype prediction, active learning on assay data, and molecular generation for novel targets.",
|
||||
"required_competencies": [
|
||||
"stack-science-c005",
|
||||
"stack-science-c002",
|
||||
"stack-science-c011"
|
||||
],
|
||||
"skills": [
|
||||
"Bioinformatics",
|
||||
"Python",
|
||||
"PyTorch",
|
||||
"Drug discovery"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "Boston, MA",
|
||||
"remote": true,
|
||||
"salary_min": 145000,
|
||||
"salary_max": 215000,
|
||||
"posted_at": "2026-08-12T15:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-012",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-012",
|
||||
"employer_id": "emp-deepmind",
|
||||
"title": "Climate ML Scientist",
|
||||
"description": "Develop ML models for climate forecasting and energy grid optimization. Work with earth system scientists to downscale GCM output and quantify uncertainty.",
|
||||
"required_competencies": [
|
||||
"stack-science-c004",
|
||||
"stack-science-c014",
|
||||
"stack-science-c010"
|
||||
],
|
||||
"skills": [
|
||||
"Climate modeling",
|
||||
"PyTorch",
|
||||
"Data assimilation",
|
||||
"PINNs"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "London, UK",
|
||||
"remote": true,
|
||||
"salary_min": 135000,
|
||||
"salary_max": 200000,
|
||||
"posted_at": "2026-08-27T11:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-013",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-013",
|
||||
"employer_id": "emp-langchain",
|
||||
"title": "Agent Reliability Engineer",
|
||||
"description": "Own observability and reliability for production agent workloads. Build tracing, alerting, and rollback systems for multi-step agent pipelines.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c013",
|
||||
"stack-orchestration-c015",
|
||||
"stack-orchestration-c008"
|
||||
],
|
||||
"skills": [
|
||||
"Observability",
|
||||
"Python",
|
||||
"SRE",
|
||||
"Distributed tracing"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 140000,
|
||||
"salary_max": 205000,
|
||||
"posted_at": "2026-09-04T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-014",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-014",
|
||||
"employer_id": "emp-mistral",
|
||||
"title": "AI Red Team Lead",
|
||||
"description": "Lead adversarial testing of frontier and open-weight models. Build automated red-team suites, track vulnerabilities, and coordinate disclosure.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c002",
|
||||
"stack-safety-c010",
|
||||
"stack-safety-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Red teaming",
|
||||
"Prompt injection",
|
||||
"Python",
|
||||
"Leadership"
|
||||
],
|
||||
"seniority": "staff",
|
||||
"category": null,
|
||||
"location": "Paris, France",
|
||||
"remote": true,
|
||||
"salary_min": 160000,
|
||||
"salary_max": 230000,
|
||||
"posted_at": "2026-08-19T10:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-015",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-015",
|
||||
"employer_id": "emp-huggingface",
|
||||
"title": "Conversation Designer",
|
||||
"description": "Design dialogue flows, personas, and repair strategies for AI assistants across open-source products. Partner with ML engineers to align tone with model behavior.",
|
||||
"required_competencies": [
|
||||
"stack-designer-c002",
|
||||
"stack-designer-c010",
|
||||
"stack-designer-c011"
|
||||
],
|
||||
"skills": [
|
||||
"Conversation design",
|
||||
"Figma",
|
||||
"Voice UX",
|
||||
"Prototyping"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 105000,
|
||||
"salary_max": 160000,
|
||||
"posted_at": "2026-08-29T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-016",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-016",
|
||||
"employer_id": "emp-anduril",
|
||||
"title": "Field AI Technician",
|
||||
"description": "Deploy and maintain AI vision systems on defense hardware in the field. Calibrate sensors, interpret model alerts, and escalate edge cases to engineering.",
|
||||
"required_competencies": [
|
||||
"stack-operator-c004",
|
||||
"stack-operator-c009",
|
||||
"stack-operator-c001"
|
||||
],
|
||||
"skills": [
|
||||
"Computer vision",
|
||||
"Sensor calibration",
|
||||
"Field ops",
|
||||
"Python"
|
||||
],
|
||||
"seniority": "entry",
|
||||
"category": null,
|
||||
"location": "Costa Mesa, CA",
|
||||
"remote": false,
|
||||
"salary_min": 90000,
|
||||
"salary_max": 130000,
|
||||
"posted_at": "2026-08-14T08:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-017",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-017",
|
||||
"employer_id": "emp-deepmind",
|
||||
"title": "Materials ML Engineer",
|
||||
"description": "Discover novel materials with ML. Train property-prediction models, run active-learning loops over DFT calculations, and validate candidates experimentally.",
|
||||
"required_competencies": [
|
||||
"stack-science-c003",
|
||||
"stack-science-c002",
|
||||
"stack-science-c006"
|
||||
],
|
||||
"skills": [
|
||||
"Materials science",
|
||||
"PyTorch",
|
||||
"DFT",
|
||||
"Active learning"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "London, UK",
|
||||
"remote": true,
|
||||
"salary_min": 140000,
|
||||
"salary_max": 210000,
|
||||
"posted_at": "2026-08-26T12:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-018",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-018",
|
||||
"employer_id": "emp-openai",
|
||||
"title": "AI Trust & Safety Analyst",
|
||||
"description": "Investigate misuse patterns, triage safety incidents, and improve policy enforcement for consumer AI products. Author postmortems and recommend mitigations.",
|
||||
"required_competencies": [
|
||||
"stack-safety-c008",
|
||||
"stack-safety-c006",
|
||||
"stack-safety-c002"
|
||||
],
|
||||
"skills": [
|
||||
"Trust & safety",
|
||||
"Incident response",
|
||||
"Policy",
|
||||
"Investigation"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "San Francisco, CA",
|
||||
"remote": true,
|
||||
"salary_min": 115000,
|
||||
"salary_max": 170000,
|
||||
"posted_at": "2026-09-03T10:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-019",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-019",
|
||||
"employer_id": "emp-replicate",
|
||||
"title": "Edge AI Engineer",
|
||||
"description": "Optimize and deploy models to edge hardware. Quantize, distill, and compile models for low-latency inference on field devices with constrained budgets.",
|
||||
"required_competencies": [
|
||||
"stack-operator-c008",
|
||||
"stack-orchestration-c012",
|
||||
"stack-orchestration-c014"
|
||||
],
|
||||
"skills": [
|
||||
"Edge ML",
|
||||
"TensorRT",
|
||||
"C++",
|
||||
"Quantization"
|
||||
],
|
||||
"seniority": "mid",
|
||||
"category": null,
|
||||
"location": "Remote",
|
||||
"remote": true,
|
||||
"salary_min": 130000,
|
||||
"salary_max": 195000,
|
||||
"posted_at": "2026-08-24T11:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "job-020",
|
||||
"source": "seed-corpus",
|
||||
"external_id": "job-020",
|
||||
"employer_id": "emp-cohere",
|
||||
"title": "AI Research Engineer",
|
||||
"description": "Push the frontier of language model capabilities. Prototype new architectures, run large-scale experiments, and contribute to publications and open-source releases.",
|
||||
"required_competencies": [
|
||||
"stack-orchestration-c001",
|
||||
"stack-science-c010",
|
||||
"stack-science-c011"
|
||||
],
|
||||
"skills": [
|
||||
"PyTorch",
|
||||
"Research",
|
||||
"Transformers",
|
||||
"Distributed training"
|
||||
],
|
||||
"seniority": "senior",
|
||||
"category": null,
|
||||
"location": "Berlin, Germany",
|
||||
"remote": true,
|
||||
"salary_min": 155000,
|
||||
"salary_max": 235000,
|
||||
"posted_at": "2026-08-20T09:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
"""Posting text → skills/seniority/category tags (REQ-6-002, D-052).
|
||||
|
||||
Composes the EXISTING structured_completion 4-layer pipeline (D-020)
|
||||
with the PostingTags schema — no new agent, a one-shot extraction like
|
||||
grading's rubric call. Mock-first for free: the mock provider's
|
||||
json_object path (or ScriptedJSONProvider) keeps tests cloud-free.
|
||||
|
||||
Tags are DERIVED data (D-052): re-tagging overwrites the posting's tags
|
||||
column — single source of truth; no append-only semantics (unlike P4's
|
||||
enrichment provenance).
|
||||
|
||||
Tag failure is HONEST, never fatal: a provider error leaves the posting
|
||||
UNTAGGED (tags=None) with a logged warning — facets treat untagged
|
||||
postings honestly (they simply do not contribute counts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..agents.structured import structured_completion
|
||||
from ..llm.base import LLMProvider
|
||||
from ..llm.types import Message
|
||||
from .base import PostingTags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Prompt version — bump on any wording change (prompts are code).
|
||||
TAGGING_PROMPT_VERSION = 1
|
||||
|
||||
TAGGING_PROMPT = """You are a job-posting tagger for an AI-era roles
|
||||
marketplace. Extract structured tags from the posting.
|
||||
|
||||
Rules:
|
||||
- skills: 3-10 concrete, lowercase technology/role skills actually named
|
||||
or clearly required by the posting (e.g. "python", "llm evaluation",
|
||||
"kubernetes"). Prefer the posting's own vocabulary.
|
||||
- seniority: exactly one of "entry", "mid", "senior", "staff",
|
||||
"principal" — infer from title + experience language; omit (null) if
|
||||
genuinely undeterminable.
|
||||
- category: exactly one of "ai-orchestration", "ai-safety", "product",
|
||||
"data", "field-operations", "research" — the role's primary domain;
|
||||
omit (null) if genuinely undeterminable.
|
||||
|
||||
Respond with ONLY the JSON object."""
|
||||
|
||||
_SCHEMA_HINT = (
|
||||
'{"skills": ["up to 10 lowercase skill strings"], '
|
||||
'"seniority": "entry|mid|senior|staff|principal|null", '
|
||||
'"category": "ai-orchestration|ai-safety|product|data|'
|
||||
'field-operations|research|null"}'
|
||||
)
|
||||
|
||||
|
||||
class TaggingUnavailable(Exception):
|
||||
"""The tagger could not produce valid tags (provider/parse failure).
|
||||
|
||||
The caller persists the posting UNTAGGED — never a crash, never a
|
||||
fabricated tag set (D-052 honesty).
|
||||
"""
|
||||
|
||||
|
||||
async def tag_posting(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
title: str,
|
||||
description: str,
|
||||
skills_hint: list[str] | None = None,
|
||||
) -> PostingTags:
|
||||
"""One-shot tag extraction via D-020 structured completion.
|
||||
|
||||
`skills_hint` (the posting's declared skills) rides the prompt so
|
||||
the tagger anchors on the author's own vocabulary; the schema bound
|
||||
(≤10) is the enforcement — an 11-skill response fails validation,
|
||||
runs the single bounded retry, and surfaces TaggingUnavailable if
|
||||
the retry also misses.
|
||||
"""
|
||||
hint = ""
|
||||
if skills_hint:
|
||||
hint = "\nDeclared skills: " + ", ".join(skills_hint[:15])
|
||||
messages = [
|
||||
Message(role="system", content=TAGGING_PROMPT),
|
||||
Message(
|
||||
role="user",
|
||||
content=f"Title: {title}\n\nDescription:\n{description}{hint}",
|
||||
),
|
||||
]
|
||||
try:
|
||||
return await structured_completion(
|
||||
provider,
|
||||
messages,
|
||||
model=model,
|
||||
schema=PostingTags,
|
||||
schema_hint=_SCHEMA_HINT,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Provider failures (RuntimeError from mock/loud providers) and
|
||||
# parse failures (StructuredOutputError) both land here: honest
|
||||
# untagged, loud log.
|
||||
logger.warning(
|
||||
"marketplace tagging failed for %r: %s — posting stays "
|
||||
"untagged (honest None, never fabricated)",
|
||||
title[:60],
|
||||
exc,
|
||||
)
|
||||
raise TaggingUnavailable(str(exc)) from exc
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Sandbox capability probe — the honest degrade truth source (D-068, v0.8).
|
||||
|
||||
Runtime promotion of the test-tier userns probe (``tests/helpers/userns.py``
|
||||
mechanism, verbatim): a REAL ``unshare --user --map-root-user`` attempt is
|
||||
the truth source for whether the namespace fabric can provision learner
|
||||
environments on this host. Container detection (``/.dockerenv`` or the
|
||||
``NC_CONTAINER`` marker the image sets) is belt-and-suspenders context for
|
||||
the remediation text — the degrade decision itself is ALWAYS the probe,
|
||||
never a guess from the marker (a privileged container with working userns
|
||||
must NOT report degraded).
|
||||
|
||||
The single remediation constant (G-208) is shared by the boot notice
|
||||
(``main.py`` lifespan) and the 503 arm (``api/sandboxes.py``) so the two
|
||||
surfaces can never drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
#: Remediation text (G-208): names the unprivileged default, the seccomp
|
||||
#: constraint, the privileged variant with exact flags, and the host-side
|
||||
#: alternative. One constant — boot notice and 503 payload import it, so
|
||||
#: they cannot drift apart.
|
||||
DEGRADE_REMEDIATION = (
|
||||
"learner sandbox builds are unavailable on this host: the namespace "
|
||||
"fabric needs unprivileged user namespaces and the capability probe "
|
||||
"failed. The nextcraft container runs unprivileged by default and "
|
||||
"docker's default seccomp profile denies the nested `unshare --user` "
|
||||
"the fabric requires. Options: (1) run the image with "
|
||||
"`--security-opt seccomp=unconfined` (and `--security-opt "
|
||||
"apparmor=unconfined` where apparmor applies) on a host that allows "
|
||||
"unprivileged userns; or (2) run the stack binary-side on the host "
|
||||
"(`nextcraft deploy --service`) where the fabric works natively. "
|
||||
"Everything else in this deployment is unaffected."
|
||||
)
|
||||
|
||||
_probe_result: bool | None = None
|
||||
|
||||
|
||||
def userns_available() -> bool:
|
||||
"""True only if `unshare` exists AND `--user --map-root-user` works here.
|
||||
|
||||
The result is cached per process (the probe spawns a subprocess; one
|
||||
answer per boot is the contract — the boot notice and every 503 read
|
||||
the same truth).
|
||||
"""
|
||||
global _probe_result
|
||||
if _probe_result is None:
|
||||
_probe_result = _probe()
|
||||
return _probe_result
|
||||
|
||||
|
||||
def _probe() -> bool:
|
||||
if shutil.which("unshare") is None:
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["unshare", "--user", "--map-root-user", "true"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def container_detected() -> bool:
|
||||
"""Context marker (not the degrade truth): /.dockerenv or NC_CONTAINER.
|
||||
|
||||
Used by the boot notice to tailor the message and by the CLI's
|
||||
container-mode branches. A privileged container with working userns
|
||||
is NOT degraded — the probe alone decides that.
|
||||
"""
|
||||
if Path("/.dockerenv").exists():
|
||||
return True
|
||||
import os
|
||||
|
||||
return os.environ.get("NC_CONTAINER") == "1"
|
||||
|
||||
|
||||
def sandbox_fabric_degraded() -> bool:
|
||||
"""The degrade decision: the probe fails. Marker never overrides it."""
|
||||
return not userns_available()
|
||||
@@ -38,6 +38,7 @@ BOUNDARY: this module NEVER imports `api/` or `agents/`.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -50,6 +51,7 @@ from pathlib import Path
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..config import Settings
|
||||
from ..telemetry.tokens import IngestToken
|
||||
from . import workdir as workdir_mod
|
||||
from .backend import SandboxBackend, SandboxHandle
|
||||
|
||||
@@ -119,12 +121,21 @@ class SandboxManager:
|
||||
backend: SandboxBackend,
|
||||
settings: Settings,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
ingest_token: IngestToken | None = None,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._settings = settings
|
||||
self._clock = clock or (lambda: datetime.now(UTC))
|
||||
# v0.4.7 (P1): shared with the WS endpoint via app.state — mint and
|
||||
# verify MUST use one instance while the secret is per-boot random.
|
||||
self._ingest_token = ingest_token or IngestToken(
|
||||
getattr(settings, "telemetry_ingest_secret", "") or ""
|
||||
)
|
||||
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 +183,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",
|
||||
@@ -186,15 +198,20 @@ class SandboxManager:
|
||||
|
||||
The agent joins the sandbox mount namespace but NOT its (offline)
|
||||
network namespace, so it reaches this service over loopback
|
||||
(`telemetry_ingest_host`, A-004 port).
|
||||
(`telemetry_ingest_host`, A-004 port). v0.4.7: the URL carries an
|
||||
HMAC ingest token (P1) — the WS endpoint rejects dials without a
|
||||
valid one, so LAN peers cannot poison traces with forged identity
|
||||
params.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
token = self._ingest_token.mint(learner_id, task_id, sandbox_id)
|
||||
query = urlencode(
|
||||
{
|
||||
"learner_id": learner_id,
|
||||
"task_id": task_id,
|
||||
"sandbox_id": sandbox_id,
|
||||
"token": token,
|
||||
}
|
||||
)
|
||||
ingest_url = (
|
||||
@@ -246,6 +263,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(
|
||||
@@ -264,7 +282,7 @@ class SandboxManager:
|
||||
|
||||
# -- reapers ------------------------------------------------------------
|
||||
|
||||
async def reap_expired(self) -> list[str]:
|
||||
async def reap_expired(self) -> builtins.list[str]:
|
||||
"""One reaper pass: wall-clock timeout + G-2 workdir-size sweep.
|
||||
|
||||
Destroys sandboxes older than `settings.sandbox_timeout_s`, then sweeps
|
||||
@@ -273,9 +291,14 @@ class SandboxManager:
|
||||
signal). Returns the ids destroyed this pass. Invoke on an async timer
|
||||
(the app lifespan owns the loop interval); both checks deliberately
|
||||
share one pass so the periodic work is O(live sandboxes) once.
|
||||
|
||||
P5: the return annotation is `builtins.list` because this class's
|
||||
own `list()` method shadows the `list[...]` subscript form in
|
||||
annotations below it (a mypy scoping quirk — the method name is
|
||||
public API and stays).
|
||||
"""
|
||||
now = self._clock()
|
||||
destroyed: list[str] = []
|
||||
destroyed: builtins.list[str] = []
|
||||
timeout_s = float(self._settings.sandbox_timeout_s)
|
||||
cap_bytes = int(self._settings.sandbox_max_workdir_mb) * 1024 * 1024
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ frame — no envelope:
|
||||
Server → client frames are typed status envelopes:
|
||||
|
||||
{"type": "ack_total", "count": N} — final flush summary, then close 1000
|
||||
{"type": "seq_ack", "seq": N} — advisory: durable latest_seq after
|
||||
each successful append (D-045; the
|
||||
agent trims its spool to seq > ack)
|
||||
{"type": "gap_warning", "missing_seqs": [...]} — seq skipped ahead
|
||||
{"type": "event_rejected", "detail": "..."} — one frame failed validation
|
||||
(seq echoed when parseable)
|
||||
@@ -349,6 +352,13 @@ class IngestSession:
|
||||
else:
|
||||
self._stored += 1
|
||||
self._seen.add(frame.seq)
|
||||
# Seq-ack (D-045, REQ-5-007): advisory hint carrying the durable
|
||||
# latest_seq AFTER this append — the capture agent trims its spool to
|
||||
# seq > ack on receipt, bounding the replay margin to the in-flight
|
||||
# window. Emitted on dedup'd appends too (a-6) so a replay flush
|
||||
# tightens the margin immediately. Gap detection stays authoritative
|
||||
# (_check_gap below); G-3 flood semantics untouched.
|
||||
await self._send_json({"type": "seq_ack", "seq": after})
|
||||
await self._check_gap(frame.seq)
|
||||
# SQLite appends are sync and fast; on a burst the drainer can hold
|
||||
# the loop between receives. Yield so the WS writer flushes the close
|
||||
|
||||
@@ -21,7 +21,7 @@ Boundary (D-027): telemetry/ never imports agents/ or api/.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import JSON, Index, String
|
||||
@@ -38,7 +38,9 @@ EventKind = Literal[
|
||||
"stdin",
|
||||
"stdout",
|
||||
]
|
||||
_EVENT_KINDS: frozenset[str] = frozenset(EventKind.__args__)
|
||||
# P5: get_args() is the typed accessor for Literal values (bare __args__
|
||||
# trips mypy on the typing special form).
|
||||
_EVENT_KINDS: frozenset[str] = frozenset(get_args(EventKind))
|
||||
|
||||
|
||||
class TelemetryEvent(SQLModel, table=True):
|
||||
|
||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
from sqlmodel import Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
from .models import TelemetryEvent
|
||||
@@ -151,7 +151,9 @@ class SQLiteTraceStore:
|
||||
select(TelemetryEvent)
|
||||
.where(TelemetryEvent.learner_id == learner_id)
|
||||
.where(TelemetryEvent.task_id == task_id)
|
||||
.order_by(TelemetryEvent.seq)
|
||||
# P5: col() — SQLModel class attrs are Mapped[T], the
|
||||
# column reference rides col() in order_by/func.count.
|
||||
.order_by(col(TelemetryEvent.seq))
|
||||
)
|
||||
results = session.exec(stmt).all()
|
||||
# Detach from the session: callers must not depend on open-session
|
||||
@@ -167,7 +169,7 @@ class SQLiteTraceStore:
|
||||
select(TelemetryEvent.seq)
|
||||
.where(TelemetryEvent.learner_id == learner_id)
|
||||
.where(TelemetryEvent.task_id == task_id)
|
||||
.order_by(TelemetryEvent.seq)
|
||||
.order_by(col(TelemetryEvent.seq))
|
||||
)
|
||||
# sqlmodel scalar select: rows are plain ints, not 1-tuples.
|
||||
return [int(seq) for seq in session.exec(stmt).all()]
|
||||
@@ -196,7 +198,7 @@ class SQLiteTraceStore:
|
||||
# docstring-free body keeps it obvious what the query shape is.
|
||||
with self._session() as session:
|
||||
stmt = (
|
||||
select(sa.func.count(TelemetryEvent.seq))
|
||||
select(sa.func.count(col(TelemetryEvent.seq)))
|
||||
.where(TelemetryEvent.learner_id == learner_id)
|
||||
.where(TelemetryEvent.task_id == task_id)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""HMAC tokens for the WS ingest socket (v0.4.7, P1 final-review flag).
|
||||
|
||||
The ingest WS is a grading input: events land in the trace store the
|
||||
Assessor/Proctor consume. In network mode the socket is reachable on the
|
||||
LAN, and the endpoint accepted any ``learner_id``/``task_id`` query params —
|
||||
anyone on the network could poison or flood a learner's trace (grading
|
||||
DoS, the G-3 flood cap only limits volume, not identity).
|
||||
|
||||
The sandbox manager bakes ``token=<hmac>`` into ``NC_INGEST_URL``; the WS
|
||||
endpoint recomputes the HMAC from the presented identity and rejects on
|
||||
mismatch. Nonce-free and stateless: the token binds (learner_id, task_id,
|
||||
sandbox_id) to the process's secret — nothing to store, nothing to leak
|
||||
beyond the URL the manager already owns. Restart rotates the secret when
|
||||
unset (per-boot random); sandboxes are not reboot-persistent, so stale
|
||||
URLs dying with the boot is the correct lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
_TOKEN_TTL_BIND_FIELDS = ("learner_id", "task_id", "sandbox_id")
|
||||
|
||||
|
||||
class IngestToken:
|
||||
"""Mint/verify HMAC ingest tokens bound to the settings secret."""
|
||||
|
||||
def __init__(self, secret: str = "") -> None:
|
||||
# Empty/short configured secrets are REPLACED per boot (never warn-
|
||||
# and-accept a guessable secret on an unauthenticated socket).
|
||||
self._secret = (
|
||||
secret.encode("utf-8")
|
||||
if secret
|
||||
else secrets.token_bytes(32)
|
||||
)
|
||||
|
||||
def mint(self, learner_id: str, task_id: str, sandbox_id: str) -> str:
|
||||
msg = _bind_message(learner_id, task_id, sandbox_id)
|
||||
return hmac.new(self._secret, msg, hashlib.sha256).hexdigest()
|
||||
|
||||
def verify(
|
||||
self, token: str, learner_id: str, task_id: str, sandbox_id: str
|
||||
) -> bool:
|
||||
expected = self.mint(learner_id, task_id, sandbox_id)
|
||||
# compare_digest guards the timing channel; an empty token never
|
||||
# matches (mint always yields 64 hex chars).
|
||||
return bool(token) and hmac.compare_digest(expected, token)
|
||||
|
||||
|
||||
def _bind_message(learner_id: str, task_id: str, sandbox_id: str) -> bytes:
|
||||
# Length-prefixed concat — no delimiter injection: a (learner, task)
|
||||
# pair cannot be re-spelled into another valid binding.
|
||||
parts = [learner_id, task_id, sandbox_id]
|
||||
return b"\x00".join(
|
||||
f"{len(p)}:{p}".encode() for p in parts
|
||||
)
|
||||
|
||||
|
||||
#: Field names bound into the token (docs + tests import this).
|
||||
TOKEN_BIND_FIELDS = tuple(_TOKEN_TTL_BIND_FIELDS)
|
||||
@@ -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,9 +41,9 @@ 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
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
|
||||
@@ -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]:
|
||||
@@ -285,7 +325,9 @@ class SQLiteVariantStore:
|
||||
.where(VariantRecord.learner_id == learner_id)
|
||||
# Chronological; task_id is a deterministic tie-break for
|
||||
# variants stamped within the same instant.
|
||||
.order_by(VariantRecord.created_at, VariantRecord.task_id)
|
||||
# P5: col() — SQLModel class attrs are Mapped[T], the
|
||||
# column reference rides col() in order_by.
|
||||
.order_by(col(VariantRecord.created_at), VariantRecord.task_id)
|
||||
)
|
||||
results = session.exec(stmt).all()
|
||||
for row in results:
|
||||
@@ -299,7 +341,7 @@ class SQLiteVariantStore:
|
||||
select(VariantRecord)
|
||||
.where(VariantRecord.template_id == template_id)
|
||||
# Chronological; learner_id is a deterministic tie-break.
|
||||
.order_by(VariantRecord.created_at, VariantRecord.learner_id)
|
||||
.order_by(col(VariantRecord.created_at), VariantRecord.learner_id)
|
||||
)
|
||||
results = session.exec(stmt).all()
|
||||
for row in results:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class VoiceDescriptor(BaseModel):
|
||||
"""Capability descriptor served to the web client (D-030).
|
||||
|
||||
The assessment UI reads this to decide HOW the learner speaks/hears:
|
||||
- `mode="server"` → server-side STT/TTS (v0.4 real provider seam)
|
||||
- `mode="server"` → server-side STT/TTS (openai-audio, live since v0.5)
|
||||
- `mode="browser"` → browser-native SpeechRecognition/speechSynthesis
|
||||
- `mode="mock"` → deterministic no-op path (tests / no-key dev)
|
||||
The descriptor never contains secrets — only capability hints.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Browser-native fallback descriptor (D-030, CUT-1 / G-7, REQ-3-006).
|
||||
|
||||
v0.3 has NO real server STT/TTS (deferred to v0.4 with KYC/keys — GRILL
|
||||
CUT-1). When the factory selects `browser` mode, the defense endpoints return
|
||||
this descriptor and the WEB CLIENT performs SpeechRecognition + speechSynthesis
|
||||
natively; the server persists text turns as usual.
|
||||
Browser-native SR/TTS is the no-key CLIENT-side path. When the factory
|
||||
selects `browser` mode, the defense endpoints return this descriptor and the
|
||||
WEB CLIENT performs SpeechRecognition + speechSynthesis natively; the server
|
||||
persists text turns as usual. Real server STT/TTS (openai-audio) is live
|
||||
since v0.5 — this descriptor is the no-key fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,7 +27,7 @@ MOCK_DESCRIPTOR = VoiceDescriptor(
|
||||
sr_available=True,
|
||||
tts_available=True,
|
||||
hint=(
|
||||
"Deterministic mock voice (tests / no-key dev). Server STT/TTS "
|
||||
"endpoints serve canned responses; real server STT/TTS lands in v0.4."
|
||||
"Deterministic mock voice (tests / no-key dev). Real server "
|
||||
"STT/TTS is live since v0.5 (AI_VOICE_PROVIDER=openai-audio)."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -63,22 +63,24 @@ from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol
|
||||
from typing import Any, Literal, Protocol, get_args
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import JSON, Index, String
|
||||
from sqlalchemy.orm import validates
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DefenseStatus = Literal["in_progress", "finished"]
|
||||
_DEFENSE_STATUSES: frozenset[str] = frozenset(DefenseStatus.__args__)
|
||||
# P5: get_args() is the typed accessor for Literal values (bare __args__
|
||||
# trips mypy on the typing special form).
|
||||
_DEFENSE_STATUSES: frozenset[str] = frozenset(get_args(DefenseStatus))
|
||||
|
||||
TurnRole = Literal["examiner", "learner"]
|
||||
_TURN_ROLES: frozenset[str] = frozenset(TurnRole.__args__)
|
||||
_TURN_ROLES: frozenset[str] = frozenset(get_args(TurnRole))
|
||||
|
||||
|
||||
class DefenseRecord(SQLModel, table=True):
|
||||
@@ -464,7 +466,9 @@ class SQLiteDefenseStore:
|
||||
stmt = (
|
||||
select(DefenseTurn)
|
||||
.where(DefenseTurn.defense_id == defense_id)
|
||||
.order_by(DefenseTurn.seq)
|
||||
# P5: col() — SQLModel class attrs are Mapped[T], the
|
||||
# column reference rides col() in order_by.
|
||||
.order_by(col(DefenseTurn.seq))
|
||||
)
|
||||
turns = session.exec(stmt).all()
|
||||
for turn in turns:
|
||||
@@ -489,7 +493,7 @@ class SQLiteDefenseStore:
|
||||
.where(DefenseRecord.learner_id == learner_id)
|
||||
# Chronological; id is a deterministic tie-break for
|
||||
# defenses stamped within the same instant.
|
||||
.order_by(DefenseRecord.created_at, DefenseRecord.id)
|
||||
.order_by(col(DefenseRecord.created_at), DefenseRecord.id)
|
||||
)
|
||||
results = session.exec(stmt).all()
|
||||
for row in results:
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
"""Voice provider factory (D-030, REQ-3-006).
|
||||
"""Voice provider factory (D-030; REQ-5-001 real path, D-040).
|
||||
|
||||
`AI_VOICE_PROVIDER = browser | mock` (default: mock — the no-key path is
|
||||
first-class). The real server provider (`openai-audio`) is a v0.4 seam and
|
||||
is REJECTED here with a clear error naming the deferral, so a stale env var
|
||||
can't silently pretend a real backend exists.
|
||||
`AI_VOICE_PROVIDER = mock | browser | openai-audio` (default: mock — the
|
||||
no-key path is first-class). `openai-audio` requires voice_base_url +
|
||||
voice_api_key: the factory raises `UnknownVoiceProviderError` with an
|
||||
actionable message for direct callers (tests), while the lifespan in
|
||||
main.py CATCHES it and falls back to mock with a loud log — a typo'd env
|
||||
must never crash the unattended boot (G-11), and the mock provider's
|
||||
descriptor then honestly reports mode='mock' so the UI badge cannot lie.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import Settings
|
||||
from .base import VoiceProvider
|
||||
from .mock import MockVoiceProvider
|
||||
from .openai_audio import OpenAIAudioProvider, TtsFormat
|
||||
|
||||
|
||||
class UnknownVoiceProviderError(ValueError):
|
||||
"""Raised for a provider name outside the v0.3 contract."""
|
||||
"""Raised for a provider name outside the contract, or a real provider
|
||||
selected without its required configuration."""
|
||||
|
||||
|
||||
def voice_provider_from_settings(settings: Settings) -> VoiceProvider:
|
||||
"""Select the voice provider by settings (env `AI_VOICE_PROVIDER`)."""
|
||||
def voice_provider_from_settings(
|
||||
settings: Settings, http_client: httpx.AsyncClient | None = None
|
||||
) -> VoiceProvider:
|
||||
"""Select the voice provider by settings (env `AI_VOICE_PROVIDER`).
|
||||
|
||||
`http_client` is required for the `openai-audio` branch (D-017 shared
|
||||
pool); mock/browser ignore it.
|
||||
"""
|
||||
name = (settings.voice_provider or "mock").strip().lower()
|
||||
if name == "mock":
|
||||
return MockVoiceProvider()
|
||||
@@ -28,10 +43,30 @@ def voice_provider_from_settings(settings: Settings) -> VoiceProvider:
|
||||
# uses the descriptor for mic/speech). See browser.py.
|
||||
return MockVoiceProvider()
|
||||
if name in ("openai-audio", "openai", "server"):
|
||||
raise UnknownVoiceProviderError(
|
||||
"real server STT/TTS (OpenAIAudioProvider) is deferred to v0.4 "
|
||||
"(GRILL CUT-1 / G-7): set AI_VOICE_PROVIDER=mock or browser"
|
||||
if not settings.voice_base_url or not settings.voice_api_key:
|
||||
raise UnknownVoiceProviderError(
|
||||
"AI_VOICE_PROVIDER=openai-audio requires AI_VOICE_BASE_URL "
|
||||
"and AI_VOICE_API_KEY — set both, or use 'mock'/'browser'. "
|
||||
"(main.py falls back to mock when these are missing; the "
|
||||
"voice badge then honestly reports mock — G-11)"
|
||||
)
|
||||
if http_client is None:
|
||||
raise UnknownVoiceProviderError(
|
||||
"openai-audio requires the shared httpx client "
|
||||
"(voice_provider_from_settings(settings, http_client))"
|
||||
)
|
||||
return OpenAIAudioProvider(
|
||||
http_client=http_client,
|
||||
base_url=settings.voice_base_url,
|
||||
api_key=settings.voice_api_key,
|
||||
stt_model=settings.voice_stt_model,
|
||||
tts_model=settings.voice_tts_model,
|
||||
tts_voice=settings.voice_tts_voice,
|
||||
# P5: Settings.voice_tts_format is str, but its pydantic
|
||||
# validator normalizes to exactly mp3|wav|opus (invalid → mp3
|
||||
# with a warning) — the cast states that provenance.
|
||||
tts_format=cast(TtsFormat, settings.voice_tts_format),
|
||||
)
|
||||
raise UnknownVoiceProviderError(
|
||||
f"unknown AI_VOICE_PROVIDER {name!r}: use 'mock' or 'browser'"
|
||||
f"unknown AI_VOICE_PROVIDER {name!r}: use 'mock', 'browser', or 'openai-audio'"
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import struct
|
||||
import wave
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from .base import TranscriptSegment
|
||||
from .base import TranscriptSegment, VoiceProvider
|
||||
|
||||
|
||||
def _tone_wav(duration_ms: int = 250, freq_hz: float = 1000.0) -> bytes:
|
||||
@@ -73,9 +73,9 @@ class MockVoiceProvider:
|
||||
duration_ms=max(1, len(audio) // 32), # deterministic pseudo-duration
|
||||
)
|
||||
|
||||
async def synthesize(self, text: str, voice: str = "default") -> AsyncIterator[bytes]: # noqa: ASYNC109 (protocol parity)
|
||||
async def synthesize(self, text: str, voice: str = "default") -> AsyncIterator[bytes]:
|
||||
# NOTE: protocol parity matters more than the async-generator purity
|
||||
# lint; the real provider seam (v0.4) will stream over HTTP.
|
||||
# lint; the real provider (openai_audio.py) streams over HTTP.
|
||||
self.synthesize_calls += 1
|
||||
if not text:
|
||||
raise MockVoiceFailure("cannot synthesize empty text")
|
||||
@@ -86,7 +86,6 @@ class MockVoiceProvider:
|
||||
|
||||
|
||||
# Protocol-shape parity guard (mock must satisfy the D-030 port).
|
||||
from .base import VoiceProvider # noqa: E402
|
||||
|
||||
|
||||
def _assert_protocol() -> None:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""OpenAI-compatible audio provider — real server STT/TTS (D-040, REQ-5-001).
|
||||
|
||||
One implementation serves any OpenAI-compatible audio endpoint (base_url is
|
||||
config; A-301 endpoint-agnostic by config, D-014 pattern). Raw httpx on the
|
||||
shared lifespan client (D-017; read=300s tolerates multi-minute clips).
|
||||
|
||||
Boundary rules (mirror llm/openai_compat.py):
|
||||
- voice/ imports nothing from agents/ or api/
|
||||
- api_key NEVER appears in exceptions, logs, or error messages
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import TranscriptSegment, VoiceDescriptor
|
||||
|
||||
#: TTS response formats the OpenAI audio endpoint accepts (P5: named so
|
||||
#: the factory can type-honestly pass the config-normalized value through).
|
||||
TtsFormat = Literal["mp3", "wav", "opus"]
|
||||
|
||||
|
||||
class OpenAIAudioProvider:
|
||||
"""Server STT (`/audio/transcriptions`) + TTS (`/audio/speech`)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
stt_model: str = "whisper-1",
|
||||
tts_model: str = "tts-1",
|
||||
tts_voice: str = "alloy",
|
||||
tts_format: TtsFormat = "mp3",
|
||||
) -> None:
|
||||
self._client = http_client
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._stt_model = stt_model
|
||||
self._tts_model = tts_model
|
||||
self._tts_voice = tts_voice
|
||||
self._tts_format = tts_format
|
||||
# a-15: the descriptor is what defense.py prefers; a missing one
|
||||
# would badge the real server path as "mock".
|
||||
self.descriptor = VoiceDescriptor(
|
||||
mode="server",
|
||||
sr_available=True,
|
||||
tts_available=True,
|
||||
hint="server STT/TTS via AI_VOICE_BASE_URL",
|
||||
)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
return headers
|
||||
|
||||
def _sanitize(self, exc: Exception) -> RuntimeError:
|
||||
text = str(exc)
|
||||
if self._api_key and self._api_key in text:
|
||||
text = text.replace(self._api_key, "[REDACTED]")
|
||||
return RuntimeError(f"voice provider error: {text}")
|
||||
|
||||
async def transcribe(self, audio: bytes, fmt: str) -> TranscriptSegment:
|
||||
"""STT: multipart upload (`file` + `model`) → TranscriptSegment.
|
||||
|
||||
`fmt` is a bare extension ('wav' | 'webm' | 'mp3') — the defense
|
||||
route strips codec params before this call (D-041).
|
||||
"""
|
||||
files = {"file": (f"answer.{fmt}", audio, f"audio/{fmt}")}
|
||||
data = {"model": self._stt_model, "response_format": "json"}
|
||||
try:
|
||||
resp = await self._client.post(
|
||||
f"{self._base_url}/audio/transcriptions",
|
||||
files=files,
|
||||
data=data,
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise self._sanitize(exc) from exc
|
||||
if not isinstance(body, dict):
|
||||
# P2 (verifier): a non-dict 200 body is a contract break —
|
||||
# context-wrap instead of a raw AttributeError.
|
||||
raise RuntimeError(
|
||||
"voice provider error: unexpected transcription response shape"
|
||||
)
|
||||
text = str(body.get("text", "")).strip()
|
||||
if not text:
|
||||
# 200 with an empty transcript is a provider contract break —
|
||||
# TranscriptSegment(min_length=1) would raise a bare pydantic
|
||||
# error; wrap it with provider context instead.
|
||||
raise RuntimeError("voice provider error: empty transcription")
|
||||
return TranscriptSegment(text=text)
|
||||
|
||||
def synthesize(
|
||||
self, text: str, voice: str = "default"
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""TTS: JSON body → raw audio byte stream.
|
||||
|
||||
OpenAI's TTS caps `input` at 4096 chars; examiner questions are
|
||||
short, but enforce the guard so a long question fails loudly at the
|
||||
seam instead of as an opaque provider 400.
|
||||
"""
|
||||
return self._synthesize_stream(text, voice)
|
||||
|
||||
async def _synthesize_stream(
|
||||
self, text: str, voice: str
|
||||
) -> AsyncIterator[bytes]:
|
||||
if len(text) > 4096:
|
||||
raise RuntimeError(
|
||||
f"voice provider error: TTS input exceeds 4096 chars ({len(text)})"
|
||||
)
|
||||
payload = {
|
||||
"model": self._tts_model,
|
||||
"input": text,
|
||||
"voice": voice if voice != "default" else self._tts_voice,
|
||||
"response_format": self._tts_format,
|
||||
}
|
||||
try:
|
||||
async with self._client.stream(
|
||||
"POST",
|
||||
f"{self._base_url}/audio/speech",
|
||||
content=json.dumps(payload),
|
||||
headers={**self._headers(), "Content-Type": "application/json"},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if chunk:
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
raise self._sanitize(exc) from exc
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@nextcraft/ai-service",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.5",
|
||||
"scripts": {
|
||||
"dev": "bash scripts/dev.sh",
|
||||
"test": "bash scripts/test.sh",
|
||||
"bootstrap": "bash scripts/bootstrap.sh",
|
||||
"lint": "bash scripts/lint.sh"
|
||||
"lint": "bash scripts/lint.sh",
|
||||
"typecheck": "bash scripts/typecheck.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "nextcraft-ai-service"
|
||||
version = "0.2.0"
|
||||
version = "0.6.5"
|
||||
description = "Nextcraft AI tutor service — six LLM agents behind a provider-agnostic layer"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -14,20 +14,32 @@ dependencies = [
|
||||
"pydantic-settings>=2.15,<2.16",
|
||||
"httpx>=0.28,<0.29",
|
||||
"sse-starlette>=3.4,<3.5",
|
||||
"sqlmodel>=0.0.24,<0.1",
|
||||
"sqlmodel>=0.0.24,<0.0.43",
|
||||
# ^ P1 CI finding: sqlmodel 0.0.43+ (up to 0.0.47 resolved in fresh CI
|
||||
# venvs) tightens Literal-arg inference on pydantic models — 15 mypy
|
||||
# arg-type errors surface (DefenseRecord/TelemetryEvent/DefenseTurn).
|
||||
# The v0.7-shipped line was 0.0.42; cap below .43 until the Literal
|
||||
# annotations are tightened deliberately (per-module overrides later).
|
||||
"sqlalchemy>=2.0,<2.1",
|
||||
"websockets>=13,<16",
|
||||
"aiofiles>=24.1,<26",
|
||||
# POST /v1/defense/{id}/answer multipart audio (REQ-3-006): FastAPI
|
||||
# form/File parsing requires python-multipart at runtime.
|
||||
"python-multipart>=0.0.32,<0.1",
|
||||
# P4-B resume parse (REQ-6-005, D-054, A-410): PDF text extraction.
|
||||
# Pure-Python, probe-verified 6.19.0 — the ONLY new v0.6 dependency.
|
||||
"pypdf>=6.19,<7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=9.1,<10",
|
||||
"pytest-asyncio>=1.4,<2",
|
||||
"pytest-cov>=7.1,<8",
|
||||
"pytest-timeout>=2.4,<3",
|
||||
"ruff>=0.14",
|
||||
# P5 type hardening (D-058): mypy relaxed-first baseline. Resolved 2.3.1.
|
||||
"mypy>=2.3,<2.4",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -35,13 +47,105 @@ include = ["ai_service*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
# D-056: pin the fixture loop scope — pytest-asyncio 1.4 warns at configure
|
||||
# time when this is unset and a future default flip would change loop reuse
|
||||
# for async fixtures. "function" is the current-effective default semantics.
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["tests"]
|
||||
# P1 harness wiring (D-053/D-054): strict markers + a 60s global timeout.
|
||||
# `timeout` also ships its own marker (`pytest.mark.timeout`); the 180s
|
||||
# override for real_server/userns tests is applied by the collection hook
|
||||
# in tests/conftest.py — marker-level values beat this global default.
|
||||
markers = [
|
||||
"fast: in-process unit test (default tier — unmarked tests are fast)",
|
||||
"real_server: spins a real uvicorn server process",
|
||||
"userns: enters a real unshare user namespace",
|
||||
]
|
||||
addopts = "--strict-markers --timeout=60 --timeout-method=signal"
|
||||
# P3 zero-warning policy (D-056): base action `error` — every warning fails
|
||||
# the run; new first-party warnings can never pass silently. The ONLY
|
||||
# entries after "error" are the 2 known upstream import-time deprecations,
|
||||
# each scoped by exact message prefix + emitting module (the module whose
|
||||
# import triggers the warning, i.e. the frame the warning is attributed to
|
||||
# after stacklevel adjustment — NOT the package the warning text mentions):
|
||||
#
|
||||
# 1. fastapi.testclient re-exports starlette's TestClient, and importing
|
||||
# it emits starlette's httpx→httpx2 migration warning (stacklevel=2
|
||||
# attributes it to fastapi/testclient.py's import line).
|
||||
# 2. starlette.testclient resolves `anyio.abc.BlockingPortal` in a
|
||||
# module-level type alias; anyio's lazy-import __getattr__ emits the
|
||||
# alias-deprecation (stacklevel=3 attributes it to starlette's
|
||||
# testclient.py).
|
||||
#
|
||||
# G-104 invariant: category-scoped ignores and ANY ignore matching
|
||||
# ai_service.* are forbidden forever; new upstream deprecations may join
|
||||
# this list only as module+message-scoped entries, justified in ship notes.
|
||||
# Fields: action:message-regex:category:module-regex:lineno (pytest parses
|
||||
# ini entries as raw regex — dots below are explicitly escaped).
|
||||
filterwarnings = [
|
||||
"error",
|
||||
# Upstream (1/2): starlette 1.6 httpx→httpx2 migration notice, raised
|
||||
# while importing fastapi.testclient (importer frame is fastapi's).
|
||||
"ignore:Using `httpx` with `starlette\\.testclient` is deprecated:starlette.exceptions.StarletteDeprecationWarning:fastapi\\.testclient",
|
||||
# Upstream (2/2): anyio 4.x lazy-import alias deprecation, raised while
|
||||
# starlette.testclient evaluates its `_PortalFactoryType` alias.
|
||||
"ignore:The anyio\\.abc\\.BlockingPortal alias is deprecated:DeprecationWarning:starlette\\.testclient",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["ai_service"] # tests/ excluded by construction — only the package is measured
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
# exclude_also APPENDS to coverage.py's built-ins (which already cover
|
||||
# `pragma: no cover` and `if TYPE_CHECKING:`); these add the __main__
|
||||
# entry-point guard (none in ai_service today — future-proof).
|
||||
exclude_also = [
|
||||
"if __name__ == [\"']__main__[\"']:",
|
||||
"if TYPE_CHECKING:",
|
||||
"pragma: no cover",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B"]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "RUF100"]
|
||||
# B008: Depends() in argument defaults is the idiomatic FastAPI DI pattern
|
||||
ignore = ["B008"]
|
||||
ignore = ["B008"]
|
||||
|
||||
# P5 mypy relaxed-first baseline (D-058) — NOT full strict. The flags
|
||||
# below are the binding set from the decision; per-module tightening via
|
||||
# [[tool.mypy.overrides]] is available later (the plan's Wave-5 follow-up
|
||||
# form). Scope decision (recorded per the task's pragmatic clause): ALL
|
||||
# THREE trees pass — ai_service/ + scripts/sandbox-agent.py + tests/ —
|
||||
# so tests are NOT excluded; every test violation was either fixed at
|
||||
# source or scoped with a coded ignore + reason inline. Tests remain
|
||||
# fully checked; a future tests.* per-module relaxation override stays
|
||||
# available (D-058) if the annotation burden grows.
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
files = [
|
||||
"ai_service",
|
||||
"scripts/sandbox-agent.py",
|
||||
"tests",
|
||||
]
|
||||
# Untracked runtime droppings under tests/ (gitignored via
|
||||
# `apps/ai-service/**/sandboxes/`) must never be type-checked.
|
||||
exclude = ["tests/sandboxes"]
|
||||
# D-058 relaxed-first baseline:
|
||||
check_untyped_defs = true # real bugs hide in unannotated bodies too
|
||||
no_implicit_optional = true # implicit Optional is a genuine bug class
|
||||
warn_unused_ignores = true # stale `# type: ignore` becomes an error (self-eliminating suppressions)
|
||||
warn_redundant_casts = true
|
||||
warn_unreachable = true
|
||||
warn_unused_configs = true
|
||||
# Start strict on imports: every runtime dep checked code imports ships
|
||||
# stubs (verified py.typed: fastapi/uvicorn/pydantic/pydantic-settings/
|
||||
# httpx/sse-starlette/sqlmodel/sqlalchemy/websockets/python-multipart/
|
||||
# pypdf/pytest/pytest-asyncio; aiofiles + pytest-timeout/pytest-cov are
|
||||
# never imported by checked code). Overrides are reserved for deps that
|
||||
# genuinely lack stubs — currently NONE, so no ignore_missing_imports
|
||||
# overrides are listed; warn_unused_configs would flag any added no-op.
|
||||
ignore_missing_imports = false
|
||||
|
||||
@@ -1,30 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idempotent bootstrap: create venv + install deps.
|
||||
# Handles Debian systems without python3-venv/ensurepip via --without-pip + get-pip.
|
||||
# Handles Debian/Ubuntu systems without python3-venv/ensurepip via --without-pip + get-pip.
|
||||
# v2 (v0.3.5): recovers from a poisoned partial .venv left by a failed earlier
|
||||
# attempt, cleans before each retry, and dies with a distro-specific fix hint
|
||||
# when venv creation is impossible (e.g. missing python3.XX-venv package).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VENV="$APP_DIR/.venv"
|
||||
|
||||
venv_usable() {
|
||||
[ -x "$VENV/bin/python3" ]
|
||||
}
|
||||
|
||||
rm_broken_venv() {
|
||||
echo "bootstrap: removing broken partial .venv from a failed earlier attempt" >&2
|
||||
rm -rf "$VENV"
|
||||
}
|
||||
|
||||
mkdir -p "$HOME/.cache/ciagent"
|
||||
|
||||
if [ ! -x "$VENV/bin/python3" ]; then
|
||||
if python3 -m venv "$VENV" 2>/dev/null; then
|
||||
if venv_usable && [ ! -x "$VENV/bin/pip" ]; then
|
||||
# A usable python3 without pip means the --without-pip fallback half-ran and
|
||||
# the get-pip step never completed: start over cleanly.
|
||||
rm_broken_venv
|
||||
fi
|
||||
|
||||
if ! venv_usable; then
|
||||
if [ -d "$VENV" ]; then
|
||||
# Directory exists but no working python3: remains of a crashed venv create.
|
||||
rm_broken_venv
|
||||
fi
|
||||
if python3 -m venv "$VENV" 2>/tmp/venv-create.err; then
|
||||
:
|
||||
else
|
||||
# No ensurepip available — create bare venv and bootstrap pip separately.
|
||||
python3 -m venv --without-pip "$VENV"
|
||||
rm -rf "$VENV"
|
||||
if python3 -m venv --without-pip "$VENV" 2>>/tmp/venv-create.err; then
|
||||
:
|
||||
else
|
||||
rm -rf "$VENV"
|
||||
PYVER="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || true)"
|
||||
PKG="python3-venv"
|
||||
[ -n "$PYVER" ] && PKG="python${PYVER}-venv"
|
||||
echo "bootstrap: could not create a virtual environment." >&2
|
||||
echo " python3 reported:" >&2
|
||||
sed 's/^/ /' /tmp/venv-create.err >&2 || true
|
||||
echo " fix (Debian/Ubuntu): install the venv support package, then re-run nextcraft bootstrap:" >&2
|
||||
echo " apt install $PKG" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -x "$VENV/bin/pip" ]; then
|
||||
GET_PIP="$HOME/.cache/ciagent/get-pip.py"
|
||||
if [ ! -f "$GET_PIP" ]; then
|
||||
curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP"
|
||||
if ! curl -sSf --max-time 60 https://bootstrap.pypa.io/get-pip.py -o "$GET_PIP"; then
|
||||
rm -rf "$VENV"
|
||||
echo "bootstrap: get-pip.py download failed (no network?)." >&2
|
||||
echo " fix: restore network access and re-run nextcraft bootstrap" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if ! "$VENV/bin/python3" "$GET_PIP" --quiet; then
|
||||
rm -rf "$VENV"
|
||||
echo "bootstrap: pip installation into the venv failed." >&2
|
||||
echo " fix: re-run nextcraft bootstrap (the venv was cleaned; this retry is safe)" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$VENV/bin/python3" "$GET_PIP" --quiet
|
||||
fi
|
||||
|
||||
"$VENV/bin/pip" install --quiet --upgrade pip
|
||||
"$VENV/bin/pip" install --quiet -e "$APP_DIR[dev]"
|
||||
echo "bootstrap complete: $VENV"
|
||||
echo "bootstrap complete: $VENV"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dev server: export secrets (if present) then run uvicorn on :8420.
|
||||
# Dev server: export secrets (if present) then run uvicorn.
|
||||
# Binds 0.0.0.0 by default so the stack is reachable from other machines
|
||||
# (v0.3.5 network mode) — set AI_HOST=127.0.0.1 in .env to revert to loopback.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -7,7 +9,7 @@ REPO_ROOT="$(cd "$APP_DIR/../.." && pwd)"
|
||||
VENV="$APP_DIR/.venv"
|
||||
|
||||
if [ ! -x "$VENV/bin/uvicorn" ]; then
|
||||
echo "venv missing — run scripts/bootstrap.sh first" >&2
|
||||
echo "venv missing — run nextcraft bootstrap first (or: bash scripts/bootstrap.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -22,5 +24,17 @@ if [ -f "$SECRETS" ]; then
|
||||
done < "$SECRETS"
|
||||
fi
|
||||
|
||||
ENV_FILE="$APP_DIR/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
AI_HOST|AI_PORT|AI_CORS_ORIGINS) export "$key=$value" ;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
|
||||
HOST="${AI_HOST:-0.0.0.0}"
|
||||
PORT="${AI_PORT:-8420}"
|
||||
|
||||
cd "$APP_DIR"
|
||||
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --port 8420
|
||||
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --host "$HOST" --port "$PORT"
|
||||
|
||||
@@ -11,4 +11,4 @@ if [ ! -x "$VENV/bin/ruff" ]; then
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
exec "$VENV/bin/ruff" check .
|
||||
exec "$VENV/bin/ruff" check .
|
||||
|
||||
@@ -84,6 +84,10 @@ class AgentConfig:
|
||||
command_timeout_s: float = 30.0
|
||||
backoff_base_s: float = 0.25
|
||||
backoff_max_s: float = 8.0
|
||||
#: Spool bound (G-14): explicit cap where none existed. Worst case
|
||||
#: ~64KB/line (diff cap) * SPOOL_MAX_LINES must stay well under the
|
||||
#: G-2 512MB workdir sweep: 4096 * 64KB = 256MB (half the budget).
|
||||
spool_max_lines: int = 4096
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("learner_id", "task_id", "ingest_url", "sandbox_id"):
|
||||
@@ -151,6 +155,20 @@ class Spool:
|
||||
os.replace(tmp, self._path)
|
||||
|
||||
|
||||
def _line_seq(line: str) -> int | None:
|
||||
"""Best-effort seq extraction from a spool line (None when unparseable).
|
||||
|
||||
The event's seq is a top-level wire field (`_next_event`). Used only for
|
||||
ack trimming; an unparseable line is retained (never dropped by the ack
|
||||
path — the overflow bound is the only dropper).
|
||||
"""
|
||||
try:
|
||||
seq = json.loads(line).get("seq")
|
||||
return seq if isinstance(seq, int) else None
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- websocket codec
|
||||
|
||||
|
||||
@@ -191,21 +209,36 @@ class WsConnection:
|
||||
path += "?" + parts.query
|
||||
|
||||
sock = socket.create_connection((host, port), timeout=timeout_s)
|
||||
if parts.scheme == "wss":
|
||||
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host)
|
||||
try:
|
||||
if parts.scheme == "wss":
|
||||
sock = ssl.create_default_context().wrap_socket(
|
||||
sock, server_hostname=host
|
||||
)
|
||||
|
||||
key = base64.b64encode(secrets.token_bytes(16)).decode("ascii")
|
||||
request = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
)
|
||||
sock.sendall(request.encode("ascii"))
|
||||
response = cls._read_http_response(sock)
|
||||
cls._validate_handshake(response, key)
|
||||
key = base64.b64encode(secrets.token_bytes(16)).decode("ascii")
|
||||
request = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
)
|
||||
sock.sendall(request.encode("ascii"))
|
||||
response = cls._read_http_response(sock)
|
||||
cls._validate_handshake(response, key)
|
||||
except BaseException:
|
||||
# P3 (Task 3-2-02): a failed connect after the TCP dial leaves
|
||||
# the raw socket with no WsConnection owner — the supervisor's
|
||||
# except block re-raised before one existed, so every failed
|
||||
# reconnect leaked the fd (unclosed-socket ResourceWarnings;
|
||||
# fd exhaustion over a long outage). Close it HERE and re-raise
|
||||
# the same exception: the backoff/reconnect logic is unchanged.
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return cls(sock)
|
||||
|
||||
@staticmethod
|
||||
@@ -337,13 +370,23 @@ class Agent:
|
||||
self._spool = Spool(config.spool_path)
|
||||
self._pending: deque[str] = deque()
|
||||
self._seq = 0
|
||||
self._emit_lock = threading.Lock() # serializes seq + spool + flush
|
||||
# RLock (D-3 verifier fix): emit() holds this across sends; a send
|
||||
# failure drops the conn, and _drop_conn -> replay_margin re-enters
|
||||
# the same lock. A plain Lock deadlocked the emitting thread there.
|
||||
self._emit_lock = threading.RLock() # serializes seq + spool + flush
|
||||
self._conn_lock = threading.Lock() # guards _conn swaps
|
||||
self._conn: WsConnection | None = None
|
||||
self._last_sent: str | None = None # one-line replay margin, see below
|
||||
self._stop = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._baseline: dict[str, tuple[int, int, str | None]] = {}
|
||||
# Spool bound (G-14): explicit cap where none existed. Worst case
|
||||
# ~64KB/line (diff cap) x SPOOL_MAX_LINES must stay well under the
|
||||
# G-2 512MB workdir sweep; overflow drops OLDEST lines with a
|
||||
# counter — by-design gap creation, so the trace goes ungradable
|
||||
# (G-4) instead of silently truncated-but-gradable.
|
||||
self._dropped_overflow = 0
|
||||
self._enforce_spool_bound_locked()
|
||||
self._resume_from_spool()
|
||||
|
||||
# -- durability ------------------------------------------------------
|
||||
@@ -400,9 +443,30 @@ class Agent:
|
||||
line = json.dumps(self._next_event(kind, payload))
|
||||
self._spool.append(line) # durable BEFORE any send attempt
|
||||
self._pending.append(line)
|
||||
self._enforce_spool_bound_locked()
|
||||
self._flush_locked()
|
||||
return json.loads(line)
|
||||
|
||||
def _enforce_spool_bound_locked(self) -> None:
|
||||
"""Drop OLDEST spooled lines past `spool_max_lines` (G-14).
|
||||
|
||||
Overflow is by-design gap creation: the dropped seqs become
|
||||
permanent gaps server-side, the gap path flags the trace, and the
|
||||
grader refuses it (G-4) — never a silently-truncated-but-gradable
|
||||
trace. Caller must hold `_emit_lock`. `_dropped_overflow` is the
|
||||
observable counter (surfaced in the final stop status event).
|
||||
"""
|
||||
lines = self._spool.read_all()
|
||||
overflow = len(lines) - self.config.spool_max_lines
|
||||
if overflow <= 0:
|
||||
return
|
||||
self._dropped_overflow += overflow
|
||||
self._spool.rewrite(lines[overflow:])
|
||||
# Pending may reference dropped lines; they replay as no-ops (server
|
||||
# dedup) but trimming them keeps the replay window honest.
|
||||
dropped = set(lines[:overflow])
|
||||
self._pending = deque(ln for ln in self._pending if ln not in dropped)
|
||||
|
||||
def _flush_locked(self) -> None:
|
||||
conn = self._current_conn()
|
||||
while self._pending and conn is not None:
|
||||
@@ -413,24 +477,57 @@ class Agent:
|
||||
self._drop_conn()
|
||||
return
|
||||
self._pending.popleft()
|
||||
self._last_sent = line # kept until a later send proves delivery
|
||||
if not self._pending and self._last_sent is not None:
|
||||
# Compact, but retain the most recently sent line: a send into a
|
||||
# silently-dead socket "succeeds" once at TCP level, so the last
|
||||
# line is only confirmed-sent once a later write works. Retention
|
||||
# is cheap; the server dedups on (learner, task, seq).
|
||||
self._spool.rewrite([self._last_sent])
|
||||
self._last_sent = line
|
||||
# D-045/D-1 (verifier fix): the spool is NEVER compacted below the
|
||||
# unacked window. A send into a silently-dead socket "succeeds" at
|
||||
# TCP level — those lines may be lost in flight — so they stay in
|
||||
# the spool until the server's seq_ack proves durable storage
|
||||
# (trim_to_ack is the ONLY spool shrinker besides the overflow
|
||||
# bound). Replays are harmless: the server dedups on
|
||||
# (learner, task, seq).
|
||||
|
||||
def replay_margin(self) -> None:
|
||||
"""Requeue the last-sent line after a detected disconnect."""
|
||||
"""Requeue every unacked spooled line after a detected disconnect.
|
||||
|
||||
D-045/D-1 (verifier fix): the pre-ack one-line margin could not cover
|
||||
a multi-frame in-flight window — a burst accepted by a dying socket
|
||||
popped N lines from pending while the spool had been compacted to the
|
||||
last one, permanently losing lines 1..N-1. The spool now retains
|
||||
everything unacked, so replay requeues the full unacked window;
|
||||
server-side dedup absorbs the duplicates.
|
||||
"""
|
||||
with self._emit_lock:
|
||||
if self._last_sent is not None and (
|
||||
not self._pending or self._pending[0] != self._last_sent
|
||||
):
|
||||
self._pending.appendleft(self._last_sent)
|
||||
self._spool.rewrite(list(self._pending))
|
||||
if self._pending:
|
||||
return # mid-flush caller holds the pending queue intact
|
||||
self._pending = deque(self._spool.read_all())
|
||||
self._last_sent = None
|
||||
|
||||
def trim_to_ack(self, ack_seq: int) -> None:
|
||||
"""Drop every spooled/pending line with seq <= ack_seq (D-045).
|
||||
|
||||
Advisory server hint: `seq_ack` carries the durable latest_seq, so
|
||||
everything up to and including it is stored server-side and dedup
|
||||
absorbs nothing on replay. Runs under `_emit_lock` — ordered against
|
||||
concurrent emit()/flush, and the rewrite is atomic (Spool.rewrite).
|
||||
Lines without a parseable seq are retained; the overflow bound is
|
||||
the only dropper of unparseable lines.
|
||||
"""
|
||||
with self._emit_lock:
|
||||
spooled = self._spool.read_all()
|
||||
kept_pending = [
|
||||
ln for ln in self._pending if (s := _line_seq(ln)) is None or s > ack_seq
|
||||
]
|
||||
kept_spooled = [
|
||||
ln for ln in spooled if (s := _line_seq(ln)) is None or s > ack_seq
|
||||
]
|
||||
if len(kept_pending) != len(self._pending) or len(kept_spooled) != len(spooled):
|
||||
self._pending = deque(kept_pending)
|
||||
self._spool.rewrite(kept_spooled)
|
||||
if self._last_sent is not None:
|
||||
s = _line_seq(self._last_sent)
|
||||
if s is not None and s <= ack_seq:
|
||||
self._last_sent = None
|
||||
|
||||
# -- connection supervision ------------------------------------------
|
||||
def _current_conn(self) -> WsConnection | None:
|
||||
with self._conn_lock:
|
||||
@@ -468,6 +565,10 @@ class Agent:
|
||||
"""Maintain the WS connection: connect, flush backlog, read, backoff."""
|
||||
backoff = self.config.backoff_base_s
|
||||
while not self._stop.is_set():
|
||||
# P5: explicit Optional — the else-branch read can observe None
|
||||
# (a concurrent _drop_conn between the check and the read), so
|
||||
# the `conn is None` guard below is genuinely reachable.
|
||||
conn: WsConnection | None
|
||||
if self._current_conn() is None:
|
||||
try:
|
||||
conn = WsConnection.connect(self.config.ingest_url)
|
||||
@@ -491,10 +592,33 @@ class Agent:
|
||||
continue
|
||||
if frame is None:
|
||||
continue
|
||||
opcode, _payload = frame
|
||||
opcode, payload = frame
|
||||
if opcode == 0x1: # server text frame — parse advisory envelopes
|
||||
self._handle_server_text(payload)
|
||||
continue
|
||||
if opcode == 0x8: # server close frame
|
||||
self._drop_conn()
|
||||
|
||||
def _handle_server_text(self, payload: bytes) -> None:
|
||||
"""Consume server->agent envelopes (advisory; never fatal).
|
||||
|
||||
`seq_ack` (D-045): the server's durable latest_seq — trims the
|
||||
spool/pending to `seq > ack`, bounding the replay margin to the
|
||||
in-flight window (REQ-5-007). Unknown/malformed frames are ignored:
|
||||
acks are hints; gap detection and flood semantics stay authoritative
|
||||
server-side.
|
||||
"""
|
||||
try:
|
||||
envelope = json.loads(payload.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return
|
||||
if not isinstance(envelope, dict):
|
||||
return
|
||||
if envelope.get("type") == "seq_ack":
|
||||
ack_seq = envelope.get("seq")
|
||||
if isinstance(ack_seq, int) and ack_seq >= 0:
|
||||
self.trim_to_ack(ack_seq)
|
||||
|
||||
# -- workspace watcher ------------------------------------------------
|
||||
def _snapshot_workspace(self) -> dict[str, tuple[int, int, str | None]]:
|
||||
"""Map rel path -> (mtime_ns, size, text-or-None-if-too-large)."""
|
||||
@@ -641,7 +765,14 @@ class Agent:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
try:
|
||||
self.emit("activity", {"state": "stopped", "spooled": len(self._pending)})
|
||||
self.emit(
|
||||
"activity",
|
||||
{
|
||||
"state": "stopped",
|
||||
"spooled": len(self._pending),
|
||||
"dropped_overflow": self._dropped_overflow,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
self._stop.set()
|
||||
self._drop_conn()
|
||||
@@ -668,18 +799,18 @@ def main() -> int:
|
||||
# activity until SIGTERM/SIGINT so the agent's lifecycle is tied to
|
||||
# the sandbox (destroy() reaps it) rather than to stdin EOF.
|
||||
if sys.stdin is None or sys.stdin.closed: # pragma: no cover - defensive
|
||||
agent._stop.wait() # noqa: SLF001 - daemon block
|
||||
agent._stop.wait()
|
||||
else:
|
||||
line = sys.stdin.readline()
|
||||
while line:
|
||||
agent.run_command(line)
|
||||
line = sys.stdin.readline()
|
||||
if not agent._stop.is_set() and not sys.stdin.isatty(): # noqa: SLF001
|
||||
if not agent._stop.is_set() and not sys.stdin.isatty():
|
||||
# EOF on a pipe (DEVNULL): daemonize — watch + stream until killed.
|
||||
import signal
|
||||
|
||||
signal.signal(signal.SIGTERM, lambda *_: agent._stop.set()) # noqa: SLF001
|
||||
agent._stop.wait() # noqa: SLF001
|
||||
signal.signal(signal.SIGTERM, lambda *_: agent._stop.set())
|
||||
agent._stop.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test runner: pytest via venv — mock provider only, zero network calls.
|
||||
#
|
||||
# P1 harness wiring (Task 1-1-01/1-1-02):
|
||||
# default full suite WITH coverage (term + missing lines)
|
||||
# NO_COV=1 coverage off (bare pytest flags, no --cov args)
|
||||
# FAST=1 fast subset only: -m "not real_server and not userns"
|
||||
#
|
||||
# Extra args pass through: `bash scripts/test.sh tests/api -k foo`.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -11,4 +18,21 @@ if [ ! -x "$VENV/bin/pytest" ]; then
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
exec "$VENV/bin/pytest" -q "$@"
|
||||
|
||||
# Assemble args; pytest arg order: later options win, so user-passed "$@"
|
||||
# always overrides the wiring below.
|
||||
ARGS=("-q")
|
||||
if [ "${FAST:-0}" = "1" ]; then
|
||||
# Fast subset: exclude the real-server and user-namespace tiers.
|
||||
# The fast loop is the dev loop — coverage evidence comes from the full run.
|
||||
ARGS+=("-m" "not real_server and not userns")
|
||||
NO_COV=1
|
||||
fi
|
||||
if [ "${NO_COV:-0}" = "1" ]; then
|
||||
# Opt-out: bare run, no coverage tooling at all.
|
||||
ARGS+=()
|
||||
else
|
||||
ARGS+=("--cov=ai_service" "--cov-report=term-missing")
|
||||
fi
|
||||
|
||||
exec "$VENV/bin/pytest" "${ARGS[@]}" "$@"
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Type check: mypy via venv — the D-058 relaxed-first baseline
|
||||
# (check_untyped_defs, no_implicit_optional, warn_unused_ignores,
|
||||
# warn_redundant_casts, warn_unreachable; config in pyproject.toml
|
||||
# [tool.mypy], scope = ai_service + scripts/sandbox-agent.py + tests).
|
||||
#
|
||||
# Extra args pass through: `bash scripts/typecheck.sh --verbose`.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VENV="$APP_DIR/.venv"
|
||||
|
||||
if [ ! -x "$VENV/bin/mypy" ]; then
|
||||
echo "venv missing mypy — run scripts/bootstrap.sh (or pip install the dev extras) first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$APP_DIR"
|
||||
exec "$VENV/bin/mypy" "$@"
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Telemetry + artifacts corpus tests (REQ-2-007/008 inputs, D-021)."""
|
||||
"""Telemetry + artifacts corpus tests (REQ-2-007/008 inputs).
|
||||
|
||||
The D-021 TS↔py ID-sync pin (test_corpus_ids_align_with_ts_mock_data) was
|
||||
cut in P4 Wave 4-2: its counterpart packages/mock-data/ai-scenarios.ts was
|
||||
dead (zero imports) and removed as dead code, so the sync contract it
|
||||
guarded no longer exists. The Python corpus here is the single source.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,7 +19,6 @@ from ai_service.corpus.artifacts import (
|
||||
rubric_for_competency,
|
||||
)
|
||||
from ai_service.corpus.telemetry import (
|
||||
LAB_SCENARIOS,
|
||||
get_lab_scenario,
|
||||
summarize_scenario,
|
||||
)
|
||||
@@ -35,15 +38,22 @@ def test_lab_scenarios_addressable_by_id():
|
||||
def test_lab_scenarios_have_distinct_event_profiles():
|
||||
strong = get_lab_scenario("lab-scenario-strong")
|
||||
struggling = get_lab_scenario("lab-scenario-struggling")
|
||||
kinds = lambda s: {e.kind for e in s.events} # noqa: E731
|
||||
flagged = get_lab_scenario("lab-scenario-flagged")
|
||||
assert strong is not None, "seed scenario must resolve"
|
||||
assert struggling is not None, "seed scenario must resolve"
|
||||
assert flagged is not None, "seed scenario must resolve"
|
||||
def kinds(s):
|
||||
return {e.kind for e in s.events}
|
||||
assert "test_pass" in kinds(strong)
|
||||
assert "test_fail" in kinds(struggling)
|
||||
assert "idle" in kinds(struggling)
|
||||
assert "paste" in kinds(get_lab_scenario("lab-scenario-flagged"))
|
||||
assert "paste" in kinds(flagged)
|
||||
|
||||
|
||||
def test_summarize_scenario_mentions_events():
|
||||
text = summarize_scenario(get_lab_scenario("lab-scenario-struggling"))
|
||||
scenario = get_lab_scenario("lab-scenario-struggling")
|
||||
assert scenario is not None, "seed scenario must resolve"
|
||||
text = summarize_scenario(scenario)
|
||||
assert "test_fail" in text
|
||||
assert "ImportError" in text
|
||||
assert "stack-orchestration-c002" in text
|
||||
@@ -76,6 +86,7 @@ def test_transcripts_pair_with_artifacts():
|
||||
|
||||
def test_rubric_render_mentions_all_criteria():
|
||||
rubric = rubric_for_competency("stack-orchestration-c002")
|
||||
assert rubric is not None, "seed rubric must resolve"
|
||||
text = render_rubric(rubric)
|
||||
for criterion in rubric.criteria:
|
||||
assert criterion.criterion_id in text
|
||||
@@ -83,38 +94,12 @@ def test_rubric_render_mentions_all_criteria():
|
||||
|
||||
def test_transcript_render_has_both_speakers():
|
||||
transcript = get_transcript_for_artifact("art-eval-rag-dashboard")
|
||||
assert transcript is not None, "seed transcript must resolve"
|
||||
text = render_transcript(transcript)
|
||||
assert "examiner:" in text
|
||||
assert "learner:" in text
|
||||
|
||||
|
||||
_TS_SOURCE_CANDIDATES = [
|
||||
Path(__file__).resolve().parents[4] / "packages" / "mock-data" / "ai-scenarios.ts",
|
||||
Path(__file__).resolve().parents[2] / "ai-scenarios.ts",
|
||||
]
|
||||
|
||||
|
||||
def _ts_source() -> Path:
|
||||
for candidate in _TS_SOURCE_CANDIDATES:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
pytest.skip("ai-scenarios.ts not found in this checkout layout")
|
||||
|
||||
|
||||
def test_corpus_ids_align_with_ts_mock_data():
|
||||
"""D-021: Python corpus IDs string-identical to ai-scenarios.ts."""
|
||||
content = _ts_source().read_text()
|
||||
ts_ids = re.findall(r"id:\s*'([^']+)'", content)
|
||||
ts_scenarios = ts_ids[: len(LAB_SCENARIOS)]
|
||||
ts_artifacts = ts_ids[len(LAB_SCENARIOS):]
|
||||
assert sorted(ts_scenarios) == sorted(LAB_SCENARIOS), (
|
||||
f"scenario IDs drifted: py={sorted(LAB_SCENARIOS)} ts={sorted(ts_scenarios)}"
|
||||
)
|
||||
assert sorted(ts_artifacts) == sorted(ARTIFACTS), (
|
||||
f"artifact IDs drifted: py={sorted(ARTIFACTS)} ts={sorted(ts_artifacts)}"
|
||||
)
|
||||
|
||||
|
||||
def test_rubric_weights_sum_to_one():
|
||||
"""Every rubric's criteria weights must sum to exactly 1.0."""
|
||||
for rubric in RUBRICS.values():
|
||||
|
||||
@@ -25,9 +25,13 @@ async def test_stream_feedback_mentions_scenario_events():
|
||||
ctx = get_learner_context()
|
||||
strong = get_lab_scenario("lab-scenario-strong")
|
||||
struggling = get_lab_scenario("lab-scenario-struggling")
|
||||
assert strong is not None, "seed scenario must resolve"
|
||||
assert struggling is not None, "seed scenario must resolve"
|
||||
|
||||
strong_reply = "".join([t async for t in lab.stream_feedback(strong, ctx)])
|
||||
struggling_reply = "".join([t async for t in lab.stream_feedback(struggling, ctx)])
|
||||
# P5 scoped: corpus scenario stands in for the TraceDigest (dormant-
|
||||
# corpus test seam — render_digest_timeline duck-types model_dump_json).
|
||||
strong_reply = "".join([t async for t in lab.stream_feedback(strong, ctx)]) # type: ignore[arg-type]
|
||||
struggling_reply = "".join([t async for t in lab.stream_feedback(struggling, ctx)]) # type: ignore[arg-type]
|
||||
assert strong_reply
|
||||
assert strong_reply != struggling_reply # scenario-driven, not canned
|
||||
|
||||
@@ -35,6 +39,7 @@ async def test_stream_feedback_mentions_scenario_events():
|
||||
def test_build_evaluation_messages_carry_timeline():
|
||||
lab = make_lab()
|
||||
scenario = get_lab_scenario("lab-scenario-flagged")
|
||||
assert scenario is not None, "seed scenario must resolve"
|
||||
timeline = summarize_scenario(scenario)
|
||||
messages = lab.build_messages(None, timeline, get_learner_context())
|
||||
assert messages[0].role == "system"
|
||||
|
||||
@@ -34,7 +34,8 @@ def test_proctor_scenarios_addressable_and_distinct_type():
|
||||
healthy = get_proctor_scenario("proctor-scenario-healthy")
|
||||
flagged = get_proctor_scenario("proctor-scenario-flagged")
|
||||
assert healthy is not None and flagged is not None
|
||||
kinds = lambda s: {e.kind for e in s.events} # noqa: E731
|
||||
def kinds(s):
|
||||
return {e.kind for e in s.events}
|
||||
assert "tab_switch" in kinds(get_proctor_scenario("proctor-scenario-distracted"))
|
||||
assert "paste_large" in kinds(flagged)
|
||||
assert not kinds(healthy) & {"tab_switch", "paste_large", "focus_lost"}
|
||||
@@ -48,7 +49,13 @@ async def test_assess_returns_validated_signals():
|
||||
provider = ScriptedJSONProvider(VALID_ASSESSMENT)
|
||||
proctor = make_proctor(provider)
|
||||
scenario = get_proctor_scenario("proctor-scenario-distracted")
|
||||
result = await proctor.assess(scenario, get_learner_context())
|
||||
assert scenario is not None, "seed scenario must resolve"
|
||||
# P5 scoped: corpus scenario stands in for the TraceDigest (dormant-
|
||||
# corpus test seam — the agent renders digest.model_dump_json).
|
||||
result = await proctor.assess(
|
||||
scenario, # type: ignore[arg-type]
|
||||
learner_context=get_learner_context(),
|
||||
)
|
||||
assert isinstance(result, ProctorAssessment)
|
||||
assert len(result.signals) == 2
|
||||
assert result.signals[0].severity == "low"
|
||||
@@ -58,8 +65,13 @@ async def test_assess_returns_validated_signals():
|
||||
async def test_assess_rejects_invalid_after_retry():
|
||||
proctor = make_proctor(MockProvider()) # non-schema JSON
|
||||
scenario = get_proctor_scenario("proctor-scenario-healthy")
|
||||
assert scenario is not None, "seed scenario must resolve"
|
||||
with pytest.raises(StructuredOutputError):
|
||||
await proctor.assess(scenario, get_learner_context())
|
||||
# P5 scoped: corpus scenario as digest — same dormant-corpus seam.
|
||||
await proctor.assess(
|
||||
scenario, # type: ignore[arg-type]
|
||||
learner_context=get_learner_context(),
|
||||
)
|
||||
|
||||
|
||||
def test_system_prompt_is_coaching_not_punitive():
|
||||
@@ -71,6 +83,7 @@ def test_system_prompt_is_coaching_not_punitive():
|
||||
|
||||
def test_timeline_summary_carries_events():
|
||||
scenario = get_proctor_scenario("proctor-scenario-flagged")
|
||||
assert scenario is not None, "seed scenario must resolve"
|
||||
text = summarize_proctor_scenario(scenario)
|
||||
assert "paste_large" in text
|
||||
assert "3,100 chars" in text
|
||||
|
||||
@@ -187,7 +187,10 @@ class TestProctorEndpoint:
|
||||
from ai_service.variants.store import SQLiteVariantStore
|
||||
from ai_service.voice.defense_store import SQLiteDefenseStore
|
||||
|
||||
app = create_app(Settings(provider="mock"))
|
||||
# P1 CI finding: bare Settings() defaults db_path to ~/.nextcraft —
|
||||
# hermeticity rule violated (CI job containers have no ~/.nextcraft;
|
||||
# sqlite fails "unable to open database file"). Pin all state to tmp.
|
||||
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
|
||||
provider = RecordingProvider(PROCTOR_JSON)
|
||||
app.state.provider = provider
|
||||
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
|
||||
@@ -14,7 +14,9 @@ async def test_create_and_get():
|
||||
store = InMemorySessionStore()
|
||||
session = await store.create("s1", agent="coach")
|
||||
assert session.agent == "coach"
|
||||
assert (await store.get("s1")).session_id == "s1"
|
||||
got = await store.get("s1")
|
||||
assert got is not None
|
||||
assert got.session_id == "s1"
|
||||
assert await store.get("missing") is None
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@ async def test_structured_completion_retries_then_raises():
|
||||
# Plain MockProvider returns non-schema JSON for json_object requests →
|
||||
# both attempts fail validation → StructuredOutputError after ONE retry.
|
||||
provider = MockProvider()
|
||||
provider.received_calls = []
|
||||
# P5 scoped: test-only call ledger attribute on the shared mock.
|
||||
provider.received_calls = [] # type: ignore[attr-defined]
|
||||
messages = [Message(role="user", content="grade me")]
|
||||
with pytest.raises(StructuredOutputError):
|
||||
await structured_completion(
|
||||
@@ -141,7 +142,7 @@ async def test_structured_completion_sends_schema_instruction():
|
||||
messages, model=model, temperature=temperature, response_format=response_format
|
||||
)
|
||||
|
||||
provider.chat = recording_chat
|
||||
provider.chat = recording_chat # type: ignore[method-assign] # P5: monkeypatching the shared mock
|
||||
messages = [Message(role="user", content="grade")]
|
||||
await structured_completion(provider, messages, model="m", schema=Score, schema_hint=HINT)
|
||||
assert captured, "provider was never called"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
@@ -33,8 +34,8 @@ class CoachingMock(MockProvider):
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path) -> TestClient:
|
||||
app = create_app(Settings(provider="mock"))
|
||||
def client(tmp_path) -> Iterator[TestClient]:
|
||||
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
|
||||
app.state.provider = CoachingMock()
|
||||
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||
@@ -45,8 +46,10 @@ def client(tmp_path) -> TestClient:
|
||||
|
||||
def _seed_grade(client: TestClient) -> None:
|
||||
|
||||
# P5: TestClient.app is typed ASGIApp (Callable); at runtime it is the
|
||||
# FastAPI instance — this state-injection seam is the suite's pattern.
|
||||
app = client.app
|
||||
store: SQLiteGradeStore = app.state.grade_store
|
||||
store: SQLiteGradeStore = app.state.grade_store # type: ignore[attr-defined]
|
||||
store.save(
|
||||
GradeRecord(
|
||||
learner_id="api-learner",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""CORS policy tests (A-008, P7 review regression).
|
||||
"""CORS policy tests (A-008, D-038 network mode).
|
||||
|
||||
v0.3 initially shipped `allow_methods` WITHOUT "PUT" while the learner
|
||||
build surface writes workspace files with PUT (engine-client writeFile) —
|
||||
@@ -6,11 +6,11 @@ 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).
|
||||
v0.3.5 network mode (D-038): the default AI_CORS_ORIGINS='*' admits any
|
||||
origin (safe ONLY because credentials are never enabled); an explicit list
|
||||
restricts. Both modes are pinned here:
|
||||
- wildcard: remote origin gets the grant; credentials still never sent;
|
||||
- explicit: unlisted origins get no grant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,9 +18,14 @@ from __future__ import annotations
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ALLOWED_ORIGIN = "http://localhost:3000"
|
||||
REMOTE_ORIGIN = "http://nextcraft-1:3000"
|
||||
ALL_CLIENT_METHODS = ("GET", "POST", "PUT", "DELETE")
|
||||
|
||||
|
||||
def _allow_origin(resp) -> str | None:
|
||||
return resp.headers.get("access-control-allow-origin")
|
||||
|
||||
|
||||
def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -> None:
|
||||
for method in ALL_CLIENT_METHODS:
|
||||
resp = client.options(
|
||||
@@ -31,7 +36,7 @@ def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, f"preflight {method} failed: {resp.status_code}"
|
||||
assert resp.headers["access-control-allow-origin"] == ALLOWED_ORIGIN
|
||||
assert _allow_origin(resp) in ("*", ALLOWED_ORIGIN)
|
||||
allowed = resp.headers["access-control-allow-methods"].split(", ")
|
||||
assert method in allowed, f"{method} missing from CORS methods: {allowed}"
|
||||
|
||||
@@ -39,13 +44,30 @@ def test_preflight_allows_every_method_the_web_client_uses(client: TestClient) -
|
||||
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
|
||||
assert _allow_origin(resp) in ("*", 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_wildcard_mode_grants_remote_origins(client: TestClient) -> None:
|
||||
"""D-038 default: '*' grants any origin — remote browsers work zero-config."""
|
||||
resp = client.get("/v1/sandboxes", headers={"Origin": REMOTE_ORIGIN})
|
||||
assert resp.status_code == 200
|
||||
assert _allow_origin(resp) in ("*", REMOTE_ORIGIN)
|
||||
|
||||
|
||||
def test_explicit_list_mode_denies_unlisted_origins(
|
||||
settings, monkeypatch, tmp_path
|
||||
) -> None:
|
||||
"""Explicit AI_CORS_ORIGINS restricts to the listed origins only."""
|
||||
from fastapi.testclient import TestClient as TC
|
||||
|
||||
from ai_service.main import create_app
|
||||
|
||||
restricted = settings.model_copy(update={"cors_origins": "http://localhost:3000"})
|
||||
app = create_app(restricted)
|
||||
with TC(app) as c:
|
||||
resp = c.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:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -48,7 +49,20 @@ class ScriptedLLM(MockProvider):
|
||||
|
||||
@pytest.fixture()
|
||||
def app(tmp_path: Path):
|
||||
application = create_app(Settings(provider="mock", voice_provider="mock"))
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
voice_provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app.db", # hermeticity (P1 CI finding)
|
||||
)
|
||||
application = create_app(settings)
|
||||
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
|
||||
seed_verified_identity(identity_store)
|
||||
application.state.identity_store = identity_store # G-9
|
||||
llm = ScriptedLLM()
|
||||
application.state.provider = llm
|
||||
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
@@ -65,7 +79,7 @@ def app(tmp_path: Path):
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app) -> TestClient:
|
||||
def client(app) -> Iterator[TestClient]:
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
@@ -94,12 +108,40 @@ class TestStart:
|
||||
body = _start(client)
|
||||
assert body["trace_complete"] is True
|
||||
|
||||
def test_same_millisecond_starts_do_not_collide(self, client) -> None:
|
||||
"""v0.4.7 (P1): the old `dfn-{ms:x}` mint collided on same-ms starts —
|
||||
the insert-only store's PK then 500'd the second start PERMANENTLY
|
||||
(the identity-resubmit P0-C1 class). Nonce+counter keeps ids unique
|
||||
within a process regardless of clock granularity."""
|
||||
first = _start(client)
|
||||
second = _start(client)
|
||||
third = _start(client)
|
||||
ids = {first["defense_id"], second["defense_id"], third["defense_id"]}
|
||||
assert len(ids) == 3, f"defense ids collided: {ids}"
|
||||
for did in ids:
|
||||
stored = client.get(f"/v1/defense/{did}")
|
||||
assert stored.status_code == 200, stored.text
|
||||
|
||||
|
||||
class TestBrowserFallback:
|
||||
def test_browser_mode_serves_browser_descriptor(self, tmp_path: Path) -> None:
|
||||
"""Must-Have #6: AI_VOICE_PROVIDER=browser → start returns the
|
||||
browser-native SR/TTS fallback descriptor (D-030), not 'mock'."""
|
||||
application = create_app(Settings(provider="mock", voice_provider="browser"))
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
application = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
voice_provider="browser",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "app-b.db", # hermeticity (P1 CI finding)
|
||||
)
|
||||
)
|
||||
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity-b.db")
|
||||
seed_verified_identity(identity_store)
|
||||
application.state.identity_store = identity_store # G-9
|
||||
application.state.provider = ScriptedLLM()
|
||||
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
@@ -232,3 +274,107 @@ class TestFinishAndGet:
|
||||
def test_unknown_defense_404_on_all(self, client) -> None:
|
||||
assert client.post("/v1/defense/dfn-nope/finish").status_code == 404
|
||||
assert client.get("/v1/defense/dfn-nope").status_code == 404
|
||||
|
||||
|
||||
class TestServerVoiceRouteFixes:
|
||||
"""MH-2c (D-041/G-16/G-12): codec-strip, size guard, format-aware TTS."""
|
||||
|
||||
def test_webm_codec_params_stripped_for_provider(self, client, app) -> None:
|
||||
"""MediaRecorder sends 'audio/webm;codecs=opus' — the provider must
|
||||
see the bare 'webm' (D-041), else a real STT endpoint 400s."""
|
||||
received_fmts: list[str] = []
|
||||
|
||||
class ProbeVoice(MockVoiceProvider):
|
||||
async def transcribe(self, audio: bytes, fmt: str):
|
||||
received_fmts.append(fmt)
|
||||
return await super().transcribe(audio, fmt)
|
||||
|
||||
app.state.voice_provider = ProbeVoice(["clean fmt seen"])
|
||||
defense_id = _start(client)["defense_id"]
|
||||
resp = client.post(
|
||||
f"/v1/defense/{defense_id}/answer",
|
||||
files={"audio": ("answer.webm", b"\x1a\x45\xa3\xdf", "audio/webm;codecs=opus")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert received_fmts == ["webm"], (
|
||||
f"codec params leaked to the provider: {received_fmts}"
|
||||
)
|
||||
assert resp.json()["question"]
|
||||
|
||||
def test_oversize_audio_413_before_provider_call(self, client, app) -> None:
|
||||
"""G-12/D-041: the guard fires before any provider call — the client
|
||||
renders an honest re-record prompt."""
|
||||
called = {"n": 0}
|
||||
|
||||
class ProbeVoice(MockVoiceProvider):
|
||||
async def transcribe(self, audio: bytes, fmt: str):
|
||||
called["n"] += 1
|
||||
return await super().transcribe(audio, fmt)
|
||||
|
||||
app.state.voice_provider = ProbeVoice(["x"])
|
||||
defense_id = _start(client)["defense_id"]
|
||||
settings = Settings()
|
||||
too_big = b"\x00" * (settings.voice_max_audio_mb * 1024 * 1024 + 1)
|
||||
resp = client.post(
|
||||
f"/v1/defense/{defense_id}/answer",
|
||||
files={"audio": ("answer.webm", too_big, "audio/webm")},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
assert "re-record" in resp.json()["detail"]
|
||||
assert called["n"] == 0, "provider must not be called for oversize audio"
|
||||
|
||||
def test_tts_media_type_maps_from_settings_enum(self, tmp_path: Path) -> None:
|
||||
"""G-16: media_type follows voice_tts_format (was hardcoded wav)."""
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
application = create_app(
|
||||
Settings(
|
||||
provider="mock",
|
||||
voice_provider="mock",
|
||||
voice_tts_format="opus",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
)
|
||||
)
|
||||
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity-opus.db")
|
||||
seed_verified_identity(identity_store)
|
||||
application.state.identity_store = identity_store # G-9
|
||||
application.state.provider = ScriptedLLM()
|
||||
application.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
application.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
application.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||
application.state.trace_integrity = TraceIntegrityMap()
|
||||
application.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
|
||||
application.state.examiner_agent = ExaminerAgent(
|
||||
application.state.provider,
|
||||
Settings(provider="mock", voice_provider="mock"),
|
||||
)
|
||||
with TestClient(application) as c:
|
||||
defense_id = _start(c)["defense_id"]
|
||||
resp = c.get(f"/v1/defense/{defense_id}/audio/0")
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
|
||||
@@ -12,14 +12,11 @@ Probe-guarded for user namespaces (the in-sandbox exec needs them).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.grading.store import SQLiteGradeStore
|
||||
@@ -29,7 +26,12 @@ from ai_service.telemetry.ingest import TraceIntegrityMap
|
||||
from ai_service.telemetry.store import SQLiteTraceStore
|
||||
from ai_service.variants.store import SQLiteVariantStore
|
||||
from ai_service.voice.defense_store import SQLiteDefenseStore
|
||||
from tests.sandbox.test_isolation import USERSNS_AVAILABLE
|
||||
from tests.helpers import free_port, serve, skip_if_no_userns
|
||||
|
||||
# P1 tier marker (Task 1-1-02): this suite boots a REAL uvicorn server
|
||||
# (uvicorn.Server + serve loop, below) — real_server tier, 180s timeout
|
||||
# via the conftest collection hook.
|
||||
pytestmark = [pytest.mark.real_server]
|
||||
|
||||
COACHING_JSON = json.dumps(
|
||||
{
|
||||
@@ -97,22 +99,26 @@ class FlowLLM(MockProvider):
|
||||
return "Walk me through your last fix — what changed and why?"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_credential_flow(tmp_path: Path) -> None:
|
||||
if not USERSNS_AVAILABLE:
|
||||
pytest.skip("user namespaces unavailable on this host (probe)")
|
||||
skip_if_no_userns()
|
||||
|
||||
import httpx
|
||||
|
||||
port = _free_port()
|
||||
settings = Settings(provider="mock", voice_provider="mock", port=port)
|
||||
port = free_port()
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
from tests.conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
voice_provider="mock",
|
||||
port=port,
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
)
|
||||
app = create_app(settings)
|
||||
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
|
||||
seed_verified_identity(identity_store)
|
||||
app.state.identity_store = identity_store # G-9
|
||||
app.state.provider = FlowLLM()
|
||||
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||
@@ -120,15 +126,7 @@ async def test_full_credential_flow(tmp_path: Path) -> None:
|
||||
app.state.defense_store = SQLiteDefenseStore(db_path=tmp_path / "d.db")
|
||||
app.state.trace_integrity = TraceIntegrityMap()
|
||||
|
||||
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
|
||||
|
||||
async with serve(app, "127.0.0.1", port):
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
async with httpx.AsyncClient(base_url=base, timeout=30.0) as client:
|
||||
# 1. Variant (real seeded generation, mock-rendered).
|
||||
@@ -210,7 +208,3 @@ async def test_full_credential_flow(tmp_path: Path) -> None:
|
||||
assert not any(
|
||||
m in json.dumps(payload) for m in corpus_markers
|
||||
), "corpus fixture leaked into the learner path"
|
||||
finally:
|
||||
server.should_exit = True
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(serve_task, timeout=10.0)
|
||||
|
||||
@@ -7,6 +7,7 @@ scenarios; empty trace is a valid "no telemetry yet" coaching path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -44,8 +45,8 @@ class StreamingMock(MockProvider):
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path) -> TestClient:
|
||||
app = create_app(Settings(provider="mock"))
|
||||
def client(tmp_path: Path) -> Iterator[TestClient]:
|
||||
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
|
||||
app.state.provider = StreamingMock()
|
||||
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||
@@ -55,7 +56,7 @@ def client(tmp_path: Path) -> TestClient:
|
||||
|
||||
|
||||
def test_live_trace_streams_full_envelope(client: TestClient) -> None:
|
||||
store: SQLiteTraceStore = client.app.state.trace_store
|
||||
store: SQLiteTraceStore = client.app.state.trace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
for e in [
|
||||
_event(0, "file_diff", {"path": "x.py"}, 0),
|
||||
_event(1, "command", {"cmd": "pytest -q"}, 10),
|
||||
|
||||
@@ -7,6 +7,7 @@ and gathers digest + defense signals + variant context server-side.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -49,8 +50,8 @@ class BrokenJSON(MockProvider):
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path) -> TestClient:
|
||||
app = create_app(Settings(provider="mock"))
|
||||
def client(tmp_path: Path) -> Iterator[TestClient]:
|
||||
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
|
||||
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
||||
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
||||
app.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
|
||||
@@ -61,7 +62,7 @@ def client(tmp_path: Path) -> TestClient:
|
||||
|
||||
|
||||
def _seed_trace(client: TestClient) -> None:
|
||||
store: SQLiteTraceStore = client.app.state.trace_store
|
||||
store: SQLiteTraceStore = client.app.state.trace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
for e in [
|
||||
TelemetryEvent(
|
||||
learner_id="p-learner",
|
||||
@@ -86,7 +87,7 @@ def _seed_trace(client: TestClient) -> None:
|
||||
|
||||
|
||||
def test_signals_returns_validated_json(client: TestClient) -> None:
|
||||
client.app.state.provider = ProctorJSON()
|
||||
client.app.state.provider = ProctorJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
_seed_trace(client)
|
||||
resp = client.post(
|
||||
"/v1/proctor/signals", json={"learner_id": "p-learner", "task_id": "p-task"}
|
||||
@@ -99,7 +100,7 @@ def test_signals_returns_validated_json(client: TestClient) -> None:
|
||||
|
||||
def test_empty_trace_is_valid_not_404(client: TestClient) -> None:
|
||||
"""No telemetry → the proctor still assesses (nothing to flag)."""
|
||||
client.app.state.provider = ProctorJSON()
|
||||
client.app.state.provider = ProctorJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
resp = client.post(
|
||||
"/v1/proctor/signals", json={"learner_id": "nobody", "task_id": "nothing"}
|
||||
)
|
||||
@@ -107,7 +108,7 @@ def test_empty_trace_is_valid_not_404(client: TestClient) -> None:
|
||||
|
||||
|
||||
def test_unparseable_provider_502(client: TestClient) -> None:
|
||||
client.app.state.provider = BrokenJSON()
|
||||
client.app.state.provider = BrokenJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
_seed_trace(client)
|
||||
resp = client.post(
|
||||
"/v1/proctor/signals", json={"learner_id": "p-learner", "task_id": "p-task"}
|
||||
@@ -116,6 +117,6 @@ def test_unparseable_provider_502(client: TestClient) -> None:
|
||||
|
||||
|
||||
def test_missing_fields_422(client: TestClient) -> None:
|
||||
client.app.state.provider = ProctorJSON()
|
||||
client.app.state.provider = ProctorJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
resp = client.post("/v1/proctor/signals", json={"learner_id": "x"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
@@ -43,15 +43,34 @@ BASE_SETTINGS: dict = {
|
||||
"sandbox_max_concurrent": 5,
|
||||
"sandbox_max_per_learner": 1,
|
||||
"sandbox_creates_per_min": 10,
|
||||
# G-9: allowlist widened to the suite roster (identity records seeded
|
||||
# per-app below); the gate tests live in test_identity.py.
|
||||
"learner_allowlist": __import__("tests.conftest", fromlist=["SUITE_LEARNERS"]).SUITE_LEARNERS,
|
||||
}
|
||||
|
||||
|
||||
def _seed_identity(app, tmp_path: Path) -> None:
|
||||
"""G-9: every ad-hoc app in this module gets the suite's verified
|
||||
identity store (allowlist widened via BASE_SETTINGS)."""
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
from tests.conftest import seed_verified_identity
|
||||
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / f"identity-{id(app):x}.db")
|
||||
seed_verified_identity(store)
|
||||
app.state.identity_store = store
|
||||
|
||||
|
||||
class StubBackend:
|
||||
"""Structural SandboxBackend: lays out the workdir, spawns nothing.
|
||||
|
||||
Tracks spawn/destroy calls so lifespan shutdown behaviour is assertable.
|
||||
`structural = True` exempts it from the v0.8 degrade guard (D-068): the
|
||||
stub fabric never touches userns, so it must stay usable inside
|
||||
unprivileged CI job containers (D-063 fast lane).
|
||||
"""
|
||||
|
||||
structural = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.spawned_specs: list[SandboxSpec] = []
|
||||
self.destroyed_ids: list[str] = []
|
||||
@@ -103,6 +122,7 @@ def client(
|
||||
monkeypatch.setenv("AI_SANDBOX_CREATES_PER_MIN", "150") # shared-window headroom
|
||||
settings = Settings(**{**BASE_SETTINGS, "sandbox_dir": tmp_path / "sandboxes"})
|
||||
app = create_app(settings)
|
||||
_seed_identity(app, tmp_path)
|
||||
app.state.sandbox_manager = SandboxManager(backend=stub_backend, settings=settings)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
@@ -190,6 +210,7 @@ def test_pool_full_returns_503(tmp_path: Path, stub_backend: StubBackend) -> Non
|
||||
sandbox_creates_per_min=150,
|
||||
)
|
||||
app = create_app(settings)
|
||||
_seed_identity(app, tmp_path)
|
||||
app.state.sandbox_manager = SandboxManager(backend=stub_backend, settings=settings)
|
||||
with TestClient(app) as client:
|
||||
ids = [
|
||||
@@ -227,6 +248,7 @@ def test_second_active_sandbox_for_same_learner_429(
|
||||
sandbox_creates_per_min=150,
|
||||
)
|
||||
app = create_app(settings)
|
||||
_seed_identity(app, tmp_path)
|
||||
app.state.sandbox_manager = SandboxManager(backend=stub_backend, settings=settings)
|
||||
with TestClient(app) as client:
|
||||
first = client.post("/v1/sandboxes", json={"learner_id": PILOT})
|
||||
@@ -252,6 +274,7 @@ def test_burst_over_global_create_rate_429(tmp_path: Path, stub_backend: StubBac
|
||||
sandbox_creates_per_min=3,
|
||||
)
|
||||
app = create_app(settings)
|
||||
_seed_identity(app, tmp_path)
|
||||
manager = SandboxManager(backend=stub_backend, settings=settings)
|
||||
app.state.sandbox_manager = manager
|
||||
with TestClient(app) as client:
|
||||
@@ -309,6 +332,7 @@ def test_lifespan_start_and_shutdown_destroy(
|
||||
)
|
||||
manager = SandboxManager(backend=stub_backend, settings=settings)
|
||||
app = create_app(settings)
|
||||
_seed_identity(app, tmp_path)
|
||||
app.state.sandbox_manager = manager
|
||||
with TestClient(app) as client:
|
||||
assert not orphan_root.exists() # startup reaper ran during lifespan boot
|
||||
@@ -325,6 +349,7 @@ def test_lifespan_constructs_real_manager_when_not_overridden(tmp_path: Path) ->
|
||||
"""No override → the lifespan builds the production UnshareBackend manager."""
|
||||
settings = Settings(provider="mock", sandbox_dir=tmp_path / "sandboxes")
|
||||
app = create_app(settings)
|
||||
_seed_identity(app, tmp_path)
|
||||
with TestClient(app):
|
||||
manager = app.state.sandbox_manager
|
||||
assert isinstance(manager, SandboxManager)
|
||||
@@ -347,6 +372,7 @@ def test_real_backend_create_path_runs(tmp_path: Path) -> None:
|
||||
sandbox_creates_per_min=150,
|
||||
)
|
||||
app = create_app(settings) # no override → lifespan wires UnshareBackend
|
||||
_seed_identity(app, tmp_path)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
created = client.post("/v1/sandboxes", json={"learner_id": PILOT})
|
||||
|
||||
@@ -22,12 +22,19 @@ Covered:
|
||||
- reconnect after flood still cannot append (flag is terminal)
|
||||
- unknown trace GET → 404 (traces + gaps)
|
||||
- missing identity query params → handshake rejected
|
||||
|
||||
P2 (Task 2-1-04, deterministic waits): the old ``_drain()`` helper slept a
|
||||
fixed 50 ms "to give the portal a beat" after sending frames. That was never
|
||||
the synchronization — every read below is a BLOCKING portal receive, and the
|
||||
app's status frames travel a strictly-ordered unbounded memory stream, so a
|
||||
blocking ``ws.receive()`` returns each frame the moment the app produces it
|
||||
(the sibling ack/ping tests in this file never slept). The sleep added
|
||||
latency, not determinism; it is gone and the reads are the event-driven wait.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -40,13 +47,24 @@ from ai_service.config import Settings
|
||||
from ai_service.main import create_app
|
||||
from ai_service.telemetry import ingest as ingest_mod
|
||||
from ai_service.telemetry.store import SQLiteTraceStore
|
||||
from ai_service.telemetry.tokens import IngestToken
|
||||
|
||||
LEARNER = "learner-9"
|
||||
TASK = "task-9"
|
||||
_BASE_TS = datetime(2026, 9, 11, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
# P1 tier marker (Task 1-1-02): the queue-overflow leak-regression test
|
||||
# boots a REAL uvicorn server (TestClient teardown would hide the leak) —
|
||||
# real_server tier, 180s timeout via the conftest collection hook.
|
||||
# The rest of the suite is in-process TestClient-only.
|
||||
pytestmark = [pytest.mark.real_server]
|
||||
|
||||
KNOWN_KINDS = ("command", "file_diff", "run_result", "test_result", "activity")
|
||||
|
||||
#: Shared test token — minted into every test URL, verified by the endpoint
|
||||
#: (v0.4.7 P1: untokened dials are refused; tests prove both directions).
|
||||
TEST_TOKEN = IngestToken("test-secret-not-random")
|
||||
|
||||
|
||||
def _frame(seq: int, kind: str = "command", sandbox_id: str = "") -> str:
|
||||
return json.dumps(
|
||||
@@ -60,17 +78,6 @@ def _frame(seq: int, kind: str = "command", sandbox_id: str = "") -> str:
|
||||
)
|
||||
|
||||
|
||||
def _drain(ws) -> None:
|
||||
"""Give the portal a beat to deliver queued frames.
|
||||
|
||||
Starlette's TestClient runs the app on a portal thread; frames produced in
|
||||
the app loop still need a portal round-trip to reach the test thread. A
|
||||
real (tiny) sleep flushes them deterministically without hardcoding
|
||||
protocol timing.
|
||||
"""
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def _recv_status(ws) -> dict:
|
||||
"""Next JSON status frame, skipping keepalive pings; re-raise close.
|
||||
|
||||
@@ -84,11 +91,26 @@ def _recv_status(ws) -> dict:
|
||||
continue
|
||||
if msg["type"] == "websocket.close":
|
||||
raise WebSocketDisconnect(msg.get("code", 1000), msg.get("reason", ""))
|
||||
return json.loads(msg["text"])
|
||||
frame = json.loads(msg["text"])
|
||||
if frame.get("type") == "seq_ack": # D-045 advisory ack per append
|
||||
continue
|
||||
return frame
|
||||
|
||||
|
||||
def _ingest_url(learner_id: str = LEARNER, task_id: str = TASK, sandbox_id: str = "") -> str:
|
||||
url = f"/v1/telemetry/ingest?learner_id={learner_id}&task_id={task_id}"
|
||||
def _ingest_url(
|
||||
learner_id: str = LEARNER,
|
||||
task_id: str = TASK,
|
||||
sandbox_id: str = "",
|
||||
token: str | None = None,
|
||||
) -> str:
|
||||
"""Ingest URL with a VALID token by default (None = mint for identity);
|
||||
pass an explicit token string to test invalid/absent-token paths."""
|
||||
if token is None:
|
||||
token = TEST_TOKEN.mint(learner_id, task_id, sandbox_id)
|
||||
url = (
|
||||
f"/v1/telemetry/ingest?learner_id={learner_id}&task_id={task_id}"
|
||||
f"&token={token}"
|
||||
)
|
||||
return f"{url}&sandbox_id={sandbox_id}" if sandbox_id else url
|
||||
|
||||
|
||||
@@ -114,6 +136,9 @@ def app(store: SQLiteTraceStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
)
|
||||
application = create_app(settings)
|
||||
application.state.trace_store = store # state-injection override
|
||||
# v0.4.7 (P1): known token for tests (pre-boot-random would be untestable);
|
||||
# the endpoint refuses dials without a valid HMAC.
|
||||
application.state.ingest_token = TEST_TOKEN
|
||||
yield application
|
||||
|
||||
|
||||
@@ -123,6 +148,34 @@ def client(app) -> Iterator[TestClient]:
|
||||
yield c
|
||||
|
||||
|
||||
# -- seq-ack (D-045, REQ-5-007) ------------------------------------------------
|
||||
|
||||
|
||||
def test_each_append_emits_seq_ack_with_durable_latest(
|
||||
client: TestClient, store: SQLiteTraceStore
|
||||
) -> None:
|
||||
"""MH-1a: every successful append acks the post-append durable latest_seq
|
||||
(on dedup'd replays too — a-6)."""
|
||||
with client.websocket_connect(_ingest_url()) as ws:
|
||||
ws.send_text(_frame(0))
|
||||
ws.send_text(_frame(1))
|
||||
ws.send_text(_frame(2))
|
||||
ws.send_text(_frame(2)) # replay → dedup, still acked (a-6)
|
||||
acks: list[int] = []
|
||||
while len(acks) < 4:
|
||||
msg = ws.receive()
|
||||
if msg.get("bytes") is not None:
|
||||
continue
|
||||
if msg["type"] == "websocket.close":
|
||||
raise WebSocketDisconnect(msg.get("code", 1000))
|
||||
frame = json.loads(msg["text"])
|
||||
if frame.get("type") == "seq_ack":
|
||||
assert isinstance(frame["seq"], int)
|
||||
acks.append(frame["seq"])
|
||||
assert acks == [0, 1, 2, 2], f"expected per-append acks incl. dedup, got {acks}"
|
||||
assert store.latest_seq(LEARNER, TASK) == 2
|
||||
|
||||
|
||||
# -- happy path: ordered trace retrieval --------------------------------------
|
||||
|
||||
|
||||
@@ -179,8 +232,7 @@ def test_skip_to_seq_5_reports_gap_warning_and_gaps_endpoint(
|
||||
for seq in (0, 1, 2):
|
||||
ws.send_text(_frame(seq))
|
||||
ws.send_text(_frame(5)) # skips 3,4 → gap_warning frame
|
||||
_drain(ws)
|
||||
frames = [_recv_status(ws)]
|
||||
frames = [_recv_status(ws)] # blocking receive = the event-driven wait
|
||||
assert frames == [{"type": "gap_warning", "missing_seqs": [3, 4]}]
|
||||
|
||||
gaps = client.get(f"/v1/telemetry/gaps/{LEARNER}/{TASK}")
|
||||
@@ -199,7 +251,8 @@ def test_burst_past_cap_closes_1008_and_marks_incomplete_flooded(
|
||||
with client.websocket_connect(_ingest_url()) as ws:
|
||||
for seq in range(6):
|
||||
ws.send_text(_frame(seq))
|
||||
_drain(ws) # let the app flush the flooded frame + 1008 close
|
||||
# No settle sleep needed: the read loop below blocks until the app
|
||||
# delivers the flooded frame and the 1008 close (P2 Task 2-1-04).
|
||||
frames: list[dict] = []
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
while True:
|
||||
@@ -230,7 +283,6 @@ def test_queue_overflow_also_floods(
|
||||
with client.websocket_connect(_ingest_url(learner_id="L-q", task_id="T-q")) as ws:
|
||||
for seq in range(64): # receiver out-drains the drainer with bound=1
|
||||
ws.send_text(_frame(seq))
|
||||
_drain(ws)
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
while True:
|
||||
_recv_status(ws)
|
||||
@@ -250,14 +302,11 @@ async def test_queue_overflow_flood_session_task_terminates(tmp_path, monkeypatc
|
||||
stopped after the flood; the session tasks must be gone shortly after.
|
||||
"""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket as socket_mod
|
||||
|
||||
import uvicorn
|
||||
import websockets
|
||||
|
||||
from ai_service.main import create_app
|
||||
from ai_service.telemetry.ingest import TraceIntegrityMap
|
||||
from ai_service.telemetry.store import SQLiteTraceStore
|
||||
from tests.helpers import free_port, serve
|
||||
|
||||
monkeypatch.setattr(ingest_mod, "INBOUND_QUEUE_MAX", 1)
|
||||
store = SQLiteTraceStore(db_path=tmp_path / "leak.db")
|
||||
@@ -270,56 +319,43 @@ async def test_queue_overflow_flood_session_task_terminates(tmp_path, monkeypatc
|
||||
)
|
||||
app.state.trace_store = store
|
||||
app.state.trace_integrity = TraceIntegrityMap()
|
||||
# v0.4.7: real-server dial needs a token the endpoint accepts
|
||||
app.state.ingest_token = TEST_TOKEN
|
||||
|
||||
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())
|
||||
port = free_port()
|
||||
leaked = True
|
||||
try:
|
||||
for _ in range(100):
|
||||
if server.started:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
assert server.started
|
||||
async with serve(app, "127.0.0.1", port):
|
||||
uri = (
|
||||
f"ws://127.0.0.1:{port}/v1/telemetry/ingest"
|
||||
f"?learner_id=L-leak&task_id=T-leak"
|
||||
f"&token={TEST_TOKEN.mint('L-leak', '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")
|
||||
|
||||
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)
|
||||
# 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"
|
||||
|
||||
@@ -332,7 +368,6 @@ def test_reconnect_after_flood_cannot_resurrect_trace(
|
||||
with client.websocket_connect(_ingest_url()) as ws:
|
||||
for seq in range(6):
|
||||
ws.send_text(_frame(seq))
|
||||
_drain(ws)
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
while True:
|
||||
_recv_status(ws)
|
||||
@@ -340,7 +375,6 @@ def test_reconnect_after_flood_cannot_resurrect_trace(
|
||||
|
||||
with client.websocket_connect(_ingest_url()) as ws:
|
||||
ws.send_text(_frame(5))
|
||||
_drain(ws)
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
while True:
|
||||
_recv_status(ws)
|
||||
@@ -358,7 +392,6 @@ def test_frame_with_learner_id_in_body_rejected(client: TestClient) -> None:
|
||||
bad = json.loads(_frame(0))
|
||||
bad["learner_id"] = "spoofed"
|
||||
ws.send_text(json.dumps(bad))
|
||||
_drain(ws)
|
||||
frames = [_recv_status(ws)]
|
||||
assert frames[0]["type"] == "event_rejected"
|
||||
assert client.get(f"/v1/telemetry/traces/{LEARNER}/{TASK}").status_code == 404
|
||||
@@ -367,7 +400,6 @@ def test_frame_with_learner_id_in_body_rejected(client: TestClient) -> None:
|
||||
def test_unknown_event_kind_rejected_nothing_stored(client: TestClient) -> None:
|
||||
with client.websocket_connect(_ingest_url()) as ws:
|
||||
ws.send_text(_frame(0, kind="rm_rf_everything"))
|
||||
_drain(ws)
|
||||
frames = [_recv_status(ws)]
|
||||
assert frames[0]["type"] == "event_rejected"
|
||||
assert "unknown event kind" in frames[0]["detail"]
|
||||
@@ -394,17 +426,33 @@ def test_missing_identity_query_params_rejected_at_handshake(
|
||||
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_browser_origin_rejected_in_explicit_list_mode(
|
||||
settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""D-038: with an explicit AI_CORS_ORIGINS list, a page loaded in the
|
||||
learner's browser (unlisted 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)."""
|
||||
from fastapi.testclient import TestClient as TC
|
||||
|
||||
restricted = settings.model_copy(update={"cors_origins": "http://localhost:3000"})
|
||||
app = create_app(restricted)
|
||||
with TC(app) as c:
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
with c.websocket_connect(
|
||||
_ingest_url(), headers={"Origin": "https://evil.example"}
|
||||
):
|
||||
pass
|
||||
assert excinfo.value.code == 1008
|
||||
|
||||
|
||||
def test_wildcard_mode_admits_any_browser_origin(client: TestClient) -> None:
|
||||
"""D-038 default ('*'): remote-browser origins open the ingest socket —
|
||||
the remote build surface streams telemetry from the learner's browser."""
|
||||
with client.websocket_connect(
|
||||
_ingest_url(), headers={"Origin": "http://nextcraft-1:3000"}
|
||||
) as ws:
|
||||
ws.send_text(_frame(0))
|
||||
|
||||
|
||||
def test_dev_origin_and_no_origin_both_allowed(client: TestClient) -> None:
|
||||
@@ -433,3 +481,59 @@ def test_server_sends_periodic_ping(
|
||||
with client.websocket_connect(_ingest_url()) as ws:
|
||||
ping = ws.receive_bytes() # first server→client frame while idle
|
||||
assert ping # opaque payload; contract is "a ping frame arrives"
|
||||
|
||||
|
||||
# -- ingest-token auth (v0.4.7, P1) -------------------------------------------------
|
||||
|
||||
|
||||
def test_untokened_dial_refused(client: TestClient, store: SQLiteTraceStore) -> None:
|
||||
"""P1 flag: the LAN-reachable grading input refuses identity params
|
||||
without a token — self-asserted learner/task could poison traces."""
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
with client.websocket_connect(
|
||||
f"/v1/telemetry/ingest?learner_id={LEARNER}&task_id={TASK}"
|
||||
):
|
||||
pass
|
||||
assert excinfo.value.code == 1008
|
||||
assert store.latest_seq(LEARNER, TASK) == -1 # nothing stored
|
||||
|
||||
|
||||
def test_bad_token_refused(client: TestClient, store: SQLiteTraceStore) -> None:
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
with client.websocket_connect(
|
||||
_ingest_url(token="deadbeef" * 8)
|
||||
):
|
||||
pass
|
||||
assert excinfo.value.code == 1008
|
||||
assert store.latest_seq(LEARNER, TASK) == -1
|
||||
|
||||
|
||||
def test_cross_identity_token_refused(client: TestClient) -> None:
|
||||
"""A token minted for (A, T1) cannot dial as (B, T1) or (A, T2) — the
|
||||
HMAC binds the identity, so a leaked URL poisons only its own trace."""
|
||||
other = "learner-evil"
|
||||
token = TEST_TOKEN.mint(other, TASK, "")
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
with client.websocket_connect(_ingest_url(token=token)):
|
||||
pass
|
||||
assert excinfo.value.code == 1008
|
||||
# same learner, different task
|
||||
token2 = TEST_TOKEN.mint(LEARNER, "task-other", "")
|
||||
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||
with client.websocket_connect(_ingest_url(token=token2)):
|
||||
pass
|
||||
assert excinfo.value.code == 1008
|
||||
|
||||
|
||||
def test_manager_urls_carry_valid_tokens(client: TestClient) -> None:
|
||||
"""The sandbox manager's NC_INGEST_URL mints a token the endpoint
|
||||
accepts — mint/verify agree through the shared app.state instance."""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
manager = client.app.state.sandbox_manager # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
env = manager._capture_env("sbx-tok-1", "learner-tok", "task-tok")
|
||||
parsed = urlparse(env["NC_INGEST_URL"])
|
||||
qs = parse_qs(parsed.query)
|
||||
assert qs["token"][0] == client.app.state.ingest_token.mint( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
||||
"learner-tok", "task-tok", "sbx-tok-1"
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user