feat: approval decisions, tenant scoping, error paths with tests
- POST /api/approvals/{id}/decision (approve starts continuation flow
with task facts; reject closes; 404/409/422 guards)
- Tenant scoping: list filters + cross-tenant reads 404
- Error mapping covered: LFX down 503, LFX drift 502, corrupt flow 422
- 34 tests green
This commit is contained in:
parent
1034206870
commit
6c195bc2e7
4 changed files with 389 additions and 12 deletions
|
|
@ -34,6 +34,8 @@ from .lfx_client import (
|
|||
from .schemas import (
|
||||
ApprovalCreate,
|
||||
ApprovalRead,
|
||||
DecisionRead,
|
||||
DecisionRequest,
|
||||
FlowDefinitionCreate,
|
||||
FlowDefinitionRead,
|
||||
FlowImportRead,
|
||||
|
|
@ -290,9 +292,13 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
|||
|
||||
|
||||
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
|
||||
def list_runs(flow_id: UUID, session: Session = Depends(get_db)):
|
||||
def list_runs(
|
||||
flow_id: UUID,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return [_read_run(r) for r in store.list_runs(session, flow_id)]
|
||||
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
|
@ -350,11 +356,19 @@ async def import_flow(
|
|||
|
||||
|
||||
@flows_router.get("/runs/{run_id}", response_model=RunRead)
|
||||
def get_run(run_id: UUID, session: Session = Depends(get_db)):
|
||||
def get_run(
|
||||
run_id: UUID,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
):
|
||||
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
|
||||
try:
|
||||
return _read_run(store.get_run(session, run_id))
|
||||
run = store.get_run(session, run_id)
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if tenant_id is not None and run.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
||||
return _read_run(run)
|
||||
|
||||
|
||||
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
||||
|
|
@ -376,3 +390,66 @@ def list_approvals(
|
|||
session: Session = Depends(get_db),
|
||||
):
|
||||
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
|
||||
|
||||
|
||||
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
|
||||
async def decide_approval(
|
||||
task_id: UUID,
|
||||
data: DecisionRequest,
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
):
|
||||
"""Decide an open task. Approve starts the continuation flow (if any)."""
|
||||
try:
|
||||
task = store.get_task(session, task_id)
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
# Resolve the continuation first: bad references fail before any state change.
|
||||
continuation_version = None
|
||||
if data.decision == "approve" and task.continuation_flow_id is not None:
|
||||
try:
|
||||
continuation_version = store.get_latest_deployed_version(
|
||||
session, task.continuation_flow_id
|
||||
)
|
||||
except (store.NotFoundError, store.InvalidError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
try:
|
||||
task = store.decide_task(
|
||||
session,
|
||||
task,
|
||||
decision="approved" if data.decision == "approve" else "rejected",
|
||||
approver=data.approver,
|
||||
note=data.note,
|
||||
)
|
||||
except store.ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
# Note: the decision is committed before the continuation runs. If LFX is
|
||||
# down, the task stays decided and the continuation can be started
|
||||
# manually from the preserved task payload.
|
||||
continuation_run = None
|
||||
if continuation_version is not None:
|
||||
payload = task.payload
|
||||
if isinstance(payload.get("facts"), dict):
|
||||
payload = payload["facts"]
|
||||
try:
|
||||
continuation_run = await runner.execute_run(
|
||||
session,
|
||||
lfx,
|
||||
continuation_version,
|
||||
payload=payload,
|
||||
tenant_id=task.tenant_id,
|
||||
session_id=f"continuation-of-{task.run_id}",
|
||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||
)
|
||||
except LfxNotFound as exc:
|
||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||
except LfxUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except LfxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return DecisionRead(
|
||||
task=_read_task(task),
|
||||
continuation_run=_read_run(continuation_run) if continuation_run else None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -98,3 +98,15 @@ class FlowImportRequest(BaseModel):
|
|||
class FlowImportRead(BaseModel):
|
||||
definition: FlowDefinitionRead
|
||||
version: FlowVersionRead
|
||||
|
||||
|
||||
class DecisionRequest(BaseModel):
|
||||
decision: str = Field(pattern="^(approve|reject)$")
|
||||
approver: str = Field(default="", max_length=200)
|
||||
note: str = Field(default="", max_length=2000)
|
||||
callback_base_url: str | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
task: ApprovalRead
|
||||
continuation_run: RunRead | None
|
||||
|
|
|
|||
|
|
@ -174,16 +174,19 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
|
|||
return run
|
||||
|
||||
|
||||
def list_runs(session: Session, flow_id: UUID) -> list[FlowRun]:
|
||||
def list_runs(
|
||||
session: Session, flow_id: UUID, tenant_id: str | None = None
|
||||
) -> list[FlowRun]:
|
||||
get_definition(session, flow_id)
|
||||
return list(
|
||||
session.exec(
|
||||
select(FlowRun)
|
||||
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
|
||||
.where(FlowVersion.flow_id == flow_id)
|
||||
.order_by(FlowRun.created_at.desc())
|
||||
).all()
|
||||
query = (
|
||||
select(FlowRun)
|
||||
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
|
||||
.where(FlowVersion.flow_id == flow_id)
|
||||
.order_by(FlowRun.created_at.desc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.where(FlowRun.tenant_id == tenant_id)
|
||||
return list(session.exec(query).all())
|
||||
|
||||
|
||||
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
|
||||
|
|
@ -223,3 +226,33 @@ def list_approval_tasks(session: Session, tenant_id: str | None = None) -> list[
|
|||
if tenant_id is not None:
|
||||
query = query.where(ApprovalTask.tenant_id == tenant_id)
|
||||
return list(session.exec(query).all())
|
||||
|
||||
|
||||
def get_task(session: Session, task_id: UUID) -> ApprovalTask:
|
||||
task = session.get(ApprovalTask, task_id)
|
||||
if task is None:
|
||||
raise NotFoundError(f"approval task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
def decide_task(
|
||||
session: Session,
|
||||
task: ApprovalTask,
|
||||
*,
|
||||
decision: str,
|
||||
approver: str = "",
|
||||
note: str = "",
|
||||
) -> ApprovalTask:
|
||||
"""Approve or reject an open task. Decided tasks are immutable (409)."""
|
||||
if task.status != "open":
|
||||
raise ConflictError(f"task {task.id} is already {task.status}")
|
||||
if decision not in ("approved", "rejected"):
|
||||
raise InvalidError(f"unknown decision {decision!r}")
|
||||
task.status = decision
|
||||
task.approver = approver
|
||||
task.decision_note = note
|
||||
task.decided_at = datetime.now(timezone.utc)
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
return task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue