diff --git a/backend/druks/api/artifacts.py b/backend/druks/api/artifacts.py index 79b33d38..8924da9f 100644 --- a/backend/druks/api/artifacts.py +++ b/backend/druks/api/artifacts.py @@ -2,6 +2,7 @@ from druks.api.schemas import ArtifactContent from druks.database import db_session +from druks.durable.exceptions import AgentCallNotFound from druks.durable.models import AgentCall, Artifact router = APIRouter(prefix="/api/artifacts", tags=["artifacts"]) @@ -14,8 +15,11 @@ async def get_artifact(artifact_id: str) -> ArtifactContent: artifact = db_session().get(Artifact, artifact_id) if not artifact: raise HTTPException(status.HTTP_404_NOT_FOUND, "artifact not found") - call = AgentCall.get(artifact.agent_call_id) - path = call.get_file_path(artifact.path) if call else None + try: + call = AgentCall.get(artifact.agent_call_id) + except AgentCallNotFound as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, "artifact content missing") from error + path = call.get_file_path(artifact.path) if not path: raise HTTPException(status.HTTP_404_NOT_FOUND, "artifact content missing") return ArtifactContent(kind=artifact.kind, title=artifact.title, content=path.read_text()) diff --git a/backend/druks/durable/exceptions.py b/backend/druks/durable/exceptions.py index 083b3eca..ba107202 100644 --- a/backend/druks/durable/exceptions.py +++ b/backend/druks/durable/exceptions.py @@ -15,6 +15,11 @@ class WorkflowError(Exception): pass +class AgentCallNotFound(Exception): + def __init__(self, agent_call_id: str) -> None: + super().__init__(f"No agent call {agent_call_id}.") + + class GateTimeout(FatalError): code = "gate_timeout" diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index b0743af1..7b5e54b2 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -28,6 +28,7 @@ RunState, WorkflowEvent, ) +from druks.durable.exceptions import AgentCallNotFound from druks.harnesses.artifacts import normalize_token_usage from druks.models import Base from druks.notifications.models import Notification @@ -490,8 +491,11 @@ def fail(cls, engine, *, call_id: str, error: BaseException) -> None: session.commit() @classmethod - def get(cls, agent_call_id: str) -> "AgentCall | None": - return db_session().get(cls, agent_call_id) + def get(cls, agent_call_id: str) -> "AgentCall": + call = db_session().get(cls, agent_call_id) + if not call: + raise AgentCallNotFound(agent_call_id) + return call @classmethod def list_for_run(cls, run_id: str) -> list["AgentCall"]: diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index ce560fde..f4b14e5f 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -12,6 +12,7 @@ from druks.durable.live import keepalive_comment, serialize_model_event from .enums import RunState +from .exceptions import AgentCallNotFound from .models import AgentCall, Artifact, Run from .schemas import ( AgentCallFiles, @@ -34,8 +35,9 @@ def get_agent_call_files(call_id: str) -> AgentCallFiles | None: - call = AgentCall.get(call_id) - if not call: + try: + call = AgentCall.get(call_id) + except AgentCallNotFound: return return AgentCallFiles.from_call(call, Artifact.get_for_call(call.id)) @@ -196,14 +198,14 @@ async def stream_transcript( last_keepalive = 0.0 while True: with session_scope(engine): - call = AgentCall.get(call_id) - path = call.get_stream_path(stream) if call else None - summary = AgentCallResponse.model_validate(call) if call else None - - if not summary: - # Unknown (or deleted) call: nothing will ever arrive — close the - # stream instead of keepaliving forever. - return + try: + call = AgentCall.get(call_id) + except AgentCallNotFound: + # Unknown (or deleted) call: nothing will ever arrive — close the + # stream instead of keepaliving forever. + return + path = call.get_stream_path(stream) + summary = AgentCallResponse.model_validate(call) if path and path.exists(): size = path.stat().st_size diff --git a/backend/druks/extensions/base.py b/backend/druks/extensions/base.py index 15442dd4..1f5ffe0b 100644 --- a/backend/druks/extensions/base.py +++ b/backend/druks/extensions/base.py @@ -332,6 +332,7 @@ def _get_transcript_routes(cls) -> "APIRouter": from druks.api.dependencies import EngineDep from druks.durable import reads from druks.durable.enums import AgentCallStatus + from druks.durable.exceptions import AgentCallNotFound from druks.durable.live import SSE_HEADERS from druks.durable.models import AgentCall from druks.durable.schemas import AgentCallFiles, TranscriptChunk @@ -358,9 +359,10 @@ async def get_transcript( raise HTTPException( status.HTTP_400_BAD_REQUEST, f"limit must be in 1..{max_limit}." ) - call = AgentCall.get(call_id) - if not call: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Run not found.") + try: + call = AgentCall.get(call_id) + except AgentCallNotFound as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Run not found.") from error if call.live_status == AgentCallStatus.RUNNING: response.headers["Cache-Control"] = "no-store" else: @@ -393,9 +395,10 @@ async def get_file( file_name: str, disposition: Literal["inline", "attachment"] = "inline", ) -> FileResponse: - call = AgentCall.get(call_id) - if not call: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Agent call not found.") + try: + call = AgentCall.get(call_id) + except AgentCallNotFound as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Agent call not found.") from error resolved = call.get_file_path(file_name) if not resolved: raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found for this call.") diff --git a/backend/druks/mcp/gateway/routes.py b/backend/druks/mcp/gateway/routes.py index 53036906..c3e0bdca 100644 --- a/backend/druks/mcp/gateway/routes.py +++ b/backend/druks/mcp/gateway/routes.py @@ -2,7 +2,8 @@ from druks.accounts.dependencies import current_account from druks.accounts.models import Account -from druks.mcp.gateway import schemas, services +from druks.durable import exceptions as durable_exceptions +from druks.mcp.gateway import exceptions, schemas, services # Docstrings here are the derived tool descriptions and operation_id is the # tool name — renaming one is a break, never a refactor side effect. @@ -49,7 +50,10 @@ async def answer_gate(run_id: str, body: schemas.AnswerGateRequest) -> schemas.G async def get_agent_call(call_id: str) -> schemas.AgentCallDetailResponse: """One agent call's metadata with bounded transcript and stderr tails and an artifact chunk.""" - return services.get_agent_call(call_id) + try: + return services.get_agent_call(call_id) + except durable_exceptions.AgentCallNotFound as error: + raise exceptions.AgentCallNotFound(call_id) from error @router.get( diff --git a/backend/druks/mcp/gateway/services.py b/backend/druks/mcp/gateway/services.py index 67608850..e6da8898 100644 --- a/backend/druks/mcp/gateway/services.py +++ b/backend/druks/mcp/gateway/services.py @@ -4,6 +4,7 @@ from druks.api.exceptions import RunNotFound from druks.core.utils.time import operator_local_day from druks.database import db_session +from druks.durable import exceptions as durable_exceptions from druks.durable.enums import RunState from druks.durable.models import AgentCall, Artifact, Run from druks.durable.reads import read_slice @@ -72,8 +73,6 @@ async def answer_gate( def get_agent_call(call_id: str) -> schemas.AgentCallDetailResponse: call = AgentCall.get(call_id) - if not call: - raise exceptions.AgentCallNotFound(call_id) layout = call.artifact_layout return schemas.AgentCallDetailResponse( run_id=call.run_id, @@ -89,8 +88,11 @@ def get_agent_call(call_id: str) -> schemas.AgentCallDetailResponse: def _artifact_content(artifact: Artifact | None) -> schemas.ArtifactContent | None: if not artifact: return - call = AgentCall.get(artifact.agent_call_id) - path = call.get_file_path(artifact.path) if call else None + try: + call = AgentCall.get(artifact.agent_call_id) + except durable_exceptions.AgentCallNotFound: + return + path = call.get_file_path(artifact.path) if not path: return return schemas.ArtifactContent( diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index fab8849f..4dd54437 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -113,6 +113,17 @@ def test_agent_errors_share_one_shape(client: TestClient, druks_db): assert body["retryable"] is True +def test_missing_agent_call_returns_wire_error(client: TestClient): + response = client.get("/api/agent-calls/no-such-call") + + assert response.status_code == 404 + assert response.json() == { + "code": "AGENT_CALL_NOT_FOUND", + "message": "No agent call no-such-call.", + "retryable": False, + } + + def test_get_gate_then_answer_roundtrip(client: TestClient, druks_db, resume_spy): note = Note.create(body="answer gate") run = _park(druks_db, note) diff --git a/backend/tests/test_agent_services.py b/backend/tests/test_agent_services.py index ba70d2f3..9d3d1fc3 100644 --- a/backend/tests/test_agent_services.py +++ b/backend/tests/test_agent_services.py @@ -8,9 +8,10 @@ from druks.accounts.models import Account from druks.api import runs from druks.api.exceptions import RunNotActive, RunNotFailed, RunNotFound, SubjectBusy +from druks.durable import exceptions as durable_exceptions from druks.durable.engine import run_queue from druks.durable.enums import WorkflowEvent -from druks.durable.models import Artifact, Run +from druks.durable.models import AgentCall, Artifact, Run from druks.durable.reads import read_slice from druks.mcp.gateway import exceptions, services from druks.testing import seed_call, seed_dbos_status @@ -203,6 +204,12 @@ async def test_answer_gate_error_taxonomy(druks_db, resume_spy): # ---- agent calls ---------------------------------------------------------- +def test_agent_call_get_raises_for_missing_call(druks_db): + with pytest.raises(durable_exceptions.AgentCallNotFound) as error: + AgentCall.get("no-such-call") + assert str(error.value) == "No agent call no-such-call." + + def test_get_agent_call_serves_bounded_tails(druks_db): from conftest import finish_agent_run, seed_note_agent_run @@ -226,7 +233,7 @@ def test_get_agent_call_serves_bounded_tails(druks_db): assert detail.artifact is not None assert detail.artifact.content == "a" * 4096 - with pytest.raises(exceptions.AgentCallNotFound): + with pytest.raises(durable_exceptions.AgentCallNotFound): services.get_agent_call("no-such-call") diff --git a/backend/tests/test_api_runs.py b/backend/tests/test_api_runs.py index 64efd02f..ca3a86e6 100644 --- a/backend/tests/test_api_runs.py +++ b/backend/tests/test_api_runs.py @@ -147,6 +147,16 @@ def test_transcript_missing_file_returns_eof( assert data["text"] == "" +def test_transcript_range_missing_call_returns_404(client: TestClient): + response = client.get( + "/api/field_notes/transcripts/no-such-call", + params={"stream": "stdout"}, + ) + + assert response.status_code == 404 + assert response.json() == {"error": "HTTP_404", "detail": "Run not found."} + + def test_transcript_stream_emits_chunk_then_finishes( client: TestClient, tmp_path: Path, @@ -199,6 +209,20 @@ def test_get_file_serves_inventory_paths( assert response.json() == {"ok": True} +def test_file_inventory_missing_call_returns_404(client: TestClient): + response = client.get("/api/field_notes/transcripts/no-such-call/files") + + assert response.status_code == 404 + assert response.json() == {"error": "HTTP_404", "detail": "Agent call not found."} + + +def test_file_download_missing_call_returns_404(client: TestClient): + response = client.get("/api/field_notes/transcripts/no-such-call/files/output.json") + + assert response.status_code == 404 + assert response.json() == {"error": "HTTP_404", "detail": "Agent call not found."} + + def test_get_file_rejects_path_traversal( client: TestClient, tmp_path: Path,