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/__init__.py
ADDED
|
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
|
forkflux_api/cli.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
import typer
|
|
11
|
+
import uvicorn
|
|
12
|
+
from alembic import command
|
|
13
|
+
from alembic.config import Config
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
|
18
|
+
|
|
19
|
+
from forkflux_api.agents.dto import AgentApiTokenCreate, AgentIdentityCreate, TargetRoleCreate
|
|
20
|
+
from forkflux_api.agents.exceptions import (
|
|
21
|
+
AgentApiTokenConflictError,
|
|
22
|
+
AgentIdentityConflictError,
|
|
23
|
+
TargetRoleConflictError,
|
|
24
|
+
TargetRoleNotFoundError,
|
|
25
|
+
)
|
|
26
|
+
from forkflux_api.agents.respositories import AgentApiTokenRepository, AgentIdentityRepository, TargetRoleRepository
|
|
27
|
+
from forkflux_api.agents.services import AgentApiTokenService, AgentIdentityService, TargetRoleService
|
|
28
|
+
from forkflux_api.database import session_manager
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(help="ForkFlux Management CLI")
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
_CLI_LOGGING_CONFIGURED = False
|
|
34
|
+
|
|
35
|
+
agents_role_app = typer.Typer(help="Agents role management")
|
|
36
|
+
agent_app = typer.Typer(help="Agents management")
|
|
37
|
+
|
|
38
|
+
app.add_typer(agents_role_app, name="agents-role")
|
|
39
|
+
app.add_typer(agent_app, name="agent")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _configure_cli_logging() -> None:
|
|
43
|
+
"""Suppress INFO logs for CLI-invoked services, keep WARNING/ERROR visible."""
|
|
44
|
+
global _CLI_LOGGING_CONFIGURED
|
|
45
|
+
|
|
46
|
+
if _CLI_LOGGING_CONFIGURED:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
logging.basicConfig(level=logging.WARNING, force=True)
|
|
50
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.WARNING))
|
|
51
|
+
|
|
52
|
+
_CLI_LOGGING_CONFIGURED = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_migrations() -> None:
|
|
56
|
+
console.print("Apply database migrations")
|
|
57
|
+
current_dir = os.path.dirname(__file__)
|
|
58
|
+
alembic_cfg = Config(toml_file="../pyproject.toml")
|
|
59
|
+
alembic_cfg.set_main_option("script_location", os.path.join(current_dir, "migrations"))
|
|
60
|
+
command.upgrade(alembic_cfg, "head")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command(help="Run the server")
|
|
64
|
+
def serve(host: str = "0.0.0.0", port: int = 8080) -> None: # noqa: S104
|
|
65
|
+
apply_migrations()
|
|
66
|
+
|
|
67
|
+
console.print("Starting server...", style="bold green")
|
|
68
|
+
uvicorn.run(
|
|
69
|
+
"forkflux_api.main:app",
|
|
70
|
+
host=host,
|
|
71
|
+
port=port,
|
|
72
|
+
forwarded_allow_ips="*",
|
|
73
|
+
workers=2,
|
|
74
|
+
loop="none" if sys.platform == "win32" else "auto",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command(help="Initialize the database and add some example data")
|
|
79
|
+
def init() -> None:
|
|
80
|
+
apply_migrations()
|
|
81
|
+
|
|
82
|
+
asyncio.run(_init_async())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _init_async() -> None:
|
|
86
|
+
_configure_cli_logging()
|
|
87
|
+
|
|
88
|
+
console.print("Lets add 2 roles - developer and QA")
|
|
89
|
+
await add_role.__wrapped__(role_key="developer", role_label="Developer")
|
|
90
|
+
await add_role.__wrapped__(role_key="qa", role_label="QA")
|
|
91
|
+
|
|
92
|
+
console.print("Lets add 2 agents - agent-1 and agent-2")
|
|
93
|
+
await add_agent.__wrapped__(agent_label="agent-1", role_key="developer")
|
|
94
|
+
await add_agent.__wrapped__(agent_label="agent-2", role_key="qa")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@agents_role_app.command("list")
|
|
98
|
+
@lambda f: wraps(f)(lambda *a, **kw: asyncio.run(f(*a, **kw)))
|
|
99
|
+
async def list_roles() -> None:
|
|
100
|
+
_configure_cli_logging()
|
|
101
|
+
trace_id = str(uuid4())
|
|
102
|
+
|
|
103
|
+
async with session_manager() as session:
|
|
104
|
+
repo = TargetRoleRepository(session=session, trace_id=trace_id)
|
|
105
|
+
roles = await TargetRoleService(target_role_repo=repo, trace_id=trace_id).get_all_roles()
|
|
106
|
+
|
|
107
|
+
table = Table("Key", "Label")
|
|
108
|
+
for role in roles:
|
|
109
|
+
table.add_row(role.role_key, role.role_label)
|
|
110
|
+
console.print(table)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@agents_role_app.command("add")
|
|
114
|
+
@lambda f: wraps(f)(lambda *a, **kw: asyncio.run(f(*a, **kw)))
|
|
115
|
+
async def add_role(role_key: str, role_label: str) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Adds a new role with the specified key and label.
|
|
118
|
+
"""
|
|
119
|
+
_configure_cli_logging()
|
|
120
|
+
trace_id = str(uuid4())
|
|
121
|
+
|
|
122
|
+
async with session_manager() as session:
|
|
123
|
+
try:
|
|
124
|
+
repo = TargetRoleRepository(session=session, trace_id=trace_id)
|
|
125
|
+
dto = TargetRoleCreate(role_key=role_key, role_label=role_label)
|
|
126
|
+
new_role = await TargetRoleService(target_role_repo=repo, trace_id=trace_id).create_role(dto=dto)
|
|
127
|
+
console.print(f"Role {new_role.role_key} created successfully")
|
|
128
|
+
except TargetRoleConflictError:
|
|
129
|
+
console.print(f"Role with key {role_key} already exists", style="bold red")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@agent_app.command("list")
|
|
133
|
+
@lambda f: wraps(f)(lambda *a, **kw: asyncio.run(f(*a, **kw)))
|
|
134
|
+
async def list_agents() -> None:
|
|
135
|
+
_configure_cli_logging()
|
|
136
|
+
trace_id = str(uuid4())
|
|
137
|
+
|
|
138
|
+
async with session_manager() as session:
|
|
139
|
+
agent_repo = AgentIdentityRepository(session=session, trace_id=trace_id)
|
|
140
|
+
agents = await AgentIdentityService(agent_identity_repo=agent_repo, trace_id=trace_id).get_all_agents()
|
|
141
|
+
role_repo = TargetRoleRepository(session=session, trace_id=trace_id)
|
|
142
|
+
roles = await TargetRoleService(target_role_repo=role_repo, trace_id=trace_id).get_all_roles()
|
|
143
|
+
|
|
144
|
+
roles_mapping = {role.id: role.role_key for role in roles}
|
|
145
|
+
|
|
146
|
+
table = Table("ID", "Label", "Role key")
|
|
147
|
+
for agent in agents:
|
|
148
|
+
table.add_row(str(agent.id), agent.agent_label, roles_mapping[agent.role_id])
|
|
149
|
+
console.print(table)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@agent_app.command("add")
|
|
153
|
+
@lambda f: wraps(f)(lambda *a, **kw: asyncio.run(f(*a, **kw)))
|
|
154
|
+
async def add_agent(agent_label: str, role_key: str, tool_family: str | None = None) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Adds a new agent with the specified label, role, and tool family (optional).
|
|
157
|
+
"""
|
|
158
|
+
_configure_cli_logging()
|
|
159
|
+
trace_id = str(uuid4())
|
|
160
|
+
|
|
161
|
+
async with session_manager() as session:
|
|
162
|
+
try:
|
|
163
|
+
role_repo = TargetRoleRepository(session=session, trace_id=trace_id)
|
|
164
|
+
role = await TargetRoleService(target_role_repo=role_repo, trace_id=trace_id).get_by_role_key(role_key)
|
|
165
|
+
except TargetRoleNotFoundError:
|
|
166
|
+
console.print(f"Role with key {role_key} not found", style="bold red")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
agent_repo = AgentIdentityRepository(session=session, trace_id=trace_id)
|
|
171
|
+
agent_dto = AgentIdentityCreate(agent_label=agent_label, role_id=role.id, tool_family=tool_family)
|
|
172
|
+
new_agent = await AgentIdentityService(agent_identity_repo=agent_repo, trace_id=trace_id).create_agent(
|
|
173
|
+
dto=agent_dto
|
|
174
|
+
)
|
|
175
|
+
console.print(f"Agent {new_agent.agent_label} created successfully")
|
|
176
|
+
except AgentIdentityConflictError:
|
|
177
|
+
console.print("Can't create new agent", style="bold red")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
token_repo = AgentApiTokenRepository(session=session, trace_id=trace_id)
|
|
182
|
+
token_dto = AgentApiTokenCreate(agent_id=new_agent.id)
|
|
183
|
+
new_token = await AgentApiTokenService(agent_api_token_repo=token_repo, trace_id=trace_id).create_token(
|
|
184
|
+
dto=token_dto
|
|
185
|
+
)
|
|
186
|
+
console.print(f"Token {new_token} for agent {new_agent.agent_label} created successfully")
|
|
187
|
+
except AgentApiTokenConflictError:
|
|
188
|
+
console.print("Can't create new token", style="bold red")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@agent_app.command("revoke-token")
|
|
193
|
+
@lambda f: wraps(f)(lambda *a, **kw: asyncio.run(f(*a, **kw)))
|
|
194
|
+
async def agent_revoke_token(agent_id: int) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Revokes the token associated with a specified agent.
|
|
197
|
+
"""
|
|
198
|
+
_configure_cli_logging()
|
|
199
|
+
trace_id = str(uuid4())
|
|
200
|
+
|
|
201
|
+
async with session_manager() as session:
|
|
202
|
+
token_repo = AgentApiTokenRepository(session=session, trace_id=trace_id)
|
|
203
|
+
await AgentApiTokenService(agent_api_token_repo=token_repo, trace_id=trace_id).revoke_token(agent_id=agent_id)
|
|
204
|
+
console.print(f"Token for agent {agent_id} revoked successfully")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
app()
|