forkflux-api 0.1.0__tar.gz
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-0.1.0/.gitignore +35 -0
- forkflux_api-0.1.0/PKG-INFO +64 -0
- forkflux_api-0.1.0/README.md +33 -0
- forkflux_api-0.1.0/forkflux_api/__init__.py +0 -0
- forkflux_api-0.1.0/forkflux_api/agents/dependencies.py +21 -0
- forkflux_api-0.1.0/forkflux_api/agents/dto.py +19 -0
- forkflux_api-0.1.0/forkflux_api/agents/exceptions.py +28 -0
- forkflux_api-0.1.0/forkflux_api/agents/handlers.py +19 -0
- forkflux_api-0.1.0/forkflux_api/agents/models.py +38 -0
- forkflux_api-0.1.0/forkflux_api/agents/respositories.py +150 -0
- forkflux_api-0.1.0/forkflux_api/agents/schemas.py +17 -0
- forkflux_api-0.1.0/forkflux_api/agents/services.py +117 -0
- forkflux_api-0.1.0/forkflux_api/cli.py +208 -0
- forkflux_api-0.1.0/forkflux_api/config.py +67 -0
- forkflux_api-0.1.0/forkflux_api/database.py +70 -0
- forkflux_api-0.1.0/forkflux_api/dependencies.py +87 -0
- forkflux_api-0.1.0/forkflux_api/exceptions.py +11 -0
- forkflux_api-0.1.0/forkflux_api/jobs/api_exceptions.py +26 -0
- forkflux_api-0.1.0/forkflux_api/jobs/constants.py +28 -0
- forkflux_api-0.1.0/forkflux_api/jobs/dependencies.py +85 -0
- forkflux_api-0.1.0/forkflux_api/jobs/dto.py +56 -0
- forkflux_api-0.1.0/forkflux_api/jobs/exceptions.py +18 -0
- forkflux_api-0.1.0/forkflux_api/jobs/handlers.py +117 -0
- forkflux_api-0.1.0/forkflux_api/jobs/helpers.py +40 -0
- forkflux_api-0.1.0/forkflux_api/jobs/models.py +109 -0
- forkflux_api-0.1.0/forkflux_api/jobs/repositories.py +234 -0
- forkflux_api-0.1.0/forkflux_api/jobs/schemas.py +80 -0
- forkflux_api-0.1.0/forkflux_api/jobs/services.py +222 -0
- forkflux_api-0.1.0/forkflux_api/main.py +43 -0
- forkflux_api-0.1.0/forkflux_api/migrations/env.py +102 -0
- forkflux_api-0.1.0/forkflux_api/migrations/script.py.mako +28 -0
- forkflux_api-0.1.0/forkflux_api/migrations/versions/2026_06_05_2100-7421e85348dc_.py +77 -0
- forkflux_api-0.1.0/forkflux_api/migrations/versions/2026_06_07_1708-ef0279dd14c3_.py +182 -0
- forkflux_api-0.1.0/pyproject.toml +78 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
.idea
|
|
13
|
+
.mypy_cache
|
|
14
|
+
.ruff_cache
|
|
15
|
+
.pytest_cache
|
|
16
|
+
.env
|
|
17
|
+
local.yml
|
|
18
|
+
docker_data
|
|
19
|
+
|
|
20
|
+
AGENTS.md
|
|
21
|
+
.aiginore
|
|
22
|
+
.agents
|
|
23
|
+
.clinerules
|
|
24
|
+
.clineignore
|
|
25
|
+
.roorules
|
|
26
|
+
.roo
|
|
27
|
+
.rooignore
|
|
28
|
+
.zoorules
|
|
29
|
+
.zoo
|
|
30
|
+
.zooignore
|
|
31
|
+
.cursor
|
|
32
|
+
.cursorignore
|
|
33
|
+
.claude
|
|
34
|
+
.kilocodeignore
|
|
35
|
+
.kilo
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forkflux-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core API server and coordination bus for cross-device AI agent task handoff.
|
|
5
|
+
Project-URL: Homepage, https://github.com/forkflux/forkflux
|
|
6
|
+
Project-URL: Repository, https://github.com/forkflux/forkflux
|
|
7
|
+
Project-URL: Issues, https://github.com/forkflux/forkflux/issues
|
|
8
|
+
Keywords: agents,ai,coordination,developer-tools,fastapi,handoff,llm,multi-agent,task-delegation
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Requires-Dist: aiosqlite==0.22.1
|
|
19
|
+
Requires-Dist: alembic-postgresql-enum==1.10.0
|
|
20
|
+
Requires-Dist: alembic==1.18.4
|
|
21
|
+
Requires-Dist: asyncpg==0.31.0
|
|
22
|
+
Requires-Dist: fastapi==0.137.2
|
|
23
|
+
Requires-Dist: gunicorn==26.0.0
|
|
24
|
+
Requires-Dist: pydantic-settings==2.14.1
|
|
25
|
+
Requires-Dist: pydantic==2.13.4
|
|
26
|
+
Requires-Dist: sqlalchemy==2.0.51
|
|
27
|
+
Requires-Dist: structlog==26.1.0
|
|
28
|
+
Requires-Dist: typer==0.26.7
|
|
29
|
+
Requires-Dist: uvicorn==0.49.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# ForkFlux API
|
|
33
|
+
|
|
34
|
+
> Core API server and coordination bus for cross-device AI agent task handoff.
|
|
35
|
+
|
|
36
|
+
ForkFlux API is the stateful coordination layer behind ForkFlux. It gives isolated AI agents a shared, machine-readable job pool for publishing work, atomically claiming tasks, transferring context and artifacts, and closing jobs with explicit lifecycle states.
|
|
37
|
+
|
|
38
|
+
Use this package when you need the ForkFlux coordination bus service itself: a FastAPI application backed by PostgreSQL or SQLite, plus a small CLI for registering target roles and agent API tokens.
|
|
39
|
+
|
|
40
|
+
## What it provides
|
|
41
|
+
|
|
42
|
+
- **Shared handoff queue** for agent-to-agent job delegation.
|
|
43
|
+
- **Atomic claims** so only one agent can own a published job.
|
|
44
|
+
- **Structured context transfer** through job constraints, payloads, and artifacts.
|
|
45
|
+
- **Lifecycle control** for `published` → `in_progress` → `completed` / `failed` / `cancelled`.
|
|
46
|
+
- **Agent identity and role registry** for role-aware routing.
|
|
47
|
+
|
|
48
|
+
## Package
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install forkflux-api
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The installed CLI entry point is:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
forkflux --help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Runtime requirements
|
|
61
|
+
|
|
62
|
+
- Python 3.14+
|
|
63
|
+
|
|
64
|
+
See the main ForkFlux repository for local Docker setup, MCP integration, and end-to-end handoff examples.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ForkFlux API
|
|
2
|
+
|
|
3
|
+
> Core API server and coordination bus for cross-device AI agent task handoff.
|
|
4
|
+
|
|
5
|
+
ForkFlux API is the stateful coordination layer behind ForkFlux. It gives isolated AI agents a shared, machine-readable job pool for publishing work, atomically claiming tasks, transferring context and artifacts, and closing jobs with explicit lifecycle states.
|
|
6
|
+
|
|
7
|
+
Use this package when you need the ForkFlux coordination bus service itself: a FastAPI application backed by PostgreSQL or SQLite, plus a small CLI for registering target roles and agent API tokens.
|
|
8
|
+
|
|
9
|
+
## What it provides
|
|
10
|
+
|
|
11
|
+
- **Shared handoff queue** for agent-to-agent job delegation.
|
|
12
|
+
- **Atomic claims** so only one agent can own a published job.
|
|
13
|
+
- **Structured context transfer** through job constraints, payloads, and artifacts.
|
|
14
|
+
- **Lifecycle control** for `published` → `in_progress` → `completed` / `failed` / `cancelled`.
|
|
15
|
+
- **Agent identity and role registry** for role-aware routing.
|
|
16
|
+
|
|
17
|
+
## Package
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install forkflux-api
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The installed CLI entry point is:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
forkflux --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Runtime requirements
|
|
30
|
+
|
|
31
|
+
- Python 3.14+
|
|
32
|
+
|
|
33
|
+
See the main ForkFlux repository for local Docker setup, MCP integration, and end-to-end handoff examples.
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from fastapi import Depends, Request
|
|
2
|
+
from forkflux_api.agents.respositories import TargetRoleRepository
|
|
3
|
+
from forkflux_api.agents.services import TargetRoleService
|
|
4
|
+
from forkflux_api.database import get_session
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_trace_id(request: Request) -> str:
|
|
9
|
+
return request.state.trace_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_target_role_repo(
|
|
13
|
+
session: AsyncSession = Depends(get_session), trace_id: str = Depends(get_trace_id)
|
|
14
|
+
) -> TargetRoleRepository:
|
|
15
|
+
return TargetRoleRepository(session=session, trace_id=trace_id)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_target_role_service(
|
|
19
|
+
repository: TargetRoleRepository = Depends(get_target_role_repo), trace_id: str = Depends(get_trace_id)
|
|
20
|
+
) -> TargetRoleService:
|
|
21
|
+
return TargetRoleService(target_role_repo=repository, trace_id=trace_id)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(slots=True)
|
|
5
|
+
class TargetRoleCreate:
|
|
6
|
+
role_key: str
|
|
7
|
+
role_label: str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class AgentIdentityCreate:
|
|
12
|
+
agent_label: str
|
|
13
|
+
role_id: int
|
|
14
|
+
tool_family: str | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class AgentApiTokenCreate:
|
|
19
|
+
agent_id: int
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class AgentApiTokenNotFoundError(Exception):
|
|
2
|
+
code = "agent_api_token.not_found"
|
|
3
|
+
msg = "Agent API token not found."
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentApiTokenConflictError(Exception):
|
|
7
|
+
code = "agent_api_token.conflict"
|
|
8
|
+
msg = "Agent API token conflicts with existing data constraints."
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentIdentityNotFoundError(Exception):
|
|
12
|
+
code = "agent_identity.not_found"
|
|
13
|
+
msg = "Agent identity not found."
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentIdentityConflictError(Exception):
|
|
17
|
+
code = "agent_identity.conflict"
|
|
18
|
+
msg = "Agent identity conflicts with existing data constraints."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TargetRoleConflictError(Exception):
|
|
22
|
+
code = "target_role.conflict"
|
|
23
|
+
msg = "Target role already exists."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TargetRoleNotFoundError(Exception):
|
|
27
|
+
code = "target_role.not_found"
|
|
28
|
+
msg = "Target role not found."
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from forkflux_api.agents.dependencies import get_target_role_service
|
|
3
|
+
from forkflux_api.agents.models import AgentIdentity
|
|
4
|
+
from forkflux_api.agents.schemas import GetMeResponse, ListRolesResponse
|
|
5
|
+
from forkflux_api.agents.services import TargetRoleService
|
|
6
|
+
from forkflux_api.dependencies import get_current_agent, verify_token
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/agents", tags=["agents"], dependencies=[Depends(verify_token)])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/roles", response_model=list[ListRolesResponse])
|
|
12
|
+
async def list_roles(service: TargetRoleService = Depends(get_target_role_service)):
|
|
13
|
+
roles = await service.get_all_roles()
|
|
14
|
+
return roles
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/me", response_model=GetMeResponse)
|
|
18
|
+
async def get_me(current_agent: AgentIdentity = Depends(get_current_agent)):
|
|
19
|
+
return current_agent
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from forkflux_api.database import Base, UTCDateTime
|
|
4
|
+
from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, Text
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
PK_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TargetRole(Base):
|
|
11
|
+
__tablename__ = "target_role"
|
|
12
|
+
|
|
13
|
+
id: Mapped[int] = mapped_column(PK_TYPE, primary_key=True, autoincrement=True)
|
|
14
|
+
role_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
|
15
|
+
role_label: Mapped[str] = mapped_column(Text, nullable=False)
|
|
16
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentIdentity(Base):
|
|
20
|
+
__tablename__ = "agent_identity"
|
|
21
|
+
|
|
22
|
+
id: Mapped[int] = mapped_column(PK_TYPE, primary_key=True, autoincrement=True)
|
|
23
|
+
agent_label: Mapped[str] = mapped_column(Text, nullable=False)
|
|
24
|
+
role_id: Mapped[int] = mapped_column(ForeignKey("target_role.id"), nullable=False)
|
|
25
|
+
tool_family: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
26
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentApiToken(Base):
|
|
30
|
+
__tablename__ = "agent_api_token"
|
|
31
|
+
|
|
32
|
+
id: Mapped[int] = mapped_column(PK_TYPE, primary_key=True, autoincrement=True)
|
|
33
|
+
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
|
34
|
+
agent_id: Mapped[int] = mapped_column(ForeignKey("agent_identity.id"), nullable=False)
|
|
35
|
+
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
|
36
|
+
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
|
37
|
+
last_used_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
|
38
|
+
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from forkflux_api.agents.dto import AgentApiTokenCreate, AgentIdentityCreate, TargetRoleCreate
|
|
5
|
+
from forkflux_api.agents.exceptions import (
|
|
6
|
+
AgentApiTokenConflictError,
|
|
7
|
+
AgentApiTokenNotFoundError,
|
|
8
|
+
AgentIdentityConflictError,
|
|
9
|
+
AgentIdentityNotFoundError,
|
|
10
|
+
TargetRoleConflictError,
|
|
11
|
+
TargetRoleNotFoundError,
|
|
12
|
+
)
|
|
13
|
+
from forkflux_api.agents.models import AgentApiToken, AgentIdentity, TargetRole
|
|
14
|
+
from sqlalchemy import exists, select, update
|
|
15
|
+
from sqlalchemy.exc import IntegrityError
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TargetRoleRepository:
|
|
20
|
+
def __init__(self, session: AsyncSession, trace_id: str) -> None:
|
|
21
|
+
self._session = session
|
|
22
|
+
self._logger = structlog.get_logger().bind(cls=self.__class__.__name__, trace_id=trace_id)
|
|
23
|
+
|
|
24
|
+
async def list(self) -> list[TargetRole]:
|
|
25
|
+
result = await self._session.execute(select(TargetRole))
|
|
26
|
+
return list(result.scalars().all())
|
|
27
|
+
|
|
28
|
+
async def get_by_role_key(self, role_key: str) -> TargetRole:
|
|
29
|
+
result = await self._session.execute(select(TargetRole).where(TargetRole.role_key == role_key))
|
|
30
|
+
target_role = result.scalar_one_or_none()
|
|
31
|
+
if target_role is None:
|
|
32
|
+
raise TargetRoleNotFoundError
|
|
33
|
+
|
|
34
|
+
return target_role
|
|
35
|
+
|
|
36
|
+
async def exists(self, role_key: str) -> bool:
|
|
37
|
+
log = self._logger.bind(method="exists", role_key=role_key)
|
|
38
|
+
result = await self._session.execute(select(exists().where(TargetRole.role_key == role_key)))
|
|
39
|
+
role_exists = result.scalar_one()
|
|
40
|
+
|
|
41
|
+
if role_exists:
|
|
42
|
+
log.info("target_role_exists_hit")
|
|
43
|
+
else:
|
|
44
|
+
log.info("target_role_exists_miss")
|
|
45
|
+
|
|
46
|
+
return role_exists
|
|
47
|
+
|
|
48
|
+
async def create(self, dto: TargetRoleCreate) -> TargetRole:
|
|
49
|
+
target_role = TargetRole(
|
|
50
|
+
role_key=dto.role_key,
|
|
51
|
+
role_label=dto.role_label,
|
|
52
|
+
created_at=datetime.now(timezone.utc),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self._session.add(target_role)
|
|
56
|
+
try:
|
|
57
|
+
await self._session.flush()
|
|
58
|
+
except IntegrityError as err:
|
|
59
|
+
await self._session.rollback()
|
|
60
|
+
raise TargetRoleConflictError from err
|
|
61
|
+
|
|
62
|
+
return target_role
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AgentApiTokenRepository:
|
|
66
|
+
def __init__(self, session: AsyncSession, trace_id: str) -> None:
|
|
67
|
+
self._session = session
|
|
68
|
+
self._logger = structlog.get_logger().bind(cls=self.__class__.__name__, trace_id=trace_id)
|
|
69
|
+
|
|
70
|
+
async def get(self, token_hash: str) -> AgentApiToken:
|
|
71
|
+
result = await self._session.execute(
|
|
72
|
+
select(AgentApiToken).where(
|
|
73
|
+
AgentApiToken.token_hash == token_hash,
|
|
74
|
+
AgentApiToken.is_active.is_(True),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
token = result.scalar_one_or_none()
|
|
78
|
+
if token is None:
|
|
79
|
+
raise AgentApiTokenNotFoundError
|
|
80
|
+
|
|
81
|
+
return token
|
|
82
|
+
|
|
83
|
+
async def create(self, dto: AgentApiTokenCreate, token_hash: str) -> AgentApiToken:
|
|
84
|
+
agent_api_token = AgentApiToken(
|
|
85
|
+
token_hash=token_hash,
|
|
86
|
+
agent_id=dto.agent_id,
|
|
87
|
+
is_active=True,
|
|
88
|
+
created_at=datetime.now(timezone.utc),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self._session.add(agent_api_token)
|
|
92
|
+
try:
|
|
93
|
+
await self._session.flush()
|
|
94
|
+
except IntegrityError as err:
|
|
95
|
+
await self._session.rollback()
|
|
96
|
+
raise AgentApiTokenConflictError from err
|
|
97
|
+
|
|
98
|
+
return agent_api_token
|
|
99
|
+
|
|
100
|
+
async def revoke(self, agent_id: int) -> int:
|
|
101
|
+
revoked_at = datetime.now(timezone.utc)
|
|
102
|
+
result = await self._session.execute(
|
|
103
|
+
update(AgentApiToken)
|
|
104
|
+
.where(
|
|
105
|
+
AgentApiToken.agent_id == agent_id,
|
|
106
|
+
AgentApiToken.is_active.is_(True),
|
|
107
|
+
)
|
|
108
|
+
.values(
|
|
109
|
+
is_active=False,
|
|
110
|
+
revoked_at=revoked_at,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
await self._session.flush()
|
|
114
|
+
|
|
115
|
+
return result.rowcount or 0 # type: ignore[attr-defined]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AgentIdentityRepository:
|
|
119
|
+
def __init__(self, session: AsyncSession, trace_id: str) -> None:
|
|
120
|
+
self._session = session
|
|
121
|
+
self._logger = structlog.get_logger().bind(cls=self.__class__.__name__, trace_id=trace_id)
|
|
122
|
+
|
|
123
|
+
async def list(self) -> list[AgentIdentity]:
|
|
124
|
+
result = await self._session.execute(select(AgentIdentity))
|
|
125
|
+
return list(result.scalars().all())
|
|
126
|
+
|
|
127
|
+
async def get_by_id(self, agent_identity_id: int) -> AgentIdentity:
|
|
128
|
+
result = await self._session.execute(select(AgentIdentity).where(AgentIdentity.id == agent_identity_id))
|
|
129
|
+
agent_identity = result.scalar_one_or_none()
|
|
130
|
+
if agent_identity is None:
|
|
131
|
+
raise AgentIdentityNotFoundError
|
|
132
|
+
|
|
133
|
+
return agent_identity
|
|
134
|
+
|
|
135
|
+
async def create(self, dto: AgentIdentityCreate) -> AgentIdentity:
|
|
136
|
+
agent_identity = AgentIdentity(
|
|
137
|
+
agent_label=dto.agent_label,
|
|
138
|
+
role_id=dto.role_id,
|
|
139
|
+
tool_family=dto.tool_family,
|
|
140
|
+
created_at=datetime.now(timezone.utc),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._session.add(agent_identity)
|
|
144
|
+
try:
|
|
145
|
+
await self._session.flush()
|
|
146
|
+
except IntegrityError as err:
|
|
147
|
+
await self._session.rollback()
|
|
148
|
+
raise AgentIdentityConflictError from err
|
|
149
|
+
|
|
150
|
+
return agent_identity
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ListRolesResponse(BaseModel):
|
|
5
|
+
model_config = ConfigDict(from_attributes=True)
|
|
6
|
+
|
|
7
|
+
role_key: str
|
|
8
|
+
role_label: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GetMeResponse(BaseModel):
|
|
12
|
+
model_config = ConfigDict(from_attributes=True)
|
|
13
|
+
|
|
14
|
+
id: int
|
|
15
|
+
agent_label: str
|
|
16
|
+
role_id: int
|
|
17
|
+
tool_family: str | None
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import secrets
|
|
3
|
+
|
|
4
|
+
import structlog
|
|
5
|
+
from forkflux_api.agents.dto import AgentApiTokenCreate, AgentIdentityCreate, TargetRoleCreate
|
|
6
|
+
from forkflux_api.agents.models import AgentApiToken, AgentIdentity, TargetRole
|
|
7
|
+
from forkflux_api.agents.respositories import AgentApiTokenRepository, AgentIdentityRepository, TargetRoleRepository
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TargetRoleService:
|
|
11
|
+
def __init__(self, target_role_repo: TargetRoleRepository, trace_id: str) -> None:
|
|
12
|
+
self._logger = structlog.get_logger().bind(cls=self.__class__.__name__, trace_id=trace_id)
|
|
13
|
+
self._target_role_repo = target_role_repo
|
|
14
|
+
|
|
15
|
+
async def get_all_roles(self) -> list[TargetRole]:
|
|
16
|
+
log = self._logger.bind(method="get_all_roles")
|
|
17
|
+
log.info("operation_started")
|
|
18
|
+
|
|
19
|
+
roles = await self._target_role_repo.list()
|
|
20
|
+
|
|
21
|
+
log.info("operation_completed", roles_count=len(roles))
|
|
22
|
+
return roles
|
|
23
|
+
|
|
24
|
+
async def get_by_role_key(self, role_key: str) -> TargetRole:
|
|
25
|
+
log = self._logger.bind(method="get_by_role_key", role_key=role_key)
|
|
26
|
+
log.info("operation_started")
|
|
27
|
+
|
|
28
|
+
role = await self._target_role_repo.get_by_role_key(role_key)
|
|
29
|
+
|
|
30
|
+
log.info("operation_completed")
|
|
31
|
+
return role
|
|
32
|
+
|
|
33
|
+
async def is_role_exists(self, role_key: str) -> bool:
|
|
34
|
+
log = self._logger.bind(method="is_role_exists", role_key=role_key)
|
|
35
|
+
log.info("operation_started")
|
|
36
|
+
|
|
37
|
+
exists = await self._target_role_repo.exists(role_key)
|
|
38
|
+
|
|
39
|
+
log.info("operation_completed", role_exists=exists)
|
|
40
|
+
return exists
|
|
41
|
+
|
|
42
|
+
async def create_role(self, dto: TargetRoleCreate) -> TargetRole:
|
|
43
|
+
log = self._logger.bind(method="create_role", role_key=dto.role_key)
|
|
44
|
+
log.info("operation_started")
|
|
45
|
+
|
|
46
|
+
role = await self._target_role_repo.create(dto)
|
|
47
|
+
|
|
48
|
+
log.info("operation_completed")
|
|
49
|
+
return role
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentApiTokenService:
|
|
53
|
+
def __init__(self, agent_api_token_repo: AgentApiTokenRepository, trace_id: str) -> None:
|
|
54
|
+
self._logger = structlog.get_logger().bind(cls=self.__class__.__name__, trace_id=trace_id)
|
|
55
|
+
self._agent_api_token_repo = agent_api_token_repo
|
|
56
|
+
|
|
57
|
+
async def get_token(self, token_hash: str) -> AgentApiToken:
|
|
58
|
+
log = self._logger.bind(method="get_token", token_hash=token_hash)
|
|
59
|
+
log.info("operation_started")
|
|
60
|
+
|
|
61
|
+
token = await self._agent_api_token_repo.get(token_hash)
|
|
62
|
+
|
|
63
|
+
log.info("operation_completed", token_id=token.id, agent_id=token.agent_id)
|
|
64
|
+
return token
|
|
65
|
+
|
|
66
|
+
async def create_token(self, dto: AgentApiTokenCreate) -> str:
|
|
67
|
+
log = self._logger.bind(method="create_token", agent_id=dto.agent_id)
|
|
68
|
+
log.info("operation_started")
|
|
69
|
+
|
|
70
|
+
raw_token = secrets.token_urlsafe(32)
|
|
71
|
+
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
|
72
|
+
await self._agent_api_token_repo.create(dto=dto, token_hash=token_hash)
|
|
73
|
+
|
|
74
|
+
log.info("operation_completed")
|
|
75
|
+
return raw_token
|
|
76
|
+
|
|
77
|
+
async def revoke_token(self, agent_id: int) -> int:
|
|
78
|
+
log = self._logger.bind(method="revoke_token", agent_id=agent_id)
|
|
79
|
+
log.info("operation_started")
|
|
80
|
+
|
|
81
|
+
revoked_count = await self._agent_api_token_repo.revoke(agent_id)
|
|
82
|
+
|
|
83
|
+
log.info("operation_completed", revoked_count=revoked_count)
|
|
84
|
+
return revoked_count
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AgentIdentityService:
|
|
88
|
+
def __init__(self, agent_identity_repo: AgentIdentityRepository, trace_id: str) -> None:
|
|
89
|
+
self._logger = structlog.get_logger().bind(cls=self.__class__.__name__, trace_id=trace_id)
|
|
90
|
+
self._agent_identity_repo = agent_identity_repo
|
|
91
|
+
|
|
92
|
+
async def get_all_agents(self) -> list[AgentIdentity]:
|
|
93
|
+
log = self._logger.bind(method="get_all_agents")
|
|
94
|
+
log.info("operation_started")
|
|
95
|
+
|
|
96
|
+
agents = await self._agent_identity_repo.list()
|
|
97
|
+
|
|
98
|
+
log.info("operation_completed", agents_count=len(agents))
|
|
99
|
+
return agents
|
|
100
|
+
|
|
101
|
+
async def get_by_id(self, agent_identity_id: int) -> AgentIdentity:
|
|
102
|
+
log = self._logger.bind(method="get_by_id", agent_identity_id=agent_identity_id)
|
|
103
|
+
log.info("operation_started")
|
|
104
|
+
|
|
105
|
+
agent = await self._agent_identity_repo.get_by_id(agent_identity_id)
|
|
106
|
+
|
|
107
|
+
log.info("operation_completed")
|
|
108
|
+
return agent
|
|
109
|
+
|
|
110
|
+
async def create_agent(self, dto: AgentIdentityCreate) -> AgentIdentity:
|
|
111
|
+
log = self._logger.bind(method="create_agent", agent_label=dto.agent_label, role_id=dto.role_id)
|
|
112
|
+
log.info("operation_started")
|
|
113
|
+
|
|
114
|
+
agent = await self._agent_identity_repo.create(dto)
|
|
115
|
+
|
|
116
|
+
log.info("operation_completed", agent_identity_id=agent.id)
|
|
117
|
+
return agent
|