Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 36 additions & 9 deletions .github/workflows/engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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

Expand Down
24 changes: 24 additions & 0 deletions apps/web/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
42 changes: 35 additions & 7 deletions apps/web/app/analyze/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,38 @@ export default function AnalysisPage({ params }: { params: { id: string } }) {
useEffect(() => {
let alive = true;
let timer: ReturnType<typeof setTimeout>;
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]);

Expand All @@ -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;
Expand Down Expand Up @@ -114,7 +142,7 @@ export default function AnalysisPage({ params }: { params: { id: string } }) {
</aside>

<div aria-live="polite" className="sr-only">
{sim ? `Cascade round ${round} of ${sim.rounds.length - 1}.` : ""}
{sim && sim.rounds.length > 0 ? `Cascade round ${round} of ${sim.rounds.length - 1}.` : ""}
</div>
</div>
);
Expand Down
26 changes: 21 additions & 5 deletions apps/web/app/confirm/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DraftGraph | null>(null);
Expand All @@ -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) {
Expand Down Expand Up @@ -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() }] };
});
}

Expand Down Expand Up @@ -127,7 +143,7 @@ export default function Confirm() {
</div>
<div className="mt-3 space-y-2">
{draft.edges.map((e, i) => (
<div key={i} className="flex items-center gap-2">
<div key={e.uid ?? i} className="flex items-center gap-2">
<select
value={e.source}
onChange={(ev) => setDraft((d) => d && { ...d, edges: d.edges.map((x, idx) => (idx === i ? { ...x, source: ev.target.value } : x)) })}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,12 @@ export default function Home() {
{EXAMPLES.map((ex) => (
<button
key={ex}
disabled={loading}
onClick={() => {
setInput(ex);
submit(ex);
}}
className="rounded-full border border-edge px-3 py-1 text-sm text-gray-300 hover:border-blue-500"
className="rounded-full border border-edge px-3 py-1 text-sm text-gray-300 hover:border-blue-500 disabled:opacity-50"
>
{ex}
</button>
Expand Down
Loading
Loading