Task 007 implement agent job queue and fake runner path

This commit is contained in:
2026-05-21 19:28:37 +03:00
parent ff1a2f78c0
commit 4fd837349f
12 changed files with 956 additions and 7 deletions
@@ -9,6 +9,10 @@ from typing import Any
from uuid import UUID, uuid4
from src.domain.contracts import (
AgentJobErrorCategory,
AgentJobStatus,
AgentJobSummary,
AgentJobType,
ArticleSummary,
PublishingRules,
PublishingStatus,
@@ -36,6 +40,7 @@ class BackendRepository:
self.users = UsersRepository(self)
self.target_sites = TargetSitesRepository(self)
self.articles = ArticlesRepository(self)
self.agent_jobs = AgentJobsRepository(self)
self.script_config_versions = ScriptConfigVersionsRepository(self)
self.script_config_version_events = ScriptConfigVersionAuditEventsRepository(self)
self.schema = SchemaRepository(self)
@@ -588,6 +593,233 @@ class ArticlesRepository:
return [_workflow_event_from_row(row) for row in rows]
class AgentJobsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create(
self,
*,
article_id: UUID | None,
parent_job_id: UUID | None,
attempt: int,
job_type: AgentJobType,
agent_profile: str,
status: AgentJobStatus,
input_files: list[JsonObject],
queued_at: datetime,
) -> AgentJobSummary:
job_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
sql = f"""
INSERT INTO agent_jobs (
id,
article_id,
parent_job_id,
attempt,
job_type,
agent_profile,
status,
input_files,
queued_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder}
)
"""
with self._repository.connection() as connection:
connection.execute(
sql,
(
str(job_id),
_uuid_value(article_id),
_uuid_value(parent_job_id),
attempt,
job_type.value,
agent_profile,
status.value,
_json_value(input_files),
_datetime_value(queued_at),
),
)
return self.get(job_id)
def list(self) -> list[AgentJobSummary]:
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM agent_jobs
ORDER BY queued_at DESC
"""
).fetchall()
return [_agent_job_summary_from_row(row) for row in rows]
def get(self, job_id: UUID) -> AgentJobSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM agent_jobs
WHERE id = {placeholder}
""",
(str(job_id),),
).fetchone()
if row is None:
raise LookupError(f"Agent job not found: {job_id}")
return _agent_job_summary_from_row(row)
def claim_next_queued(self, *, started_at: datetime) -> AgentJobSummary | None:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT id
FROM agent_jobs
WHERE status = {placeholder}
ORDER BY queued_at
LIMIT 1
""",
(AgentJobStatus.QUEUED.value,),
).fetchone()
if row is None:
return None
job_id = _row_value(row, "id")
connection.execute(
f"""
UPDATE agent_jobs
SET status = {placeholder}, started_at = {placeholder}
WHERE id = {placeholder} AND status = {placeholder}
""",
(
AgentJobStatus.RUNNING.value,
_datetime_value(started_at),
str(job_id),
AgentJobStatus.QUEUED.value,
),
)
return self.get(UUID(str(job_id)))
def complete(
self,
*,
job_id: UUID,
status: AgentJobStatus,
workspace_path: str | None,
output_files: list[JsonObject],
error_category: AgentJobErrorCategory | None,
error_message: str | None,
stdout: str,
stderr: str,
exit_code: int | None,
duration_ms: int | None,
finished_at: datetime,
) -> AgentJobSummary:
existing = self.get(job_id)
if existing.status == AgentJobStatus.CANCELLED:
return existing
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE agent_jobs
SET
status = {placeholder},
workspace_path = {placeholder},
output_files = {placeholder}{json_cast},
error_category = {placeholder},
error_message = {placeholder},
stdout = {placeholder},
stderr = {placeholder},
exit_code = {placeholder},
duration_ms = {placeholder},
finished_at = {placeholder}
WHERE id = {placeholder}
""",
(
status.value,
workspace_path,
_json_value(output_files),
_agent_error_category_value(error_category),
error_message,
stdout,
stderr,
exit_code,
duration_ms,
_datetime_value(finished_at),
str(job_id),
),
)
return self.get(job_id)
def cancel(self, *, job_id: UUID, finished_at: datetime) -> AgentJobSummary:
existing = self.get(job_id)
if existing.status in {AgentJobStatus.SUCCEEDED, AgentJobStatus.FAILED}:
return existing
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE agent_jobs
SET
status = {placeholder},
error_message = {placeholder},
finished_at = {placeholder}
WHERE id = {placeholder}
""",
(
AgentJobStatus.CANCELLED.value,
"Cancelled",
_datetime_value(finished_at),
str(job_id),
),
)
return self.get(job_id)
def _select_columns(self) -> str:
return """
id,
article_id,
parent_job_id,
attempt,
job_type,
agent_profile,
status,
workspace_path,
input_files,
output_files,
error_category,
error_message,
stdout,
stderr,
exit_code,
duration_ms,
queued_at,
started_at,
finished_at
"""
class ScriptConfigVersionsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
@@ -1015,6 +1247,30 @@ def _workflow_event_from_row(row: Any) -> WorkflowEventSummary:
)
def _agent_job_summary_from_row(row: Any) -> AgentJobSummary:
return AgentJobSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
parent_job_id=_row_value(row, "parent_job_id"),
attempt=_row_value(row, "attempt"),
job_type=_row_value(row, "job_type"),
agent_profile=_row_value(row, "agent_profile"),
status=_row_value(row, "status"),
workspace_path=_row_value(row, "workspace_path"),
input_files=_json_from_row(row, "input_files"),
output_files=_json_from_row(row, "output_files"),
error_category=_row_value(row, "error_category"),
error_message=_row_value(row, "error_message"),
stdout=_row_value(row, "stdout"),
stderr=_row_value(row, "stderr"),
exit_code=_row_value(row, "exit_code"),
duration_ms=_row_value(row, "duration_ms"),
queued_at=_row_value(row, "queued_at"),
started_at=_row_value(row, "started_at"),
finished_at=_row_value(row, "finished_at"),
)
def _script_config_version_event_from_row(row: Any) -> dict[str, Any]:
return {
"id": _row_value(row, "id"),
@@ -1079,3 +1335,9 @@ def _article_status_value(value: ArticleWorkflowStatus | None) -> str | None:
if value is None:
return None
return value.value
def _agent_error_category_value(value: AgentJobErrorCategory | None) -> str | None:
if value is None:
return None
return value.value