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
53 changes: 32 additions & 21 deletions src/flightdeck/server/routes/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field

from flightdeck.operations import OperationError, compute_diff, promote_release, rollback_release
from flightdeck.operations import ActionOutcome, OperationError, compute_diff, promote_release, rollback_release
from flightdeck.server.routes.common import ensure_app_state

router = APIRouter()
Expand Down Expand Up @@ -32,6 +32,29 @@ def _raise_bad_request(exc: OperationError) -> HTTPException:
return HTTPException(status_code=400, detail=str(exc))


def _action_body(outcome: ActionOutcome) -> dict[str, object]:
return {
"action_id": outcome.action_id,
"action": outcome.action,
"release_id": outcome.release_id,
"agent_id": outcome.agent_id,
"environment": outcome.environment,
"baseline_release_id": outcome.baseline_release_id,
"promoted_pointer_changed": outcome.promoted_pointer_changed,
"policy": outcome.policy.model_dump(mode="json"),
}


def _raise_policy_blocked(action: str, outcome: ActionOutcome) -> HTTPException:
return HTTPException(
status_code=409,
detail={
"message": f"{action.capitalize()} blocked by policy.",
"outcome": _action_body(outcome),
},
)


def _require_mutation_access(request: Request) -> None:
ensure_app_state(request)
expected_token: str | None = request.app.state.local_api_token
Expand Down Expand Up @@ -123,16 +146,10 @@ def post_promote(request: Request, req: ActionRequest) -> dict[str, object]:
except OperationError as exc:
raise _raise_bad_request(exc) from exc

return {
"action_id": outcome.action_id,
"action": outcome.action,
"release_id": outcome.release_id,
"agent_id": outcome.agent_id,
"environment": outcome.environment,
"baseline_release_id": outcome.baseline_release_id,
"promoted_pointer_changed": outcome.promoted_pointer_changed,
"policy": outcome.policy.model_dump(mode="json"),
}
if not outcome.policy.passed:
raise _raise_policy_blocked("promotion", outcome)

return _action_body(outcome)


@router.post("/v1/rollback")
Expand All @@ -152,13 +169,7 @@ def post_rollback(request: Request, req: ActionRequest) -> dict[str, object]:
except OperationError as exc:
raise _raise_bad_request(exc) from exc

return {
"action_id": outcome.action_id,
"action": outcome.action,
"release_id": outcome.release_id,
"agent_id": outcome.agent_id,
"environment": outcome.environment,
"baseline_release_id": outcome.baseline_release_id,
"promoted_pointer_changed": outcome.promoted_pointer_changed,
"policy": outcome.policy.model_dump(mode="json"),
}
if not outcome.policy.passed:
raise _raise_policy_blocked("rollback", outcome)

return _action_body(outcome)
31 changes: 31 additions & 0 deletions tests/test_sdk_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,37 @@ def handler(request: httpx.Request) -> httpx.Response:
assert client.list_releases() == {"releases": []}


def test_flightdeck_client_promote_raises_on_policy_block() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/v1/promote"
return httpx.Response(
409,
json={
"detail": {
"message": "Promotion blocked by policy.",
"outcome": {
"promoted_pointer_changed": False,
"policy": {"passed": False, "reasons": ["candidate cost exceeds max"]},
},
}
},
)

transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http:
client = FlightdeckClient("http://flightdeck.test", client=http)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
client.post_promote(
release_id="rel_blocked",
environment="local",
window="7d",
reason="policy check",
)

assert excinfo.value.response.status_code == 409


def test_flightdeck_client_ingest_batch_chunks_payload() -> None:
seen_lengths: list[int] = []

Expand Down
34 changes: 34 additions & 0 deletions tests/test_server_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,40 @@ def test_http_promote_requires_reason(tmp_path: Path) -> None:
assert res.status_code == 422


def test_http_promote_policy_block_returns_conflict_and_keeps_audit(tmp_path: Path) -> None:
ws = tmp_path / "ws_policy_block"
runner, baseline_id, candidate_id = _seed_workspace(ws)
with _cwd(ws):
policy = write_policy(ws, max_cost_per_run_usd=0.0001)
assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0

with TestClient(create_app()) as client:
res = client.post(
"/v1/promote",
json={
"release_id": candidate_id,
"environment": "local",
"window": "7d",
"reason": "too expensive",
"actor": "http-test",
},
)

assert res.status_code == 409
detail = res.json()["detail"]
assert detail["message"] == "Promotion blocked by policy."
outcome = detail["outcome"]
assert outcome["promoted_pointer_changed"] is False
assert outcome["policy"]["passed"] is False

storage = Storage(load_config().db_path)
storage.migrate()
assert storage.get_promoted_release_id("agent_support", "local") == baseline_id
last = storage.list_release_actions(agent_id="agent_support", environment="local")[0]
assert last.release_id == candidate_id
assert last.policy_result.passed is False


def test_ui_root_serves_vite_index(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
assert CliRunner().invoke(cli, ["init"]).exit_code == 0
Expand Down
Loading