diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc7028f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Backend build context = repo root (infra/docker/Dockerfile.backend). +# Keep this in sync with what the Dockerfile COPYs: pyproject.toml, uv.lock, +# packages/, services/, and data/ (the data/*.json seed is needed; the db/media are not). + +# secrets / env +.env +.env.* +.env.local + +# vcs / tooling +.git +.gitignore +.github + +# python build/runtime artifacts +.venv +__pycache__ +*.py[cod] +*.egg-info +.numba_cache +.pytest_cache +.ruff_cache +build +dist + +# runtime data (NOT data/*.json, which the image needs) +data/zeroforce.db +data/media + +# frontend (built in its own context, not needed by the backend image) +apps/web/node_modules +apps/web/.next +node_modules + +# misc +*.log +.DS_Store diff --git a/.env.example b/.env.example index 368208f..eef9641 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,13 @@ ZEROFORCE_DB=./data/zeroforce.db # Optional single bearer token; if unset, auth is open (local convenience). # ZEROFORCE_API_TOKEN= +# Optional CORS allowlist (comma-separated origins). Unset => "*" in fake/dev, locked in prod. +# ZEROFORCE_CORS_ORIGINS=https://app.example.com + +# Per-client requests/minute on the cost-heavy endpoints (analyze/simulate/whatif/upload/ +# vision/video). Unset => 30 in prod, off in fake/dev. Set 0 to disable explicitly. +# ZEROFORCE_RATE_LIMIT_PER_MINUTE=30 + # --- prod mode (GCP Application Default Credentials must also be configured) --- # GCP_PROJECT= # GCP_REGION=us-central1 diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml index 6fb7169..b5d3484 100644 --- a/.github/workflows/engine.yml +++ b/.github/workflows/engine.yml @@ -3,25 +3,33 @@ name: engine on: push: paths: - - "packages/zeroforce/**" + - "packages/**" + - "services/**" + - "tests/**" - ".github/workflows/engine.yml" pull_request: paths: - - "packages/zeroforce/**" + - "packages/**" + - "services/**" + - "tests/**" - ".github/workflows/engine.yml" -defaults: - run: - working-directory: packages/zeroforce - jobs: - test: + # Engine gating: hand-computed/differential/parity oracles. Merge is blocked unless these + # (and the numba smoke test) pass. The §4.6 closed-form xfail tests may xpass/xfail freely. + engine: runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/zeroforce steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "**/uv.lock" - name: Sync (Python pinned by .python-version, deps from pyproject) run: uv sync --extra test @@ -31,7 +39,26 @@ jobs: - name: import numba smoke test run: uv run python -c "import numba, numpy; print('numba', numba.__version__, 'numpy', numpy.__version__)" - # Merge is blocked unless the smoke test and all GATING tests pass. The xfail - # closed-form tests (§4.6) may xpass or xfail without blocking. - name: Run engine tests (gating) run: uv run pytest -q -rxX + + # Full workspace suite: backend + providers + parity (tests/) under the offline fake mode, + # so paid LLM calls are never made in CI. + backend: + runs-on: ubuntu-latest + env: + ZEROFORCE_MODE: fake + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "**/uv.lock" + + - name: Sync workspace (all packages + services) + run: uv sync --all-packages + + - name: Run backend + providers + parity tests + run: uv run pytest -q packages services tests diff --git a/Makefile b/Makefile index c173e5a..777fd9f 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,6 @@ dev: # Install all deps (Python workspace + frontend). install: uv sync --all-packages - uv pip install openai tavily-python # dev-mode provider SDKs (Cerebras + Tavily) uv pip install google-genai # prod-mode provider SDK (Gemini via Vertex) cd apps/web && npm install diff --git a/PROGRESS.md b/PROGRESS.md index e67ea96..c31eec7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -3,10 +3,17 @@ Branch: `phase-0-engine` (continuing here; will push + open one PR at end). Started autonomous full build 2026-05-22. Operator asleep; no questions allowed. +> **SUPERSEDED (historical record):** the multi-provider plan below (NVIDIA/Cerebras/Tavily + +> the `fake|dev|prod` 3-way mode) was later collapsed to **Gemini-only** with just `prod` +> (Gemini via Vertex/ADC) and `fake` (offline test double). The NVIDIA/Cerebras/Tavily +> providers and `dev` mode were removed. The text in this file is kept for history; it does +> **not** describe the current runtime. + ## Operating rules (from operator) 1. Providers: operator intended to paste NVIDIA/Tavily keys but none arrived. → Build real NVIDIA/Tavily/Gemini providers + a **deterministic FakeProvider as the default** for tests/CI/local-no-keys. Keys plug in via `.env` later. Live LLM stays UNVERIFIED. + _(Superseded — see the note above; the runtime is now Gemini-only.)_ 2. Priority if time short: **backend depth first**; frontend = functional dashboard. 3. Delivery: local commits per phase, **push + one PR at end**. 4. Ambiguity: **decide + document here**, keep going. diff --git a/README.md b/README.md index 2fe8c21..b48916a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ region defaults to `us-central1`. The project needs the Vertex AI API enabled. ```bash make test # uv run pytest -q + frontend vitest -uv run pytest -q # 56 Python tests (engine oracles + backend + parity) +uv run pytest -q # full Python suite (engine oracles + backend + parity) ``` The engine's gating oracles — hand-computed graph, Monte Carlo differential, and diff --git a/SECURITY.md b/SECURITY.md index b5148af..b35f975 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ Threat model and mitigations (spec §12), with implementation status in this rep | Threat | Surface | Mitigation | Status | |---|---|---|---| | Prompt injection | User input → LLM | Prod structuring (Pass B) uses schema-constrained output; all extractor output is validated against the `Graph` Pydantic schema before the engine runs. Untrusted text never triggers tool calls. | Implemented (schema validation in extractor/validator) | -| LLM cost exhaustion | `/analyze` | `max_nodes` hard cap 18 (Pydantic bound + orchestrator check); 5-minute job timeout; per-request token caps in providers. Per-IP/user rate limiting is scaffolded (gateway) — wire a limiter (e.g. slowapi) before public exposure. | Partial | +| LLM cost exhaustion | `/analyze` | `max_nodes` hard cap 18 (Pydantic bound + orchestrator check); 5-minute job timeout; per-request token caps in providers; per-client token-bucket **rate limiting** on the cost-heavy endpoints (analyze/simulate/whatif/upload/vision/video), keyed on bearer subject or IP (`ZEROFORCE_RATE_LIMIT_PER_MINUTE`, default 30/min in prod). | Implemented (in-process per instance; back with Redis for a global cap across instances) | | PII in uploads | `/upload` | 20 MB cap; SSN / card-number regex redaction before any text leaves the gateway. | Implemented + tested (`test_upload_redacts_pii`) | | Credential exposure | Provider keys | Keys only via env / Secret Manager; never in the client bundle (all LLM calls are server-side); `.env*` gitignored; `.env.example` ships placeholders only. | Implemented | | Citation forgery | LLM hallucinated sources | Prod citations come from Vertex grounding metadata (Pass A), not free text. Unverified citations should be badged in the UI. | Partial (grounding metadata captured; UI badge TODO) | @@ -17,8 +17,10 @@ Threat model and mitigations (spec §12), with implementation status in this rep - **No secrets are committed.** Verify with `git grep -nE "(API_KEY|SECRET|TOKEN)=.+"` returning nothing. - **Chaos**: a provider outage mid-pipeline fails the job cleanly with `llm_provider_error` (tested in `test_provider_outage_fails_job_gracefully`) rather than hanging or crashing. -- **Before public deployment**: enable rate limiting, write Firestore security rules, add the - unverified-citation UI badge, and run a dependency audit (`uv pip audit`, `npm audit`). +- **Rate limiting** is per-process (token bucket). On multi-instance Cloud Run the effective + global limit is `instances × rate`; back it with a shared Redis store for a hard global cap. +- **Before public deployment**: write Firestore security rules, add the unverified-citation UI + badge, and run a dependency audit (`uv pip audit`, `npm audit`). ## Reporting diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..2d66d3a --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,24 @@ +# Web build context = apps/web (infra/docker/Dockerfile.web does `COPY . .` in the builder). +# Exclude artifacts that would bloat the context or get reinstalled/rebuilt anyway. + +# deps & build output (npm ci reinstalls; next build regenerates) +node_modules +.next +out + +# env / secrets +.env +.env.* + +# vcs / tooling +.git +.gitignore + +# typescript / next scratch +*.tsbuildinfo +next-env.d.ts +.vercel + +# misc +*.log +.DS_Store diff --git a/apps/web/app/analyze/[id]/page.tsx b/apps/web/app/analyze/[id]/page.tsx index 5528569..aeaf86e 100644 --- a/apps/web/app/analyze/[id]/page.tsx +++ b/apps/web/app/analyze/[id]/page.tsx @@ -21,20 +21,38 @@ export default function AnalysisPage({ params }: { params: { id: string } }) { useEffect(() => { let alive = true; let timer: ReturnType; + const ctrl = new AbortController(); + // Retry transient fetch failures with bounded exponential backoff so a + // single network blip doesn't permanently halt polling. + const MAX_TRANSIENT_RETRIES = 6; + let transientFailures = 0; + async function poll() { try { - const j = await getAnalysis(params.id); + const j = await getAnalysis(params.id, ctrl.signal); if (!alive) return; + transientFailures = 0; setJob(j); if (j.status === "pending" || j.status === "running") timer = setTimeout(poll, 600); } catch (e) { - if (alive) setErr((e as Error).message); + if (!alive || ctrl.signal.aborted) return; + // A 4xx/5xx surfaced by the API client is treated as fatal; raw + // network errors (fetch rejects with TypeError) are treated as transient. + const transient = e instanceof TypeError; + if (transient && transientFailures < MAX_TRANSIENT_RETRIES) { + transientFailures += 1; + const delay = Math.min(600 * 2 ** (transientFailures - 1), 8000); + timer = setTimeout(poll, delay); + return; + } + setErr((e as Error).message); } } poll(); return () => { alive = false; clearTimeout(timer); + ctrl.abort(); }; }, [params.id]); @@ -49,13 +67,23 @@ export default function AnalysisPage({ params }: { params: { id: string } }) { return; } const maxRound = sim.rounds.length - 1; - if (round >= maxRound) { + if (maxRound <= 0) { setPlaying(false); return; } - const t = setTimeout(() => setRound((r) => r + 1), 800); - return () => clearTimeout(t); - }, [playing, sim, round]); + // Single interval keyed on [playing, sim]; advances until the last round, + // then stops itself. Cleared on unmount, pause, or a new simulation. + const interval = setInterval(() => { + setRound((r) => { + if (r >= maxRound) { + setPlaying(false); + return r; + } + return r + 1; + }); + }, 800); + return () => clearInterval(interval); + }, [playing, sim]); const startCascade = useCallback(async () => { if (!selected) return; @@ -114,7 +142,7 @@ export default function AnalysisPage({ params }: { params: { id: string } }) {
- {sim ? `Cascade round ${round} of ${sim.rounds.length - 1}.` : ""} + {sim && sim.rounds.length > 0 ? `Cascade round ${round} of ${sim.rounds.length - 1}.` : ""}
); diff --git a/apps/web/app/confirm/page.tsx b/apps/web/app/confirm/page.tsx index 34f130b..4ee7927 100644 --- a/apps/web/app/confirm/page.tsx +++ b/apps/web/app/confirm/page.tsx @@ -2,9 +2,14 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { startAnalysis } from "@/lib/api"; +import { isDraftGraph, startAnalysis } from "@/lib/api"; import type { DraftGraph } from "@/lib/types"; +let uidCounter = 0; +function edgeUid(): string { + return `edge_${Date.now().toString(36)}_${(uidCounter++).toString(36)}`; +} + export default function Confirm() { const router = useRouter(); const [draft, setDraft] = useState(null); @@ -13,7 +18,18 @@ export default function Confirm() { useEffect(() => { const raw = sessionStorage.getItem("zeroforce.draft"); - if (raw) setDraft(JSON.parse(raw)); + if (!raw) return; + try { + const parsed: unknown = JSON.parse(raw); + if (!isDraftGraph(parsed)) return; // falls through to the "No detected graph" branch + // Assign a stable uid to each edge so the reorderable list keys are stable. + setDraft({ + ...parsed, + edges: parsed.edges.map((e) => ({ ...e, uid: e.uid ?? edgeUid() })), + }); + } catch { + // Malformed JSON → leave draft null, showing the empty-state branch. + } }, []); if (!draft) { @@ -59,14 +75,14 @@ export default function Confirm() { (d) => d && { ...d, - edges: d.edges.map((e, idx) => (idx === i ? { source: e.target, target: e.source } : e)), + edges: d.edges.map((e, idx) => (idx === i ? { ...e, source: e.target, target: e.source } : e)), }, ); } function addEdge() { setDraft((d) => { if (!d || d.nodes.length < 2) return d; - return { ...d, edges: [...d.edges, { source: d.nodes[0].id, target: d.nodes[1].id }] }; + return { ...d, edges: [...d.edges, { source: d.nodes[0].id, target: d.nodes[1].id, uid: edgeUid() }] }; }); } @@ -127,7 +143,7 @@ export default function Confirm() {
{draft.edges.map((e, i) => ( -
+