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
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ layer** built on top of it — all fully verifiable with `pytest`.
| Chain of Responsibility layer (router + executor + handlers, harness stays the authority) | done — `backend/app/chains/`, `backend/app/handlers/`, [`docs/chain_of_responsibility.md`](docs/chain_of_responsibility.md) |
| Side-effecting git capture + allowlisted command runners (wired into the implementation chain) | done — `backend/app/runners/git_runner.py`, `command_runner.py` |
| Runtime execution spine (safe API execution of a registered chain: workspace policy, controlled provider, tool runtime, structured parsing) | done — `backend/app/runtime/`, `backend/app/agents/`, `backend/app/tools/`, `backend/app/parsing/` |
| Tests covering every hard rule in Section 19 + the chain layer + runners + runtime spine | done — `backend/tests/` (256) |
| Durable run persistence behind a port (in-memory default + PostgreSQL adapter, durable audit schema) | done — `backend/app/storage/run_repository.py`, `postgres_run_repository.py`, [`docs/persistence.md`](docs/persistence.md) |
| Tests covering every hard rule in Section 19 + the chain layer + runners + runtime spine + persistence | done — `backend/tests/` (262; +6 Postgres-gated) |

## Chain of Responsibility (task routing inside the harness)

Expand Down Expand Up @@ -95,8 +96,11 @@ These are intentionally **not** claimed as done:

- Temporal workflow engine integration (the workflows are written as plain,
Temporal-ready orchestration for now).
- PostgreSQL persistence (the API uses an in-memory registry for the MVP;
`docker-compose.yml` provisions Postgres for the next phase).
- Async worker execution, tenant isolation/auth, and object-backed artifact
storage (Epics 4–6). Run persistence itself is **done**: runs live behind a
`RunRepository` port with an in-memory default and a durable PostgreSQL adapter
(`AGENT_ANALYSIS_DATABASE_URL`); see [`docs/persistence.md`](docs/persistence.md).
The `runs`/`evidence_artifacts` schema reserves `tenant_id` for Epic 5.
- Docker sandbox runner and GitHub push/PR integration (the *policies* and
*gates* that govern them exist; those side-effecting runners do not). The git
runner (read-only working-tree capture) and allowlisted command runner *are*
Expand Down Expand Up @@ -136,7 +140,7 @@ Notable invariants proven by the suite:
```bash
cd backend
python -m pip install -r requirements-dev.txt
python -m pytest -q # 256 tests
python -m pytest -q # 262 tests (+6 Postgres-gated, skipped without a DB)
python scripts/generate_schemas.py # regenerate ../schemas/*.schema.json
uvicorn app.main:app --reload # control API on http://127.0.0.1:8000
```
Expand Down Expand Up @@ -180,7 +184,8 @@ agent-analysis/
retry_budget.py # bounded autonomy (Section 6.9)
schemas/ # Pydantic contracts (Section 9)
gates/ # pure-function gates (Section 13)
storage/ # hashing, artifact store, ledger/checkpoint writers
storage/ # hashing, artifact store, ledger/checkpoint writers,
# run repository port + in-memory & Postgres adapters, sql/
runners/ # sandbox policy + git capture + command runner
llm/ # controlled LLM layer (adapter, router, recorder, stub)
workflows/ # read-only analysis loop (Section 17.1)
Expand All @@ -193,10 +198,10 @@ agent-analysis/
api/ # FastAPI routers (safe endpoints only)
main.py # FastAPI app
scripts/generate_schemas.py
tests/ # 256 tests
tests/ # 262 tests (+6 Postgres-gated)
pyproject.toml
schemas/ # generated JSON Schemas (Section 10)
docs/ # chain_of_responsibility.md, github_enforcement.md
docs/ # chain_of_responsibility.md, github_enforcement.md, persistence.md
artifacts/ # runtime evidence (git-ignored except .gitkeep)
frontend/ # Phase 6 control plane (planned — see README)
docker-compose.yml
Expand Down
13 changes: 9 additions & 4 deletions backend/app/api/routes_chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from fastapi import APIRouter, HTTPException

from app.api.store import registry
from app.api.store import get_repository
from app.chains.registry import CHAIN_DEFINITIONS, resolve_chain
from app.constants import Decision
from app.runtime.execution_request import ChainExecuteRequest
Expand Down Expand Up @@ -82,7 +82,8 @@ def plan_chain(run_id: str, request: ChainRequest) -> dict:
Unknown task types are BLOCKED (no chain). An agent cannot reorder the plan;
it is returned verbatim from the registry.
"""
record = registry.get(run_id)
repo = get_repository()
record = repo.get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")

Expand All @@ -97,6 +98,7 @@ def plan_chain(run_id: str, request: ChainRequest) -> dict:
"envelope_gate": envelope.model_dump(),
}
record.chain_result = result
repo.save(record)
return result

result = {
Expand All @@ -111,6 +113,7 @@ def plan_chain(run_id: str, request: ChainRequest) -> dict:
"auto_deploy": False,
}
record.chain_result = result
repo.save(record)
return result


Expand All @@ -123,7 +126,8 @@ def execute_chain(run_id: str, body: ChainExecuteRequest) -> ChainExecutionResul
execution path against the workspace policy, and returns a real
``ChainExecutionResult``. It never merges, deploys, or creates a PR.
"""
record = registry.get(run_id)
repo = get_repository()
record = repo.get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")

Expand Down Expand Up @@ -190,12 +194,13 @@ def execute_chain(run_id: str, body: ChainExecuteRequest) -> ChainExecutionResul

record.chain_execution_result = result
record.state = "EXECUTE_CHAIN"
repo.save(record)
return result


@router.get("/runs/{run_id}/chain/results")
def get_chain_results(run_id: str):
record = registry.get(run_id)
record = get_repository().get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")
if record.chain_execution_result is not None:
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from fastapi import APIRouter, HTTPException

from app.api.store import registry
from app.api.store import get_repository
from app.llm.catalog import MODEL_CATALOG
from app.schemas.model_policy import ModelRole

Expand All @@ -34,7 +34,7 @@ def models_catalog() -> list[dict]:

@router.get("/runs/{run_id}/llm-invocations")
def run_llm_invocations(run_id: str) -> list[dict]:
record = registry.get(run_id)
record = get_repository().get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")
return list(record.llm_invocations)
18 changes: 11 additions & 7 deletions backend/app/api/routes_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from app.gates.manifest_gate import manifest_gate
from app.gates.no_self_certification_gate import no_self_certification_gate
from app.gates.pr_gate import pr_gate
from app.api.store import RunRecord, registry
from app.api.store import RunRecord, get_repository
from app.schemas.evidence_ledger import EvidenceLedger
from app.schemas.gate_result import GateResult
from app.schemas.run_manifest import RunManifest
Expand Down Expand Up @@ -50,18 +50,18 @@ def create_run(manifest: RunManifest) -> CreateRunResponse:

run_id = manifest.run_id or f"run-{uuid.uuid4().hex[:12]}"
record = RunRecord(run_id=run_id, manifest=manifest, state="INTAKE")
registry.add(record)
get_repository().add(record)
return CreateRunResponse(run_id=run_id, state=record.state, manifest_gate=gate)


@router.get("")
def list_runs() -> list[dict]:
return [{"run_id": r.run_id, "state": r.state} for r in registry.list()]
return [{"run_id": r.run_id, "state": r.state} for r in get_repository().list()]


@router.get("/{run_id}")
def get_run(run_id: str) -> dict:
record = registry.get(run_id)
record = get_repository().get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")
return {
Expand All @@ -76,7 +76,7 @@ def get_run(run_id: str) -> dict:

@router.get("/{run_id}/state")
def get_run_state(run_id: str) -> dict:
record = registry.get(run_id)
record = get_repository().get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")
return {"run_id": record.run_id, "state": record.state}
Expand All @@ -94,7 +94,8 @@ def verify_run(run_id: str, body: VerifyRequest) -> dict:
Re-runs the no-self-certification and evidence gates server-side: the API
will not let a self-certified or unhashed-evidence report stand as PASS.
"""
record = registry.get(run_id)
repo = get_repository()
record = repo.get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")

Expand All @@ -121,6 +122,7 @@ def verify_run(run_id: str, body: VerifyRequest) -> dict:

record.verifier_report = report.model_copy(update={"decision": effective})
record.state = "VERIFY"
repo.save(record)
return {
"run_id": record.run_id,
"reported_decision": report.decision,
Expand All @@ -141,7 +143,8 @@ def create_or_update_pr(run_id: str, body: PrRequest) -> dict:

There is no merge and no deploy here. The PR gate re-asserts that.
"""
record = registry.get(run_id)
repo = get_repository()
record = repo.get(run_id)
if record is None:
raise HTTPException(status_code=404, detail="run not found")
decision = record.verifier_report.decision if record.verifier_report else Decision.PENDING
Expand All @@ -161,6 +164,7 @@ def create_or_update_pr(run_id: str, body: PrRequest) -> dict:
raise HTTPException(status_code=422, detail=gate.model_dump())

record.state = "PR_GATE"
repo.save(record)
return {
"run_id": record.run_id,
"pr_url": body.pr_url,
Expand Down
85 changes: 57 additions & 28 deletions backend/app/api/store.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,72 @@
"""In-memory run registry for the MVP control API.
"""Run store selection for the control API.

This is deliberately simple state-of-the-world storage. It is *not* the source
of truth for PASS — only verifier reports decide that. PostgreSQL replaces this
in a later phase; the surface is intentionally small so that swap is contained.
The run state lives behind a port (:class:`RunRepository`); this module owns the
*active* adapter the routers use. The default is the in-memory adapter (dev and
the whole test suite). Set ``AGENT_ANALYSIS_DATABASE_URL`` to bind the durable
Postgres adapter instead (see ``configure_from_env``).

Back-compat: ``RunRecord``, ``RunRegistry`` and the ``registry`` singleton are
re-exported so existing imports and ``registry.runs.clear()`` keep working — the
default active repository *is* that in-memory ``registry`` instance.
"""

from __future__ import annotations

from dataclasses import dataclass, field
import os

from app.storage.run_records import RunRecord
from app.storage.run_repository import (
InMemoryRunRepository,
RunRegistry,
RunRepository,
)

__all__ = [
"RunRecord",
"RunRegistry",
"RunRepository",
"registry",
"get_repository",
"set_repository",
"reset_repository",
"configure_from_env",
]

# Default in-memory store. Kept as a module-level singleton for back-compat.
registry: InMemoryRunRepository = InMemoryRunRepository()

_active: RunRepository = registry

from app.schemas.chain import ChainExecutionResult
from app.schemas.run_manifest import RunManifest
from app.schemas.verifier_report import VerifierReport

def get_repository() -> RunRepository:
"""Return the active run repository the routers should use."""
return _active

@dataclass
class RunRecord:
run_id: str
manifest: RunManifest
state: str = "INTAKE"
verifier_report: VerifierReport | None = None
chain_result: dict | None = None
chain_execution_result: ChainExecutionResult | None = None
llm_invocations: list = field(default_factory=list)

def set_repository(repo: RunRepository) -> None:
"""Bind a specific repository adapter (used at startup and in tests)."""
global _active
_active = repo

@dataclass
class RunRegistry:
runs: dict[str, RunRecord] = field(default_factory=dict)

def add(self, record: RunRecord) -> None:
self.runs[record.run_id] = record
def reset_repository() -> None:
"""Restore the default in-memory repository."""
global _active
_active = registry

def get(self, run_id: str) -> RunRecord | None:
return self.runs.get(run_id)

def list(self) -> list[RunRecord]:
return list(self.runs.values())
def configure_from_env() -> RunRepository:
"""Select the adapter from the environment.

``AGENT_ANALYSIS_DATABASE_URL`` set -> durable Postgres adapter; otherwise the
in-memory adapter. Importing the Postgres adapter is deferred so the core
install never needs a database driver.
"""
dsn = os.environ.get("AGENT_ANALYSIS_DATABASE_URL")
if dsn:
from app.storage.postgres_run_repository import PostgresRunRepository

# Module-level singleton used by the routers for the MVP.
registry = RunRegistry()
set_repository(PostgresRunRepository(dsn))
else:
reset_repository()
return get_repository()
13 changes: 13 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,21 @@

from __future__ import annotations

from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.api import routes_chains, routes_models, routes_runs
from app.api.store import configure_from_env


@asynccontextmanager
async def lifespan(app: FastAPI):
# Bind the durable run store when AGENT_ANALYSIS_DATABASE_URL is set;
# otherwise the in-memory adapter, so dev and tests need no database.
configure_from_env()
yield


app = FastAPI(
title="Agent-Analysis Control API",
Expand All @@ -18,6 +30,7 @@
"No auto-merge. No auto-deploy. No self-certification."
),
version="0.1.0",
lifespan=lifespan,
)

app.include_router(routes_runs.router)
Expand Down
Loading
Loading