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
26 changes: 25 additions & 1 deletion src/sovereign_cortex/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import BaseModel, Field

from .activity import ActivityRecordStore
from .approvals import ApprovalRequest, ApprovalStatus, ApprovalStore
from .approvals import ApprovalConflictError, ApprovalRequest, ApprovalStatus, ApprovalStore
from .events import CommandPayload, EventEnvelope, EventType, TaskResult
from .orchestrator import LocalOrchestrator
from .project_reports import handle_project_report
Expand All @@ -30,6 +30,10 @@ class CommandResponse(BaseModel):
events: list[dict[str, Any]]


class RejectApprovalRequest(BaseModel):
reason: str | None = None


def create_app(repo_root: Path | None = None) -> FastAPI:
root = repo_root or Path(__file__).resolve().parents[2]
app = FastAPI(title="Sovereign AI Local Control Plane", version="0.1.0")
Expand Down Expand Up @@ -68,6 +72,26 @@ def get_approval(approval_id: str) -> ApprovalRequest:
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc

@app.post("/approvals/{approval_id}/approve", response_model=ApprovalRequest)
def approve_approval(approval_id: str) -> ApprovalRequest:
try:
return ApprovalStore(root).approve(approval_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record API approval decisions in the activity log

When an approval is resolved through either new HTTP route, the store is mutated and the function returns without appending a PATCH_APPROVED or PATCH_REJECTED event to ActivityRecordStore. The equivalent CLI paths explicitly append these events in src/sovereign_cortex/cli.py, so API-driven decisions leave the activity JSONL lifecycle incomplete and cannot be correlated or replayed with the original approval request.

Useful? React with 👍 / 👎.

except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ApprovalConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

@app.post("/approvals/{approval_id}/reject", response_model=ApprovalRequest)
def reject_approval(approval_id: str, request: RejectApprovalRequest) -> ApprovalRequest:
try:
return ApprovalStore(root).reject(approval_id, request.reason)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

return app


Expand Down
103 changes: 103 additions & 0 deletions tests/test_api_approval_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from fastapi import HTTPException
import pytest

from sovereign_cortex.api import RejectApprovalRequest, create_app
from sovereign_cortex.approvals import ApprovalStore
from sovereign_cortex.events import PatchOperation, ProposedPatch
from sovereign_cortex.policy import PolicyDecision


def _endpoint(app, path: str):
for route in app.routes:
if route.path == path:
return route.endpoint
raise AssertionError(f"Route not found: {path}")


def _write_workspace(tmp_path):
vault = tmp_path / "vault" / "demo-project" / "tasks"
vault.mkdir(parents=True)
(tmp_path / "workspaces.yaml").write_text(
"workspaces:\n demo-project:\n vault_root: vault/demo-project\n",
encoding="utf-8",
)
task = vault / "launch.md"
task.write_text(
"---\nid: launch\ntitle: Launch\nstatus: planned\n---\n# Launch\n",
encoding="utf-8",
)
return task


def _create_request(tmp_path, *, before: str, after: str):
return ApprovalStore(tmp_path).create(
workspace="demo-project",
command_text="Update launch task",
correlation_id="api-action-test",
patch=ProposedPatch(
operation=PatchOperation.UPDATE_FILE,
relative_path="tasks/launch.md",
before=before,
after=after,
summary="Update launch task",
risk="medium",
requires_human_approval=True,
),
policy_decision=PolicyDecision(
allowed=True,
approval_required=True,
reason="Medium-risk patch requires approval.",
),
)


def test_approval_action_routes_are_registered():
app = create_app()
paths = {route.path for route in app.routes}

assert "/approvals/{approval_id}/approve" in paths
assert "/approvals/{approval_id}/reject" in paths


def test_approval_approve_route_applies_patch_and_moves_request(tmp_path):
task = _write_workspace(tmp_path)
before = task.read_text(encoding="utf-8")
after = before.replace("status: planned", "status: active")
request = _create_request(tmp_path, before=before, after=after)
approve = _endpoint(create_app(tmp_path), "/approvals/{approval_id}/approve")

response = approve(request.approval_id)

assert response.status == "approved"
assert response.commit_hash
assert task.read_text(encoding="utf-8") == after
assert (tmp_path / "approval_requests" / "approved" / f"{request.approval_id}.json").exists()
assert not (tmp_path / "approval_requests" / "pending" / f"{request.approval_id}.json").exists()


def test_approval_approve_route_returns_409_for_conflict(tmp_path):
task = _write_workspace(tmp_path)
request = _create_request(tmp_path, before="older content", after="new content")
approve = _endpoint(create_app(tmp_path), "/approvals/{approval_id}/approve")

with pytest.raises(HTTPException) as error:
approve(request.approval_id)

assert error.value.status_code == 409
assert task.exists()
assert (tmp_path / "approval_requests" / "pending" / f"{request.approval_id}.json").exists()


def test_approval_reject_route_moves_request_without_writing(tmp_path):
task = _write_workspace(tmp_path)
before = task.read_text(encoding="utf-8")
request = _create_request(tmp_path, before=before, after=before + "\nextra\n")
reject = _endpoint(create_app(tmp_path), "/approvals/{approval_id}/reject")

response = reject(request.approval_id, RejectApprovalRequest(reason="Not now."))

assert response.status == "rejected"
assert response.rejection_reason == "Not now."
assert task.read_text(encoding="utf-8") == before
assert (tmp_path / "approval_requests" / "rejected" / f"{request.approval_id}.json").exists()
assert not (tmp_path / "approval_requests" / "pending" / f"{request.approval_id}.json").exists()
Loading