Add reviewer, artifact store, and paginated queries

This commit is contained in:
2026-07-03 21:44:46 +03:00
parent 50178a4e69
commit 648f0f9024
17 changed files with 441 additions and 16 deletions
+86
View File
@@ -0,0 +1,86 @@
from pathlib import Path
from ai_orchestrator.application.ports import (
ArtifactRecord,
ModelInvocationRecord,
ToolInvocationRecord,
)
from ai_orchestrator.config import AppSettings
from ai_orchestrator.domain.events import DomainEvent
from ai_orchestrator.infrastructure.storage.factory import create_storage_bundle
def test_sqlite_paginated_event_and_invocation_queries(tmp_path: Path) -> None:
db_path = tmp_path / "queries.sqlite3"
storage = create_storage_bundle(
AppSettings(storage_backend="sqlite", database_url=f"sqlite:///{db_path.as_posix()}")
)
for index in range(5):
storage.event_store.append(
DomainEvent(event_type="test_event", task_id="task_1", payload={"index": index})
)
storage.invocation_store.save_model_invocation(
ModelInvocationRecord(
invocation_id=f"minv_{index}",
task_id="task_1",
node_id=None,
slot="weak",
provider="local",
model="mock",
status="success",
request={},
response={"message": {"content": "ok"}},
)
)
storage.invocation_store.save_tool_invocation(
ToolInvocationRecord(
invocation_id=f"tinv_{index}",
task_id="task_1",
node_id=None,
source_type="mcp",
source_id="one_c",
tool_name="one_c.run_sql",
status="success",
request={},
response={"content": {"rows": []}},
)
)
events_page = storage.event_store.list_by_task_paginated("task_1", limit=2, offset=1)
model_page = storage.invocation_store.list_model_invocations_paginated(
"task_1", limit=2, offset=2
)
tool_page = storage.invocation_store.list_tool_invocations_paginated(
"task_1", limit=3, offset=1
)
assert events_page.total == 5
assert len(events_page.items) == 2
assert model_page.total == 5
assert len(model_page.items) == 2
assert tool_page.total == 5
assert len(tool_page.items) == 3
def test_sqlite_artifact_store_persists_and_paginates(tmp_path: Path) -> None:
db_path = tmp_path / "artifacts.sqlite3"
storage = create_storage_bundle(
AppSettings(storage_backend="sqlite", database_url=f"sqlite:///{db_path.as_posix()}")
)
for index in range(4):
storage.artifact_store.save_artifact(
ArtifactRecord(
artifact_id=f"art_{index}",
task_id="task_1",
node_id=None,
artifact_type="file",
storage_uri=f"file:///artifact/{index}",
metadata={"index": index},
)
)
page = storage.artifact_store.list_by_task_paginated("task_1", limit=2, offset=1)
assert page.total == 4
assert len(page.items) == 2
assert page.items[0].storage_uri == "file:///artifact/1"
+5
View File
@@ -32,6 +32,7 @@ from ai_orchestrator.infrastructure.mcp_client import McpHttpClient, McpToolGate
from ai_orchestrator.infrastructure.model_router import StaticMockModelProvider
from ai_orchestrator.infrastructure.policy import StaticProjectPolicyEvaluator
from ai_orchestrator.infrastructure.storage.memory import (
InMemoryArtifactStore,
InMemoryConfirmationRepository,
InMemoryEventStore,
InMemoryGraphRepository,
@@ -90,11 +91,13 @@ def test_worker_gateway_result_becomes_artifact_ready_invocation() -> None:
worker_repository = InMemoryWorkerRepository()
event_store = InMemoryEventStore()
invocation_store = InMemoryInvocationStore()
artifact_store = InMemoryArtifactStore()
worker_service = WorkerService(worker_repository=worker_repository, event_store=event_store)
gateway = CapabilityAwareWorkerGateway(
worker_repository=worker_repository,
event_store=event_store,
invocation_store=invocation_store,
artifact_store=artifact_store,
connection_manager=InMemoryWorkerConnectionManager(),
)
response = worker_service.register(
@@ -135,6 +138,7 @@ def test_worker_gateway_result_becomes_artifact_ready_invocation() -> None:
invocation_store.list_tool_invocations("task_1")[-1].response["artifacts"][0]["path"]
== "D:/artifact.txt"
)
assert artifact_store.list_by_task("task_1")[0].storage_uri == "D:/artifact.txt"
def test_model_and_mcp_paths_can_run_in_same_runtime_context() -> None:
@@ -206,6 +210,7 @@ def test_model_and_mcp_paths_can_run_in_same_runtime_context() -> None:
project_configs={"default": project},
client=McpHttpClient(transport=httpx.MockTransport(handler)),
invocation_store=invocation_store,
artifact_store=InMemoryArtifactStore(),
)
model_result = router.run(
+9 -2
View File
@@ -1,3 +1,5 @@
from datetime import UTC, datetime
import httpx
from ai_orchestrator.application.services.execution import (
@@ -32,6 +34,7 @@ from ai_orchestrator.infrastructure.mcp_client import McpHttpClient, McpToolGate
from ai_orchestrator.infrastructure.model_router import StaticMockModelProvider
from ai_orchestrator.infrastructure.policy import StaticProjectPolicyEvaluator
from ai_orchestrator.infrastructure.storage.memory import (
InMemoryArtifactStore,
InMemoryConfirmationRepository,
InMemoryEventStore,
InMemoryGraphRepository,
@@ -223,6 +226,7 @@ def test_smoke_policy_confirm_and_full_auto() -> None:
def test_smoke_mcp_and_worker_results_are_stored_and_finalizer_runs() -> None:
invocation_store = InMemoryInvocationStore()
artifact_store = InMemoryArtifactStore()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
@@ -244,6 +248,7 @@ def test_smoke_mcp_and_worker_results_are_stored_and_finalizer_runs() -> None:
project_configs={"default": _project_config()},
client=McpHttpClient(transport=httpx.MockTransport(handler)),
invocation_store=invocation_store,
artifact_store=artifact_store,
)
worker_repository = InMemoryWorkerRepository()
event_store = InMemoryEventStore()
@@ -252,6 +257,7 @@ def test_smoke_mcp_and_worker_results_are_stored_and_finalizer_runs() -> None:
worker_repository=worker_repository,
event_store=event_store,
invocation_store=invocation_store,
artifact_store=artifact_store,
connection_manager=InMemoryWorkerConnectionManager(),
)
worker = worker_service.register(
@@ -279,8 +285,8 @@ def test_smoke_mcp_and_worker_results_are_stored_and_finalizer_runs() -> None:
task_id="task_1",
tool="file.read",
status="success",
started_at=__import__("datetime").datetime.now(__import__("datetime").UTC),
finished_at=__import__("datetime").datetime.now(__import__("datetime").UTC),
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
duration_ms=1,
result={"content": "ok"},
),
@@ -318,4 +324,5 @@ def test_smoke_mcp_and_worker_results_are_stored_and_finalizer_runs() -> None:
assert queued.status == "queued"
assert tool_result.status == "success"
assert len(invocation_store.list_tool_invocations("task_1")) >= 2
assert artifact_store.list_by_task("task_1") == []
assert graph.nodes[-1].output_data["status"] == "completed"
+5 -1
View File
@@ -10,7 +10,10 @@ from ai_orchestrator.config import (
ProjectMetadata,
)
from ai_orchestrator.infrastructure.mcp_client import McpHttpClient, McpToolGateway
from ai_orchestrator.infrastructure.storage.memory import InMemoryInvocationStore
from ai_orchestrator.infrastructure.storage.memory import (
InMemoryArtifactStore,
InMemoryInvocationStore,
)
def test_mcp_tool_gateway_normalizes_result_and_persists_invocation() -> None:
@@ -47,6 +50,7 @@ def test_mcp_tool_gateway_normalizes_result_and_persists_invocation() -> None:
project_configs={"default": project},
client=McpHttpClient(transport=transport),
invocation_store=invocation_store,
artifact_store=InMemoryArtifactStore(),
)
result = gateway.call(
+29
View File
@@ -0,0 +1,29 @@
from ai_orchestrator.application.services.execution import StructuredReviewer
from ai_orchestrator.domain.enums import NodeType
from ai_orchestrator.domain.models import ExecutionNode, Task
def test_structured_reviewer_accepts_valid_output() -> None:
reviewer = StructuredReviewer()
task = Task(project_id="default", goal="Review", inputs={})
node = ExecutionNode(task_id=task.task_id, node_type=NodeType.PLANNER, input_data={})
reviewed = reviewer.review(
task=task,
node=node,
output_data={"status": "planned", "goal": "Review"},
)
assert reviewed["review"]["status"] == "accepted"
assert reviewed["task_id"] == task.task_id
def test_structured_reviewer_marks_missing_status_as_review_failed() -> None:
reviewer = StructuredReviewer()
task = Task(project_id="default", goal="Review", inputs={})
node = ExecutionNode(task_id=task.task_id, node_type=NodeType.PLANNER, input_data={})
reviewed = reviewer.review(task=task, node=node, output_data={"goal": "Review"})
assert reviewed["status"] == "review_failed"
assert reviewed["review"]["status"] == "rejected"
+8 -4
View File
@@ -10,6 +10,7 @@ from ai_orchestrator.application.services.workers import (
WorkerService,
)
from ai_orchestrator.infrastructure.storage.memory import (
InMemoryArtifactStore,
InMemoryEventStore,
InMemoryInvocationStore,
InMemoryWorkerRepository,
@@ -22,22 +23,25 @@ def _build_gateway() -> tuple[
InMemoryWorkerRepository,
InMemoryEventStore,
InMemoryInvocationStore,
InMemoryArtifactStore,
]:
worker_repository = InMemoryWorkerRepository()
event_store = InMemoryEventStore()
invocation_store = InMemoryInvocationStore()
artifact_store = InMemoryArtifactStore()
service = WorkerService(worker_repository=worker_repository, event_store=event_store)
gateway = CapabilityAwareWorkerGateway(
worker_repository=worker_repository,
event_store=event_store,
invocation_store=invocation_store,
artifact_store=artifact_store,
connection_manager=InMemoryWorkerConnectionManager(),
)
return gateway, service, worker_repository, event_store, invocation_store
return gateway, service, worker_repository, event_store, invocation_store, artifact_store
def test_worker_gateway_dispatches_and_completes_command() -> None:
gateway, service, worker_repository, event_store, invocation_store = _build_gateway()
gateway, service, worker_repository, event_store, invocation_store, _ = _build_gateway()
response = service.register(
RegisterWorkerRequest(
worker_id="worker_home_pc",
@@ -80,7 +84,7 @@ def test_worker_gateway_dispatches_and_completes_command() -> None:
def test_worker_gateway_rejects_unsupported_capability() -> None:
gateway, service, _, _, _ = _build_gateway()
gateway, service, _, _, _, _ = _build_gateway()
response = service.register(
RegisterWorkerRequest(
worker_id="worker_home_pc",
@@ -103,7 +107,7 @@ def test_worker_gateway_rejects_unsupported_capability() -> None:
def test_worker_service_heartbeat_and_stale_tracking() -> None:
_, service, worker_repository, event_store, _ = _build_gateway()
_, service, worker_repository, event_store, _, _ = _build_gateway()
response = service.register(
RegisterWorkerRequest(
worker_id="worker_home_pc",