forkflux-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
forkflux_api/config.py ADDED
@@ -0,0 +1,67 @@
1
+ import os
2
+ import platform
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from urllib.parse import urlsplit
6
+
7
+ from pydantic import Field, PostgresDsn, field_validator
8
+ from pydantic_core import MultiHostUrl
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ def _default_data_dir(app_name: str) -> Path:
13
+ system = platform.system().lower()
14
+
15
+ if system == "darwin":
16
+ return Path.home() / "Library" / "Application Support" / app_name
17
+
18
+ if system == "windows":
19
+ appdata = os.environ.get("APPDATA")
20
+ if appdata:
21
+ return Path(appdata) / app_name
22
+ return Path.home() / "AppData" / "Roaming" / app_name
23
+
24
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
25
+ if xdg_data_home:
26
+ return Path(xdg_data_home) / app_name
27
+
28
+ return Path.home() / ".local" / "share" / app_name
29
+
30
+
31
+ def _default_sqlite_db_path() -> Path:
32
+ return _default_data_dir("forkflux") / "forkflux.db"
33
+
34
+
35
+ def _build_default_database_url() -> str:
36
+ sqlite_path = _default_sqlite_db_path()
37
+ sqlite_path.parent.mkdir(parents=True, exist_ok=True)
38
+ return f"sqlite+aiosqlite:///{sqlite_path}"
39
+
40
+
41
+ class Settings(BaseSettings):
42
+ model_config = SettingsConfigDict(
43
+ env_file=".env", env_file_encoding="utf-8", extra="ignore", env_nested_max_split=1, env_nested_delimiter="_"
44
+ )
45
+
46
+ database_url: str = Field(default_factory=_build_default_database_url)
47
+
48
+ @field_validator("database_url", mode="before")
49
+ @classmethod
50
+ def validate_database_url(cls, value: str | MultiHostUrl) -> str:
51
+ if isinstance(value, MultiHostUrl):
52
+ return value.__str__()
53
+
54
+ scheme = urlsplit(value).scheme
55
+ if scheme == "sqlite+aiosqlite":
56
+ return value
57
+
58
+ if scheme.startswith("postgresql"):
59
+ PostgresDsn(value)
60
+ return value
61
+
62
+ raise ValueError("Unsupported database URL scheme. Use sqlite+aiosqlite or postgresql(+driver).")
63
+
64
+
65
+ @lru_cache()
66
+ def get_settings() -> Settings:
67
+ return Settings()
@@ -0,0 +1,70 @@
1
+ from contextlib import asynccontextmanager
2
+ from datetime import datetime, timezone
3
+ from typing import AsyncGenerator
4
+
5
+ from sqlalchemy import DateTime
6
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_engine_from_config, async_sessionmaker
7
+ from sqlalchemy.orm import DeclarativeBase, declared_attr
8
+ from sqlalchemy.types import TypeDecorator
9
+
10
+ from forkflux_api.config import get_settings
11
+
12
+
13
+ def get_async_engine(**overrides: str) -> AsyncEngine:
14
+ settings = get_settings()
15
+ config: dict[str, str] = {
16
+ "sqlalchemy.url": settings.database_url,
17
+ "sqlalchemy.echo": overrides.get("sqlalchemy.echo", "false"),
18
+ }
19
+
20
+ config.update(overrides)
21
+
22
+ return async_engine_from_config(config, prefix="sqlalchemy.", pool_pre_ping=True)
23
+
24
+
25
+ @asynccontextmanager
26
+ async def session_manager() -> AsyncGenerator[AsyncSession, None]:
27
+ session_factory = async_sessionmaker(bind=get_async_engine(), expire_on_commit=False)
28
+ async with session_factory() as session:
29
+ try:
30
+ yield session
31
+ await session.commit()
32
+ except Exception:
33
+ await session.rollback()
34
+ raise
35
+
36
+
37
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
38
+ async with session_manager() as session:
39
+ yield session
40
+
41
+
42
+ class Base(DeclarativeBase):
43
+ @declared_attr.directive
44
+ def __tablename__(cls) -> str: # pragma: no cover - simple convention helper
45
+ return cls.__name__.lower()
46
+
47
+ __abstract__ = True
48
+
49
+
50
+ class UTCDateTime(TypeDecorator[datetime]):
51
+ impl = DateTime(timezone=True)
52
+ cache_ok = True
53
+
54
+ def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
55
+ if value is None:
56
+ return None
57
+
58
+ if value.tzinfo is None:
59
+ return value.replace(tzinfo=timezone.utc)
60
+
61
+ return value.astimezone(timezone.utc)
62
+
63
+ def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
64
+ if value is None:
65
+ return None
66
+
67
+ if value.tzinfo is None:
68
+ return value.replace(tzinfo=timezone.utc)
69
+
70
+ return value.astimezone(timezone.utc)
@@ -0,0 +1,87 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+ from fastapi import Depends, HTTPException, Request, status
5
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from forkflux_api.agents.exceptions import AgentApiTokenNotFoundError, AgentIdentityNotFoundError
9
+ from forkflux_api.agents.models import AgentIdentity
10
+ from forkflux_api.agents.respositories import AgentApiTokenRepository, AgentIdentityRepository
11
+ from forkflux_api.agents.services import AgentApiTokenService, AgentIdentityService
12
+ from forkflux_api.database import get_session
13
+
14
+ bearer_scheme = HTTPBearer(auto_error=False)
15
+
16
+
17
+ def get_trace_id(request: Request) -> str:
18
+ return request.state.trace_id
19
+
20
+
21
+ def get_agent_api_token_repo(
22
+ session: AsyncSession = Depends(get_session), trace_id: str = Depends(get_trace_id)
23
+ ) -> AgentApiTokenRepository:
24
+ return AgentApiTokenRepository(session=session, trace_id=trace_id)
25
+
26
+
27
+ def get_agent_api_token_service(
28
+ repository: AgentApiTokenRepository = Depends(get_agent_api_token_repo), trace_id: str = Depends(get_trace_id)
29
+ ) -> AgentApiTokenService:
30
+ return AgentApiTokenService(agent_api_token_repo=repository, trace_id=trace_id)
31
+
32
+
33
+ def get_agent_identity_repo(
34
+ session: AsyncSession = Depends(get_session), trace_id: str = Depends(get_trace_id)
35
+ ) -> AgentIdentityRepository:
36
+ return AgentIdentityRepository(session=session, trace_id=trace_id)
37
+
38
+
39
+ def get_agent_identity_service(
40
+ repository: AgentIdentityRepository = Depends(get_agent_identity_repo), trace_id: str = Depends(get_trace_id)
41
+ ) -> AgentIdentityService:
42
+ return AgentIdentityService(agent_identity_repo=repository, trace_id=trace_id)
43
+
44
+
45
+ async def verify_token(
46
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
47
+ service: AgentApiTokenService = Depends(get_agent_api_token_service),
48
+ ) -> dict[str, int]:
49
+ if credentials is None:
50
+ raise HTTPException(
51
+ status_code=status.HTTP_403_FORBIDDEN,
52
+ detail="Not authenticated",
53
+ )
54
+
55
+ token = credentials.credentials
56
+ provided_hash = hashlib.sha256(token.encode()).hexdigest()
57
+
58
+ try:
59
+ entity = await service.get_token(token_hash=provided_hash)
60
+
61
+ if not hmac.compare_digest(provided_hash, entity.token_hash):
62
+ raise AgentApiTokenNotFoundError
63
+
64
+ return {"agent_id": entity.agent_id}
65
+ except AgentApiTokenNotFoundError:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_401_UNAUTHORIZED,
68
+ detail="Invalid or expired token",
69
+ headers={"WWW-Authenticate": "Bearer"},
70
+ )
71
+
72
+
73
+ async def get_current_agent(
74
+ payload: dict[str, int] = Depends(verify_token),
75
+ agent_service: AgentIdentityService = Depends(get_agent_identity_service),
76
+ ) -> AgentIdentity:
77
+ agent_id = payload["agent_id"]
78
+
79
+ try:
80
+ agent = await agent_service.get_by_id(agent_id)
81
+ except AgentIdentityNotFoundError:
82
+ raise HTTPException(
83
+ status_code=status.HTTP_401_UNAUTHORIZED,
84
+ detail="Agent not found",
85
+ )
86
+
87
+ return agent
@@ -0,0 +1,11 @@
1
+ from typing import Any, Literal
2
+
3
+
4
+ class BaseValidationError(Exception):
5
+ code: str
6
+ msg: str
7
+
8
+ def __init__(self, field_name: str, value: Any = None, loc: Literal["body", "query", "header", "path"] = "body"):
9
+ self.field_name = field_name
10
+ self.value = value
11
+ self.loc = loc
@@ -0,0 +1,26 @@
1
+ from forkflux_api.exceptions import BaseValidationError
2
+
3
+
4
+ class ParentJobValidationError(BaseValidationError):
5
+ code = "parent_job.invalid"
6
+ msg = "Parent job is invalid."
7
+
8
+
9
+ class TargetRoleValidationError(BaseValidationError):
10
+ code = "target_role.invalid"
11
+ msg = "Target role is invalid."
12
+
13
+
14
+ class HandoffJobClaimValidationError(BaseValidationError):
15
+ code = "handoff_job_claim.invalid"
16
+ msg = "Handoff job claim is invalid."
17
+
18
+
19
+ class HandoffJobIdentityValidationError(BaseValidationError):
20
+ code = "handoff_job_identity.invalid"
21
+ msg = "Handoff job identity is invalid."
22
+
23
+
24
+ class HandoffJobStatusValidationError(BaseValidationError):
25
+ code = "handoff_job_status.invalid"
26
+ msg = "Handoff job status transition is invalid."
@@ -0,0 +1,28 @@
1
+ from enum import Enum, IntEnum
2
+
3
+
4
+ class JobStatusEnum(str, Enum):
5
+ PUBLISHED = "published"
6
+ CLAIMED = "claimed"
7
+ IN_PROGRESS = "in_progress"
8
+ COMPLETED = "completed"
9
+ FAILED = "failed"
10
+ CANCELLED = "cancelled"
11
+
12
+
13
+ class JobPriorityEnum(IntEnum):
14
+ LOW = 10
15
+ NORMAL = 20
16
+ HIGH = 30
17
+ URGENT = 40
18
+
19
+
20
+ class JobListOrderEnum(str, Enum):
21
+ CREATED_AT_ASC = "created_at_asc"
22
+ CREATED_AT_DESC = "created_at_desc"
23
+ PRIORITY_ASC = "priority_asc"
24
+ PRIORITY_DESC = "priority_desc"
25
+
26
+
27
+ class JobEventTypeEnum(str, Enum):
28
+ TASK_PUBLISHED = "task_published"
@@ -0,0 +1,85 @@
1
+ from fastapi import Depends, Request
2
+ from forkflux_api.agents.dependencies import get_target_role_service
3
+ from forkflux_api.agents.exceptions import TargetRoleNotFoundError
4
+ from forkflux_api.agents.models import TargetRole
5
+ from forkflux_api.agents.services import TargetRoleService
6
+ from forkflux_api.database import get_session
7
+ from forkflux_api.jobs.api_exceptions import ParentJobValidationError, TargetRoleValidationError
8
+ from forkflux_api.jobs.dto import HandoffJobItem
9
+ from forkflux_api.jobs.exceptions import HandoffJobNotFoundError
10
+ from forkflux_api.jobs.repositories import HandoffJobRepository, JobArtifactRepository, JobEventRepository
11
+ from forkflux_api.jobs.schemas import HandoffJobCreateRequest
12
+ from forkflux_api.jobs.services import HandoffJobService
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+
16
+ def get_trace_id(request: Request) -> str:
17
+ return request.state.trace_id
18
+
19
+
20
+ async def get_handoff_job_repo(
21
+ session: AsyncSession = Depends(get_session), trace_id: str = Depends(get_trace_id)
22
+ ) -> HandoffJobRepository:
23
+ return HandoffJobRepository(session=session, trace_id=trace_id)
24
+
25
+
26
+ async def get_job_artifact_repo(
27
+ session: AsyncSession = Depends(get_session), trace_id: str = Depends(get_trace_id)
28
+ ) -> JobArtifactRepository:
29
+ return JobArtifactRepository(session=session, trace_id=trace_id)
30
+
31
+
32
+ async def get_job_event_repo(
33
+ session: AsyncSession = Depends(get_session), trace_id: str = Depends(get_trace_id)
34
+ ) -> JobEventRepository:
35
+ return JobEventRepository(session=session, trace_id=trace_id)
36
+
37
+
38
+ def get_handoff_job_service(
39
+ repository: HandoffJobRepository = Depends(get_handoff_job_repo),
40
+ job_artifact_repo: JobArtifactRepository = Depends(get_job_artifact_repo),
41
+ job_event_repo: JobEventRepository = Depends(get_job_event_repo),
42
+ trace_id: str = Depends(get_trace_id),
43
+ ) -> HandoffJobService:
44
+ return HandoffJobService(
45
+ handoff_job_repo=repository,
46
+ job_artifact_repo=job_artifact_repo,
47
+ job_event_repo=job_event_repo,
48
+ trace_id=trace_id,
49
+ )
50
+
51
+
52
+ async def validate_parent_job(
53
+ job_data: HandoffJobCreateRequest, service: HandoffJobService = Depends(get_handoff_job_service)
54
+ ) -> HandoffJobItem | None:
55
+ if job_data.parent_job_id is None:
56
+ return None
57
+
58
+ try:
59
+ parent_job = await service.get_job(job_id=job_data.parent_job_id)
60
+ return parent_job
61
+ except HandoffJobNotFoundError:
62
+ raise ParentJobValidationError(field_name="parent_job_id", value=job_data.parent_job_id)
63
+
64
+
65
+ async def validate_target_role(
66
+ job_data: HandoffJobCreateRequest, service: TargetRoleService = Depends(get_target_role_service)
67
+ ) -> TargetRole:
68
+ try:
69
+ target_role = await service.get_by_role_key(role_key=job_data.target_role_key)
70
+ return target_role
71
+ except TargetRoleNotFoundError:
72
+ raise TargetRoleValidationError(field_name="target_role_key", value=job_data.target_role_key)
73
+
74
+
75
+ async def validate_target_role_query_param(
76
+ target_role_key: str | None = None, service: TargetRoleService = Depends(get_target_role_service)
77
+ ) -> TargetRole | None:
78
+ if target_role_key is None or target_role_key.strip() == "":
79
+ return None
80
+
81
+ try:
82
+ role = await service.get_by_role_key(role_key=target_role_key)
83
+ return role
84
+ except TargetRoleNotFoundError:
85
+ raise TargetRoleValidationError(field_name="target_role_key", value=target_role_key, loc="query")
@@ -0,0 +1,56 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, TypedDict
3
+
4
+ from forkflux_api.jobs.constants import JobListOrderEnum, JobPriorityEnum, JobStatusEnum
5
+ from forkflux_api.jobs.models import HandoffJob, JobArtifact
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class HandoffJobCreate:
10
+ parent_job_id: int | None
11
+ summary: str
12
+ context_payload: dict[str, Any]
13
+ priority: JobPriorityEnum
14
+ source_agent_id: int
15
+ target_role_id: int
16
+ constraints: list[str]
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class JobArtifactCreate:
21
+ job_id: int
22
+ artifact_type: str
23
+ artifact_uri: str
24
+ artifact_checksum: str | None
25
+ metadata_json: dict[str, Any]
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class JobEventCreate:
30
+ job_id: int
31
+ event_type: str
32
+ previous_status: JobStatusEnum | None
33
+ current_status: JobStatusEnum
34
+ actor_agent_id: int | None
35
+ payload_json: dict[str, Any]
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class HandoffJobItem:
40
+ job_details: HandoffJob
41
+ target_role_key: str
42
+ source_agent_label: str
43
+ assignee_agent_label: str | None
44
+
45
+
46
+ class HandoffJobWithArtifacts(TypedDict):
47
+ job: HandoffJobItem
48
+ artifacts: list[JobArtifact]
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class HandoffJobFilterParams:
53
+ limit: int
54
+ status: JobStatusEnum | None
55
+ target_role_id: int | None
56
+ order: list[JobListOrderEnum]
@@ -0,0 +1,18 @@
1
+ class HandoffJobConflictError(Exception):
2
+ code = "handoff_job.conflict"
3
+ msg = "Handoff job conflicts with existing data constraints."
4
+
5
+
6
+ class HandoffJobNotFoundError(Exception):
7
+ code = "handoff_job.not_found"
8
+ msg = "Handoff job not found."
9
+
10
+
11
+ class JobArtifactConflictError(Exception):
12
+ code = "job_artifact.conflict"
13
+ msg = "Job artifact conflicts with existing data constraints."
14
+
15
+
16
+ class JobEventConflictError(Exception):
17
+ code = "job_event.conflict"
18
+ msg = "Job event conflicts with existing data constraints."
@@ -0,0 +1,117 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from fastapi import status as http_status
3
+ from forkflux_api.agents.models import AgentIdentity, TargetRole
4
+ from forkflux_api.dependencies import get_current_agent, verify_token
5
+ from forkflux_api.jobs.api_exceptions import (
6
+ HandoffJobClaimValidationError,
7
+ HandoffJobIdentityValidationError,
8
+ HandoffJobStatusValidationError,
9
+ )
10
+ from forkflux_api.jobs.constants import JobListOrderEnum, JobPriorityEnum, JobStatusEnum
11
+ from forkflux_api.jobs.dependencies import (
12
+ get_handoff_job_service,
13
+ validate_parent_job,
14
+ validate_target_role,
15
+ validate_target_role_query_param,
16
+ )
17
+ from forkflux_api.jobs.dto import HandoffJobFilterParams
18
+ from forkflux_api.jobs.exceptions import HandoffJobConflictError, HandoffJobNotFoundError
19
+ from forkflux_api.jobs.helpers import handoff_job_to_response_model
20
+ from forkflux_api.jobs.schemas import (
21
+ HandoffJobChangeStatusRequest,
22
+ HandoffJobCreateRequest,
23
+ HandoffJobCreateResponse,
24
+ HandoffJobListItem,
25
+ HandoffJobWithArtifactsItem,
26
+ )
27
+ from forkflux_api.jobs.services import HandoffJobService
28
+
29
+ router = APIRouter(prefix="/jobs", tags=["jobs"], dependencies=[Depends(verify_token)])
30
+
31
+
32
+ @router.post(
33
+ "",
34
+ response_model=HandoffJobCreateResponse,
35
+ status_code=http_status.HTTP_201_CREATED,
36
+ dependencies=[Depends(validate_parent_job)],
37
+ )
38
+ async def create_job(
39
+ job_data: HandoffJobCreateRequest,
40
+ valid_target_role: TargetRole = Depends(validate_target_role),
41
+ current_agent: AgentIdentity = Depends(get_current_agent),
42
+ job_service: HandoffJobService = Depends(get_handoff_job_service),
43
+ ):
44
+ job_id = await job_service.create_job(job_data, valid_target_role.id, current_agent.id)
45
+ return {"job_id": job_id}
46
+
47
+
48
+ @router.get("", response_model=list[HandoffJobListItem])
49
+ async def list_jobs(
50
+ limit: int = Query(50, ge=1, le=200),
51
+ status: JobStatusEnum | None = None,
52
+ order: list[JobListOrderEnum] = Query(default=[JobListOrderEnum.CREATED_AT_ASC]),
53
+ target_role_key: TargetRole = Depends(validate_target_role_query_param),
54
+ my_role_only: bool = True,
55
+ job_service: HandoffJobService = Depends(get_handoff_job_service),
56
+ current_agent: AgentIdentity = Depends(get_current_agent),
57
+ ):
58
+ target_role_id = current_agent.role_id if my_role_only else target_role_key.id if target_role_key else None
59
+ jobs = await job_service.list_jobs(
60
+ HandoffJobFilterParams(limit=limit, status=status, target_role_id=target_role_id, order=order)
61
+ )
62
+ return [
63
+ HandoffJobListItem(
64
+ id=x.job_details.id,
65
+ summary=x.job_details.summary,
66
+ status=x.job_details.status,
67
+ priority=JobPriorityEnum(x.job_details.priority),
68
+ source_agent_label=x.source_agent_label,
69
+ assignee_agent_label=x.assignee_agent_label,
70
+ target_role_key=x.target_role_key,
71
+ created_at=x.job_details.created_at,
72
+ )
73
+ for x in jobs
74
+ ]
75
+
76
+
77
+ @router.get("/{job_id}", response_model=HandoffJobWithArtifactsItem)
78
+ async def get_job(
79
+ job_id: int,
80
+ job_service: HandoffJobService = Depends(get_handoff_job_service),
81
+ ):
82
+ try:
83
+ entity = await job_service.get_job_with_artifacts(job_id)
84
+ except HandoffJobNotFoundError:
85
+ raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail=HandoffJobNotFoundError.msg)
86
+
87
+ return handoff_job_to_response_model(entity=entity)
88
+
89
+
90
+ @router.post("/{job_id}/claim", status_code=http_status.HTTP_201_CREATED, response_model=HandoffJobWithArtifactsItem)
91
+ async def claim_job(
92
+ job_id: int,
93
+ job_service: HandoffJobService = Depends(get_handoff_job_service),
94
+ current_agent: AgentIdentity = Depends(get_current_agent),
95
+ ):
96
+ try:
97
+ await job_service.claim_job(job_id, current_agent)
98
+ except HandoffJobNotFoundError, HandoffJobConflictError:
99
+ raise HandoffJobClaimValidationError(field_name="job_id", value=job_id, loc="path")
100
+
101
+ entity = await job_service.get_job_with_artifacts(job_id)
102
+ return handoff_job_to_response_model(entity=entity)
103
+
104
+
105
+ @router.post("/{job_id}/status", status_code=http_status.HTTP_204_NO_CONTENT)
106
+ async def change_job_status(
107
+ job_id: int,
108
+ data: HandoffJobChangeStatusRequest,
109
+ job_service: HandoffJobService = Depends(get_handoff_job_service),
110
+ current_agent: AgentIdentity = Depends(get_current_agent),
111
+ ):
112
+ try:
113
+ await job_service.change_job_status(job_id, data.status, current_agent, data.failure_reason)
114
+ except HandoffJobNotFoundError:
115
+ raise HandoffJobIdentityValidationError(field_name="job_id", value=job_id, loc="path")
116
+ except HandoffJobConflictError:
117
+ raise HandoffJobStatusValidationError(field_name="status", value=data.status, loc="body")
@@ -0,0 +1,40 @@
1
+ from forkflux_api.jobs.constants import JobPriorityEnum
2
+ from forkflux_api.jobs.dto import HandoffJobWithArtifacts
3
+ from forkflux_api.jobs.schemas import HandoffJobWithArtifactsItem, JobArtifact
4
+
5
+
6
+ def handoff_job_to_response_model(entity: HandoffJobWithArtifacts) -> HandoffJobWithArtifactsItem:
7
+ job = entity["job"]
8
+ artifacts = entity["artifacts"]
9
+
10
+ return HandoffJobWithArtifactsItem(
11
+ id=job.job_details.id,
12
+ parent_job_id=job.job_details.parent_job_id,
13
+ summary=job.job_details.summary,
14
+ context_payload=job.job_details.context_payload,
15
+ status=job.job_details.status,
16
+ priority=JobPriorityEnum(job.job_details.priority),
17
+ source_agent_label=job.source_agent_label,
18
+ assignee_agent_label=job.assignee_agent_label,
19
+ target_role_key=job.target_role_key,
20
+ constraints=job.job_details.constraints,
21
+ artifacts=[
22
+ JobArtifact(
23
+ type=artifact.artifact_type,
24
+ uri=artifact.artifact_uri,
25
+ checksum=artifact.artifact_checksum,
26
+ metadata_json=artifact.metadata_json,
27
+ )
28
+ for artifact in artifacts
29
+ ],
30
+ failure_reason=job.job_details.failure_reason,
31
+ published_at=job.job_details.published_at,
32
+ claimed_at=job.job_details.claimed_at,
33
+ started_at=job.job_details.started_at,
34
+ completed_at=job.job_details.completed_at,
35
+ failed_at=job.job_details.failed_at,
36
+ cancelled_at=job.job_details.cancelled_at,
37
+ expires_at=job.job_details.expires_at,
38
+ created_at=job.job_details.created_at,
39
+ updated_at=job.job_details.updated_at,
40
+ )