Skip to content
Closed
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
8 changes: 6 additions & 2 deletions backend/druks/api/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: this except AgentCallNotFound branch has no focused test. test_get_artifact_404_when_content_gone seeds a real AgentCall, so it exercises the missing-file branch below, not this one. The same applies to the except in druks/mcp/gateway/services.py:_artifact_content.

Both branches are hard to reach through the DB — Artifact.agent_call_id is a ForeignKey(..., ondelete="CASCADE"), so an artifact never outlives its call — which is why I am not blocking. If you want the coverage, the cheapest route is monkeypatching AgentCall.get to raise, in a sibling of test_get_artifact_404_when_content_gone (and one in test_agent_services.py for the gateway projection). Leaving both as untested defensive guards is also defensible — push back if you disagree.

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())
5 changes: 5 additions & 0 deletions backend/druks/durable/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
8 changes: 6 additions & 2 deletions backend/druks/durable/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]:
Expand Down
22 changes: 12 additions & 10 deletions backend/druks/durable/reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))

Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions backend/druks/extensions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.")
Expand Down
8 changes: 6 additions & 2 deletions backend/druks/mcp/gateway/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions backend/druks/mcp/gateway/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions backend/tests/test_agent_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions backend/tests/test_agent_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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")


Expand Down
24 changes: 24 additions & 0 deletions backend/tests/test_api_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading