{"record":{"id":"73699c711504f5d5","repo":"abi/screenshot-to-code","slug":"asset-not-found","errorCode":null,"errorMessage":"Asset not found","messagePattern":"Asset not found","errorType":"http","errorClass":"HTTPException","httpStatus":404,"severity":"warning","filePath":"backend/routes/agent_runs.py","lineNumber":224,"sourceCode":"    for candidate in (\"final_selfcontained.html\", \"final.html\"):\n        path = os.path.join(run_dir, candidate)\n        if os.path.isfile(path):\n            with open(path, \"r\", encoding=\"utf-8\") as f:\n                return HTMLResponse(content=f.read())\n    raise HTTPException(status_code=404, detail=\"Run has no captured output\")\n\n\n@router.get(\"/agent-runs/{run_id}/assets/{filename}\")\nasync def get_agent_run_asset(run_id: str, filename: str) -> FileResponse:\n    _fetch_run(run_id)\n    assets_dir = os.path.join(get_agent_runs_directory(), run_id, \"assets\")\n    # basename() strips any traversal components before the containment check.\n    safe_name = os.path.basename(filename)\n    path = os.path.realpath(os.path.join(assets_dir, safe_name))\n    if not path.startswith(os.path.realpath(assets_dir) + os.sep):\n        raise HTTPException(status_code=400, detail=\"Invalid asset path\")\n    if not os.path.isfile(path):\n        raise HTTPException(status_code=404, detail=\"Asset not found\")\n    return FileResponse(path)\n\n\n@router.post(\"/agent-runs/prune\", response_model=PruneAgentRunsResponse)\nasync def prune_agent_runs(request: PruneAgentRunsRequest) -> PruneAgentRunsResponse:\n    if request.max_age_days < 1:\n        raise HTTPException(status_code=400, detail=\"max_age_days must be >= 1\")\n\n    runs_directory = get_agent_runs_directory()\n    if not os.path.isdir(runs_directory):\n        return PruneAgentRunsResponse(deleted_count=0, freed_bytes=0)\n\n    cutoff_timestamp = (\n        datetime.now() - timedelta(days=request.max_age_days)\n    ).timestamp()\n\n    deleted_count = 0\n    freed_bytes = 0","sourceCodeStart":206,"sourceCodeEnd":242,"githubUrl":"https://github.com/abi/screenshot-to-code/blob/d026163f586dfa8c5c10d28c36edd59a9d3b0e88/backend/routes/agent_runs.py#L206-L242","documentation":"Raised by GET /agent-runs/{run_id}/assets/{filename} (404) when the containment checks pass but no regular file exists at assets/{basename(filename)}. Usually it simply means the requested asset name is not one this run produced; it also fires for traversal-style inputs, because basename() reduces '../x' to 'x' which does not exist.","triggerScenarios":"Requesting a mistyped or wrong-run asset name; referencing an asset from a different run's manifest; traversal payloads (../..) after basename stripping; assets deleted by prune or manual cleanup.","commonSituations":"Frontend caches asset URLs after a re-run produced different filenames; copy-paste of asset names between runs; partial cleanup of a run directory.","solutions":["Fetch the run detail via GET /agent-runs/{run_id} and use asset names exactly as recorded.","Verify the file exists under <runs_directory>/<run_id>/assets/ on disk.","Send only the bare filename (no path separators) so basename() does not mangle it.","If assets were pruned, re-run generation to recreate them."],"exampleFix":"# before\nGET /agent-runs/run_.../assets/images/logo.png  # basename strips dir -> 404\n\n# after\nGET /agent-runs/run_.../assets/logo.png  # bare filename that exists in assets/","handlingStrategy":"validation","validationCode":"detail = client.get(f\"/agent-runs/{run_id}\").json()\n# use names exactly as recorded by the run (e.g. from events/assets metadata)\nvalid_names = get_recorded_asset_names(detail)\nif filename not in valid_names:\n    raise LookupError(f\"asset {filename!r} not part of run {run_id}\")","typeGuard":"def asset_name_is_plain(filename: str) -> bool:\n    return isinstance(filename, str) and filename == os.path.basename(filename) and bool(filename.strip())","tryCatchPattern":"resp = client.get(f\"/agent-runs/{run_id}/assets/{filename}\")\nif resp.status_code == 404:\n    filename = pick_fresh_asset_name(run_id)  # re-sync with run detail\n    resp = client.get(f\"/agent-runs/{run_id}/assets/{filename}\")\nresp.raise_for_status()","preventionTips":["Derive asset URLs from current run metadata, not cached values.","Send only the bare filename.","Re-fetch run details after re-generation since filenames change."],"tags":["fastapi","http-404","assets","agent-runs"],"backgroundTag":null,"analyzedSha":"d026163f586dfa8c5c10d28c36edd59a9d3b0e88","analyzedAt":"2026-08-14T22:02:06.951Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}