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/__init__.py +0 -0
- forkflux_api/agents/dependencies.py +21 -0
- forkflux_api/agents/dto.py +19 -0
- forkflux_api/agents/exceptions.py +28 -0
- forkflux_api/agents/handlers.py +19 -0
- forkflux_api/agents/models.py +38 -0
- forkflux_api/agents/respositories.py +150 -0
- forkflux_api/agents/schemas.py +17 -0
- forkflux_api/agents/services.py +117 -0
- forkflux_api/cli.py +208 -0
- forkflux_api/config.py +67 -0
- forkflux_api/database.py +70 -0
- forkflux_api/dependencies.py +87 -0
- forkflux_api/exceptions.py +11 -0
- forkflux_api/jobs/api_exceptions.py +26 -0
- forkflux_api/jobs/constants.py +28 -0
- forkflux_api/jobs/dependencies.py +85 -0
- forkflux_api/jobs/dto.py +56 -0
- forkflux_api/jobs/exceptions.py +18 -0
- forkflux_api/jobs/handlers.py +117 -0
- forkflux_api/jobs/helpers.py +40 -0
- forkflux_api/jobs/models.py +109 -0
- forkflux_api/jobs/repositories.py +234 -0
- forkflux_api/jobs/schemas.py +80 -0
- forkflux_api/jobs/services.py +222 -0
- forkflux_api/main.py +43 -0
- forkflux_api/migrations/env.py +102 -0
- forkflux_api/migrations/script.py.mako +28 -0
- forkflux_api/migrations/versions/2026_06_05_2100-7421e85348dc_.py +77 -0
- forkflux_api/migrations/versions/2026_06_07_1708-ef0279dd14c3_.py +182 -0
- forkflux_api-0.1.0.dist-info/METADATA +64 -0
- forkflux_api-0.1.0.dist-info/RECORD +34 -0
- forkflux_api-0.1.0.dist-info/WHEEL +4 -0
- forkflux_api-0.1.0.dist-info/entry_points.txt +2 -0
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()
|
forkflux_api/database.py
ADDED
|
@@ -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")
|
forkflux_api/jobs/dto.py
ADDED
|
@@ -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
|
+
)
|