feat(036): artifacts.py storage + tracker tests + bugfixes

This commit is contained in:
2026-07-28 11:47:54 +03:00
parent 8e395752f9
commit 7095497995
4 changed files with 323 additions and 2 deletions

View File

@@ -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 ──

View File

@@ -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)

View 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

View 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