feat(036): artifacts.py storage + tracker tests + bugfixes
This commit is contained in:
@@ -397,6 +397,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
|||||||
_request_error_code: str | None = None
|
_request_error_code: str | None = None
|
||||||
_attempts_used: int = 0
|
_attempts_used: int = 0
|
||||||
conv_id: str | None = None
|
conv_id: str | None = None
|
||||||
|
run_tracker = None # 036: Durable run tracker
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── Resolve conversation ID and trace ID ──
|
# ── Resolve conversation ID and trace ID ──
|
||||||
|
|||||||
@@ -132,12 +132,12 @@ def test_object_name_at_256_chars_passes():
|
|||||||
|
|
||||||
# #region Test.Agent.TestInvalidContextVersionRaises [C:2] [TYPE Function]
|
# #region Test.Agent.TestInvalidContextVersionRaises [C:2] [TYPE Function]
|
||||||
def test_invalid_context_version_raises():
|
def test_invalid_context_version_raises():
|
||||||
"""Only contextVersion=1 is currently supported."""
|
"""Only contextVersion=1 and 2 are supported."""
|
||||||
payload = {
|
payload = {
|
||||||
"objectType": "dashboard",
|
"objectType": "dashboard",
|
||||||
"objectId": "42",
|
"objectId": "42",
|
||||||
"route": "/dashboards/42",
|
"route": "/dashboards/42",
|
||||||
"contextVersion": 2,
|
"contextVersion": 3,
|
||||||
}
|
}
|
||||||
with pytest.raises(UIContextValidationError, match="contextVersion"):
|
with pytest.raises(UIContextValidationError, match="contextVersion"):
|
||||||
validate_uicontext(payload)
|
validate_uicontext(payload)
|
||||||
|
|||||||
178
agent/tests/test_agent/test_agent_run_tracker.py
Normal file
178
agent/tests/test_agent/test_agent_run_tracker.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# agent/tests/test_agent/test_agent_run_tracker.py
|
||||||
|
# #region TestAgent.RunTracker [C:2] [TYPE Module] [SEMANTICS test,agent,run,tracker,036]
|
||||||
|
# @BRIEF Tests for RunTracker — backend communication, error handling, idempotency.
|
||||||
|
# @RELATION BINDS_TO -> [AgentChat.RunTracker]
|
||||||
|
# @TEST_EDGE backend_loss -> no unpersisted emission.
|
||||||
|
# @TEST_EDGE create_before_append -> error raised.
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ss_tools.agent._run_tracker import RunTracker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tracker():
|
||||||
|
return RunTracker(base_url="http://test-backend", service_jwt="test-jwt")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTrackerCreate:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_initializes_run(self, tracker):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": "run-1", "status": "RUNNING",
|
||||||
|
"last_sequence": 1,
|
||||||
|
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||||
|
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||||
|
"context_snapshot": {}, "current_stage": "context",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"stages": [], "drafts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "post", return_value=mock_response):
|
||||||
|
run_id = await tracker.create(
|
||||||
|
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||||
|
"route": "/dashboards/42", "contextVersion": 2,
|
||||||
|
"intent": "build_dashboard_test_scenario"},
|
||||||
|
)
|
||||||
|
assert run_id == "run-1"
|
||||||
|
assert tracker.run_id == "run-1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_sets_sequence_from_response(self, tracker):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"id": "run-2", "status": "RUNNING", "last_sequence": 3,
|
||||||
|
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||||
|
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||||
|
"context_snapshot": {}, "current_stage": "context",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"stages": [], "drafts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "post", return_value=mock_response):
|
||||||
|
await tracker.create(
|
||||||
|
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||||
|
"route": "/dashboards/42", "contextVersion": 2,
|
||||||
|
"intent": "build_dashboard_test_scenario"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_event_before_create_raises(self, tracker):
|
||||||
|
with pytest.raises(RuntimeError, match="Must call create"):
|
||||||
|
await tracker.append_event("progress", "inspect", "completed")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_emit_progress_before_create_raises(self, tracker):
|
||||||
|
with pytest.raises(RuntimeError, match="Must call create"):
|
||||||
|
await tracker.emit_progress("inspect", "completed")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTrackerEvents:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_append_event_increments_sequence(self, tracker):
|
||||||
|
# Mock create
|
||||||
|
mock_create = MagicMock()
|
||||||
|
mock_create.status_code = 201
|
||||||
|
mock_create.json.return_value = {
|
||||||
|
"id": "run-1", "status": "RUNNING", "last_sequence": 1,
|
||||||
|
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||||
|
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||||
|
"context_snapshot": {}, "current_stage": "context",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"stages": [], "drafts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.status_code = 201
|
||||||
|
mock_event.json.return_value = {
|
||||||
|
"id": "evt-1", "run_id": "run-1", "sequence": 2,
|
||||||
|
"event_type": "progress", "stage": "inspect", "status": "completed",
|
||||||
|
"occurred_at": "2026-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "post") as mock_post:
|
||||||
|
mock_post.side_effect = [mock_create, mock_event]
|
||||||
|
await tracker.create(
|
||||||
|
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||||
|
"route": "/dashboards/42", "contextVersion": 2,
|
||||||
|
"intent": "build_dashboard_test_scenario"},
|
||||||
|
)
|
||||||
|
result = await tracker.append_event("progress", "inspect", "completed")
|
||||||
|
assert result["sequence"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_emit_terminal_maps_status(self, tracker):
|
||||||
|
mock_create = MagicMock()
|
||||||
|
mock_create.status_code = 201
|
||||||
|
mock_create.json.return_value = {
|
||||||
|
"id": "run-1", "status": "RUNNING", "last_sequence": 1,
|
||||||
|
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||||
|
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||||
|
"context_snapshot": {}, "current_stage": "context",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"stages": [], "drafts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.status_code = 201
|
||||||
|
mock_event.json.return_value = {"id": "evt-1", "sequence": 2}
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "post") as mock_post:
|
||||||
|
mock_post.side_effect = [mock_create, mock_event]
|
||||||
|
await tracker.create(
|
||||||
|
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||||
|
"route": "/dashboards/42", "contextVersion": 2,
|
||||||
|
"intent": "build_dashboard_test_scenario"},
|
||||||
|
)
|
||||||
|
await tracker.emit_terminal("COMPLETED")
|
||||||
|
# Verify the POST body was sent with mapped status
|
||||||
|
call_args = mock_post.call_args_list[-1]
|
||||||
|
sent_body = call_args[1].get("json", {})
|
||||||
|
assert sent_body.get("status") == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTrackerErrorHandling:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_error_propagates(self, tracker):
|
||||||
|
mock_create = MagicMock()
|
||||||
|
mock_create.status_code = 201
|
||||||
|
mock_create.json.return_value = {
|
||||||
|
"id": "run-1", "status": "RUNNING", "last_sequence": 1,
|
||||||
|
"user_id": "user-1", "intent": "dashboard_scenario_build",
|
||||||
|
"trigger": "manual", "dashboard_id": "42", "environment_id": "dev",
|
||||||
|
"context_snapshot": {}, "current_stage": "context",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"stages": [], "drafts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_error = MagicMock()
|
||||||
|
mock_error.status_code = 500
|
||||||
|
mock_error.response = MagicMock()
|
||||||
|
mock_error.response.text = "Internal error"
|
||||||
|
mock_error.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Server error", request=MagicMock(), response=mock_error.response)
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "post") as mock_post:
|
||||||
|
mock_post.side_effect = [mock_create, mock_error]
|
||||||
|
await tracker.create(
|
||||||
|
context={"objectType": "dashboard", "objectId": "42", "envId": "dev",
|
||||||
|
"route": "/dashboards/42", "contextVersion": 2,
|
||||||
|
"intent": "build_dashboard_test_scenario"},
|
||||||
|
)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await tracker.append_event("progress", "inspect", "completed")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_cleans_up(self, tracker):
|
||||||
|
# Set a mocked client and verify close cleans up
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.aclose = AsyncMock()
|
||||||
|
tracker._client = mock_client
|
||||||
|
await tracker.close()
|
||||||
|
mock_client.aclose.assert_called_once()
|
||||||
|
assert tracker._client is None
|
||||||
|
# #endregion TestAgent.RunTracker
|
||||||
142
backend/src/services/agent_runs/artifacts.py
Normal file
142
backend/src/services/agent_runs/artifacts.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# backend/src/services/agent_runs/artifacts.py
|
||||||
|
# #region Services.AgentRuns.Artifacts [C:4] [TYPE Module] [SEMANTICS agent-run,draft,storage,artifact]
|
||||||
|
# @defgroup AgentRuns Artifact storage — out-of-repository, opaque refs, path safety.
|
||||||
|
# @BRIEF File-based draft storage with opaque content_ref. Drafts never touch the Git worktree.
|
||||||
|
# @LAYER Service
|
||||||
|
# @RELATION DEPENDS_ON -> [Models.AgentRun]
|
||||||
|
# @RATIONALE Opaque storage references separate previewable content from Git state.
|
||||||
|
# @REJECTED Writing drafts directly into the Git worktree — dirties repository before HITL approval.
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from ss_tools.shared.cot_logger import log
|
||||||
|
|
||||||
|
|
||||||
|
class DraftStorage:
|
||||||
|
"""File-based storage for draft artifacts outside the Git repository."""
|
||||||
|
|
||||||
|
def __init__(self, storage_root: str | Path | None = None):
|
||||||
|
root = Path(storage_root) if storage_root else Path("data/drafts")
|
||||||
|
self._root = root.resolve()
|
||||||
|
if not self._root.exists():
|
||||||
|
self._root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ── Store ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def store(self, run_id: str, sha256: str, data: bytes) -> str:
|
||||||
|
"""Store draft bytes and return an opaque content_ref.
|
||||||
|
|
||||||
|
The content_ref is NEVER a filesystem path returned to clients.
|
||||||
|
"""
|
||||||
|
content_ref = f"draft:{run_id}:{sha256}"
|
||||||
|
file_path = self._ref_to_path(content_ref)
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_bytes(data)
|
||||||
|
|
||||||
|
log("AgentRuns.Artifacts", "REASON", "Draft stored",
|
||||||
|
{"run_id": run_id, "sha256": sha256[:12], "size": len(data)})
|
||||||
|
return content_ref
|
||||||
|
|
||||||
|
def store_stream(self, run_id: str, sha256: str, stream: IO[bytes]) -> str:
|
||||||
|
"""Store from a file-like stream. Computes sha256 on the fly."""
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
content_ref = f"draft:{run_id}:{sha256}"
|
||||||
|
file_path = self._ref_to_path(content_ref)
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hasher.update(chunk)
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
actual_hash = hasher.hexdigest()
|
||||||
|
if actual_hash != sha256:
|
||||||
|
file_path.unlink()
|
||||||
|
raise ValueError(
|
||||||
|
f"sha256 mismatch: expected {sha256[:12]}, got {actual_hash[:12]}")
|
||||||
|
|
||||||
|
log("AgentRuns.Artifacts", "REFLECT", "Draft stored from stream",
|
||||||
|
{"run_id": run_id, "sha256": sha256[:12], "size": file_path.stat().st_size})
|
||||||
|
return content_ref
|
||||||
|
|
||||||
|
# ── Retrieve ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def retrieve(self, content_ref: str) -> bytes | None:
|
||||||
|
"""Retrieve draft bytes by opaque content_ref.
|
||||||
|
|
||||||
|
Returns None if the draft does not exist.
|
||||||
|
"""
|
||||||
|
file_path = self._ref_to_path(content_ref)
|
||||||
|
if not file_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
log("AgentRuns.Artifacts", "REASON", "Draft retrieved",
|
||||||
|
{"content_ref": content_ref[:30]})
|
||||||
|
return file_path.read_bytes()
|
||||||
|
|
||||||
|
def retrieve_stream(self, content_ref: str) -> IO[bytes] | None:
|
||||||
|
"""Retrieve draft as a file-like stream."""
|
||||||
|
file_path = self._ref_to_path(content_ref)
|
||||||
|
if not file_path.exists():
|
||||||
|
return None
|
||||||
|
return open(file_path, "rb")
|
||||||
|
|
||||||
|
# ── Cleanup ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def delete(self, content_ref: str) -> bool:
|
||||||
|
"""Delete stored draft bytes. Returns True if file was removed."""
|
||||||
|
file_path = self._ref_to_path(content_ref)
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
log("AgentRuns.Artifacts", "REASON", "Draft deleted",
|
||||||
|
{"content_ref": content_ref[:30]})
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_run(self, run_id: str) -> int:
|
||||||
|
"""Remove all drafts for a run. Returns count of deleted files."""
|
||||||
|
run_dir = self._root / run_id
|
||||||
|
count = 0
|
||||||
|
if run_dir.exists():
|
||||||
|
for f in run_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
count += 1
|
||||||
|
if not list(run_dir.iterdir()):
|
||||||
|
run_dir.rmdir()
|
||||||
|
return count
|
||||||
|
|
||||||
|
# ── Internal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ref_to_path(self, content_ref: str) -> Path:
|
||||||
|
"""Map opaque content_ref to a filesystem path (NEVER exposed to clients)."""
|
||||||
|
# Sanity: ensure no path traversal
|
||||||
|
if ".." in content_ref or "/" in content_ref:
|
||||||
|
raise ValueError("content_ref must not contain path separators")
|
||||||
|
parts = content_ref.split(":", 2)
|
||||||
|
if len(parts) != 3 or parts[0] != "draft":
|
||||||
|
raise ValueError(f"invalid content_ref format: {content_ref[:40]}")
|
||||||
|
run_id, sha256 = parts[1], parts[2]
|
||||||
|
# Validate sha256 format
|
||||||
|
if len(sha256) != 64 or not all(c in "0123456789abcdef" for c in sha256):
|
||||||
|
raise ValueError("invalid sha256 in content_ref")
|
||||||
|
return self._root / run_id / sha256
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton for app-wide use ─────────────────────────────────
|
||||||
|
|
||||||
|
_draft_storage: DraftStorage | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_draft_storage(storage_root: str | None = None) -> DraftStorage:
|
||||||
|
global _draft_storage
|
||||||
|
if _draft_storage is None:
|
||||||
|
root = storage_root or os.environ.get("DRAFT_STORAGE_ROOT", "data/drafts")
|
||||||
|
_draft_storage = DraftStorage(root)
|
||||||
|
return _draft_storage
|
||||||
|
# #endregion Services.AgentRuns.Artifacts
|
||||||
Reference in New Issue
Block a user