beadhub 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.
- beadhub/__init__.py +12 -0
- beadhub/api.py +260 -0
- beadhub/auth.py +101 -0
- beadhub/aweb_context.py +65 -0
- beadhub/aweb_introspection.py +70 -0
- beadhub/beads_sync.py +514 -0
- beadhub/cli.py +330 -0
- beadhub/config.py +65 -0
- beadhub/db.py +129 -0
- beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
- beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
- beadhub/defaults/invariants/03-communication-chat.md +60 -0
- beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
- beadhub/defaults/invariants/05-collaborate.md +12 -0
- beadhub/defaults/roles/backend.md +55 -0
- beadhub/defaults/roles/coordinator.md +44 -0
- beadhub/defaults/roles/frontend.md +77 -0
- beadhub/defaults/roles/implementer.md +73 -0
- beadhub/defaults/roles/reviewer.md +56 -0
- beadhub/defaults/roles/startup-expert.md +93 -0
- beadhub/defaults.py +262 -0
- beadhub/events.py +704 -0
- beadhub/internal_auth.py +121 -0
- beadhub/jsonl.py +68 -0
- beadhub/logging.py +62 -0
- beadhub/migrations/beads/001_initial.sql +70 -0
- beadhub/migrations/beads/002_search_indexes.sql +20 -0
- beadhub/migrations/server/001_initial.sql +279 -0
- beadhub/names.py +33 -0
- beadhub/notifications.py +275 -0
- beadhub/pagination.py +125 -0
- beadhub/presence.py +495 -0
- beadhub/rate_limit.py +152 -0
- beadhub/redis_client.py +11 -0
- beadhub/roles.py +35 -0
- beadhub/routes/__init__.py +1 -0
- beadhub/routes/agents.py +303 -0
- beadhub/routes/bdh.py +655 -0
- beadhub/routes/beads.py +778 -0
- beadhub/routes/claims.py +141 -0
- beadhub/routes/escalations.py +471 -0
- beadhub/routes/init.py +348 -0
- beadhub/routes/mcp.py +338 -0
- beadhub/routes/policies.py +833 -0
- beadhub/routes/repos.py +538 -0
- beadhub/routes/status.py +568 -0
- beadhub/routes/subscriptions.py +362 -0
- beadhub/routes/workspaces.py +1642 -0
- beadhub/workspace_config.py +202 -0
- beadhub-0.1.0.dist-info/METADATA +254 -0
- beadhub-0.1.0.dist-info/RECORD +54 -0
- beadhub-0.1.0.dist-info/WHEEL +4 -0
- beadhub-0.1.0.dist-info/entry_points.txt +2 -0
- beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
beadhub/routes/agents.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from aweb.alias_allocator import suggest_next_name_prefix
|
|
7
|
+
from aweb.auth import validate_project_slug
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
from redis.asyncio import Redis
|
|
11
|
+
|
|
12
|
+
from beadhub.auth import validate_workspace_id, verify_workspace_access
|
|
13
|
+
from beadhub.aweb_introspection import get_project_from_auth
|
|
14
|
+
|
|
15
|
+
from ..beads_sync import is_valid_alias
|
|
16
|
+
from ..config import get_settings
|
|
17
|
+
from ..db import DatabaseInfra, get_db_infra
|
|
18
|
+
from ..presence import list_agent_presences_by_workspace_ids, update_agent_presence
|
|
19
|
+
from ..redis_client import get_redis
|
|
20
|
+
from ..roles import ROLE_ERROR_MESSAGE, ROLE_MAX_LENGTH, is_valid_role, normalize_role
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/v1/agents", tags=["agents"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_workspace_id_field(v: str) -> str:
|
|
26
|
+
"""Pydantic validator wrapper for workspace_id."""
|
|
27
|
+
try:
|
|
28
|
+
return validate_workspace_id(v)
|
|
29
|
+
except ValueError as e:
|
|
30
|
+
raise ValueError(str(e))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_alias_field(v: str) -> str:
|
|
34
|
+
"""Pydantic validator wrapper for alias."""
|
|
35
|
+
if not is_valid_alias(v):
|
|
36
|
+
raise ValueError("Invalid alias: must be alphanumeric with hyphens/underscores, 1-64 chars")
|
|
37
|
+
return v
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RegisterAgentRequest(BaseModel):
|
|
41
|
+
workspace_id: str = Field(..., min_length=1)
|
|
42
|
+
alias: str = Field(..., min_length=1, max_length=64)
|
|
43
|
+
human_name: Optional[str] = Field(None, max_length=64)
|
|
44
|
+
project_slug: Optional[str] = Field(None, max_length=63)
|
|
45
|
+
program: Optional[str] = None
|
|
46
|
+
model: Optional[str] = None
|
|
47
|
+
task_description: Optional[str] = None
|
|
48
|
+
repo: Optional[str] = Field(None, max_length=255)
|
|
49
|
+
branch: Optional[str] = Field(None, max_length=255)
|
|
50
|
+
role: Optional[str] = Field(
|
|
51
|
+
None,
|
|
52
|
+
max_length=ROLE_MAX_LENGTH,
|
|
53
|
+
description="Brief description of workspace purpose",
|
|
54
|
+
)
|
|
55
|
+
ttl_seconds: Optional[int] = Field(
|
|
56
|
+
None, gt=0, le=86400, description="Presence TTL in seconds (default 300, max 86400)"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@field_validator("workspace_id")
|
|
60
|
+
@classmethod
|
|
61
|
+
def validate_workspace_id(cls, v: str) -> str:
|
|
62
|
+
return _validate_workspace_id_field(v)
|
|
63
|
+
|
|
64
|
+
@field_validator("alias")
|
|
65
|
+
@classmethod
|
|
66
|
+
def validate_alias(cls, v: str) -> str:
|
|
67
|
+
return _validate_alias_field(v)
|
|
68
|
+
|
|
69
|
+
@field_validator("role")
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_role(cls, v: Optional[str]) -> Optional[str]:
|
|
72
|
+
if v is None:
|
|
73
|
+
return None
|
|
74
|
+
if not is_valid_role(v):
|
|
75
|
+
raise ValueError(ROLE_ERROR_MESSAGE)
|
|
76
|
+
return normalize_role(v)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AgentInfo(BaseModel):
|
|
80
|
+
alias: str
|
|
81
|
+
human_name: Optional[str] = None
|
|
82
|
+
project_slug: Optional[str] = None
|
|
83
|
+
program: Optional[str] = None
|
|
84
|
+
model: Optional[str] = None
|
|
85
|
+
member: Optional[str] = None
|
|
86
|
+
repo: Optional[str] = None
|
|
87
|
+
branch: Optional[str] = None
|
|
88
|
+
role: Optional[str] = None
|
|
89
|
+
registered_at: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WorkspaceInfo(BaseModel):
|
|
93
|
+
workspace_id: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RegisterAgentResponse(BaseModel):
|
|
97
|
+
agent: AgentInfo
|
|
98
|
+
workspace: WorkspaceInfo
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AgentView(BaseModel):
|
|
102
|
+
agent_id: str
|
|
103
|
+
alias: str
|
|
104
|
+
human_name: Optional[str] = None
|
|
105
|
+
agent_type: Optional[str] = None
|
|
106
|
+
status: str = "offline"
|
|
107
|
+
last_seen: Optional[str] = None
|
|
108
|
+
online: bool = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ListAgentsResponse(BaseModel):
|
|
112
|
+
project_id: str
|
|
113
|
+
agents: list[AgentView]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SuggestAliasPrefixRequest(BaseModel):
|
|
117
|
+
project_slug: str = Field(..., min_length=1, max_length=256)
|
|
118
|
+
|
|
119
|
+
@field_validator("project_slug")
|
|
120
|
+
@classmethod
|
|
121
|
+
def validate_project_slug(cls, v: str) -> str:
|
|
122
|
+
return validate_project_slug(v.strip())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SuggestAliasPrefixResponse(BaseModel):
|
|
126
|
+
project_slug: str
|
|
127
|
+
project_id: str | None
|
|
128
|
+
name_prefix: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.post("/suggest-alias-prefix", response_model=SuggestAliasPrefixResponse)
|
|
132
|
+
async def suggest_alias_prefix(
|
|
133
|
+
payload: SuggestAliasPrefixRequest,
|
|
134
|
+
db_infra: DatabaseInfra = Depends(get_db_infra),
|
|
135
|
+
) -> SuggestAliasPrefixResponse:
|
|
136
|
+
"""Suggest the next available classic alias prefix for a project.
|
|
137
|
+
|
|
138
|
+
aweb-protocol compatibility: this endpoint is intentionally unauthenticated so new
|
|
139
|
+
agents can bootstrap cleanly in OSS mode.
|
|
140
|
+
"""
|
|
141
|
+
aweb_db = db_infra.get_manager("aweb")
|
|
142
|
+
row = await aweb_db.fetch_one(
|
|
143
|
+
"""
|
|
144
|
+
SELECT project_id, slug
|
|
145
|
+
FROM {{tables.projects}}
|
|
146
|
+
WHERE slug = $1 AND deleted_at IS NULL
|
|
147
|
+
""",
|
|
148
|
+
payload.project_slug,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if row is None:
|
|
152
|
+
return SuggestAliasPrefixResponse(
|
|
153
|
+
project_slug=payload.project_slug,
|
|
154
|
+
project_id=None,
|
|
155
|
+
name_prefix="alice",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
project_id = str(row["project_id"])
|
|
159
|
+
aliases = await aweb_db.fetch_all(
|
|
160
|
+
"""
|
|
161
|
+
SELECT alias
|
|
162
|
+
FROM {{tables.agents}}
|
|
163
|
+
WHERE project_id = $1 AND deleted_at IS NULL
|
|
164
|
+
ORDER BY alias
|
|
165
|
+
""",
|
|
166
|
+
UUID(project_id),
|
|
167
|
+
)
|
|
168
|
+
name_prefix = suggest_next_name_prefix([r.get("alias") or "" for r in aliases])
|
|
169
|
+
if name_prefix is None:
|
|
170
|
+
raise HTTPException(status_code=409, detail="alias_exhausted")
|
|
171
|
+
|
|
172
|
+
return SuggestAliasPrefixResponse(
|
|
173
|
+
project_slug=payload.project_slug,
|
|
174
|
+
project_id=project_id,
|
|
175
|
+
name_prefix=name_prefix,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@router.get("", response_model=ListAgentsResponse)
|
|
180
|
+
async def list_agents(
|
|
181
|
+
request: Request,
|
|
182
|
+
redis: Redis = Depends(get_redis),
|
|
183
|
+
db_infra: DatabaseInfra = Depends(get_db_infra),
|
|
184
|
+
) -> ListAgentsResponse:
|
|
185
|
+
"""List agents in the current project with best-effort presence enrichment."""
|
|
186
|
+
project_id = await get_project_from_auth(request, db_infra)
|
|
187
|
+
|
|
188
|
+
aweb_db = db_infra.get_manager("aweb")
|
|
189
|
+
rows = await aweb_db.fetch_all(
|
|
190
|
+
"""
|
|
191
|
+
SELECT agent_id, alias, human_name, agent_type
|
|
192
|
+
FROM {{tables.agents}}
|
|
193
|
+
WHERE project_id = $1 AND deleted_at IS NULL
|
|
194
|
+
ORDER BY alias ASC
|
|
195
|
+
""",
|
|
196
|
+
UUID(project_id),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
agent_ids = [str(r["agent_id"]) for r in rows]
|
|
200
|
+
presences = await list_agent_presences_by_workspace_ids(redis, agent_ids) if agent_ids else []
|
|
201
|
+
presence_by_id = {str(p.get("workspace_id")): p for p in presences if p.get("workspace_id")}
|
|
202
|
+
|
|
203
|
+
agents: list[AgentView] = []
|
|
204
|
+
for r in rows:
|
|
205
|
+
agent_id = str(r["agent_id"])
|
|
206
|
+
presence = presence_by_id.get(agent_id)
|
|
207
|
+
status = "offline"
|
|
208
|
+
last_seen = None
|
|
209
|
+
online = False
|
|
210
|
+
if presence and presence.get("project_id") == project_id:
|
|
211
|
+
online = True
|
|
212
|
+
status = presence.get("status") or "active"
|
|
213
|
+
last_seen = presence.get("last_seen") or None
|
|
214
|
+
|
|
215
|
+
agents.append(
|
|
216
|
+
AgentView(
|
|
217
|
+
agent_id=agent_id,
|
|
218
|
+
alias=r["alias"],
|
|
219
|
+
human_name=(r.get("human_name") or None),
|
|
220
|
+
agent_type=(r.get("agent_type") or None),
|
|
221
|
+
status=status,
|
|
222
|
+
last_seen=last_seen,
|
|
223
|
+
online=online,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return ListAgentsResponse(project_id=project_id, agents=agents)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.post("/register", response_model=RegisterAgentResponse)
|
|
231
|
+
async def register_agent(
|
|
232
|
+
request: Request,
|
|
233
|
+
payload: RegisterAgentRequest,
|
|
234
|
+
redis: Redis = Depends(get_redis),
|
|
235
|
+
db_infra: DatabaseInfra = Depends(get_db_infra),
|
|
236
|
+
) -> RegisterAgentResponse:
|
|
237
|
+
"""
|
|
238
|
+
Register an agent and record presence for its workspace.
|
|
239
|
+
|
|
240
|
+
Presence is a cache of SQL; the workspace must exist and be accessible under
|
|
241
|
+
the authenticated project.
|
|
242
|
+
"""
|
|
243
|
+
settings = get_settings()
|
|
244
|
+
|
|
245
|
+
project_id = await verify_workspace_access(request, payload.workspace_id, db_infra)
|
|
246
|
+
|
|
247
|
+
# Fetch workspace metadata for presence indexing (project_slug, repo_id) and to avoid
|
|
248
|
+
# trusting client-supplied identifiers.
|
|
249
|
+
server_db = db_infra.get_manager("server")
|
|
250
|
+
workspace_row = await server_db.fetch_one(
|
|
251
|
+
"""
|
|
252
|
+
SELECT
|
|
253
|
+
w.alias,
|
|
254
|
+
w.human_name,
|
|
255
|
+
w.role,
|
|
256
|
+
w.repo_id,
|
|
257
|
+
p.slug AS project_slug
|
|
258
|
+
FROM {{tables.workspaces}} w
|
|
259
|
+
JOIN {{tables.projects}} p ON p.id = w.project_id AND p.deleted_at IS NULL
|
|
260
|
+
WHERE w.workspace_id = $1 AND w.deleted_at IS NULL
|
|
261
|
+
""",
|
|
262
|
+
UUID(payload.workspace_id),
|
|
263
|
+
)
|
|
264
|
+
if not workspace_row:
|
|
265
|
+
# Should be prevented by verify_workspace_access, but keep a defensive error here.
|
|
266
|
+
raise HTTPException(status_code=422, detail="Workspace not found")
|
|
267
|
+
|
|
268
|
+
alias = workspace_row["alias"]
|
|
269
|
+
project_slug = workspace_row["project_slug"]
|
|
270
|
+
repo_id = str(workspace_row["repo_id"]) if workspace_row.get("repo_id") else None
|
|
271
|
+
human_name = payload.human_name or (workspace_row.get("human_name") or None)
|
|
272
|
+
role = payload.role or (workspace_row.get("role") or None)
|
|
273
|
+
|
|
274
|
+
registered_at = await update_agent_presence(
|
|
275
|
+
redis,
|
|
276
|
+
workspace_id=payload.workspace_id,
|
|
277
|
+
alias=alias,
|
|
278
|
+
human_name=human_name,
|
|
279
|
+
project_id=project_id,
|
|
280
|
+
project_slug=project_slug,
|
|
281
|
+
repo_id=repo_id,
|
|
282
|
+
program=payload.program,
|
|
283
|
+
model=payload.model,
|
|
284
|
+
current_branch=payload.branch,
|
|
285
|
+
role=role,
|
|
286
|
+
ttl_seconds=payload.ttl_seconds or settings.presence_ttl_seconds,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
agent = AgentInfo(
|
|
290
|
+
alias=alias,
|
|
291
|
+
human_name=human_name,
|
|
292
|
+
project_slug=project_slug,
|
|
293
|
+
program=payload.program,
|
|
294
|
+
model=payload.model,
|
|
295
|
+
member=None,
|
|
296
|
+
repo=payload.repo,
|
|
297
|
+
branch=payload.branch,
|
|
298
|
+
role=role,
|
|
299
|
+
registered_at=registered_at,
|
|
300
|
+
)
|
|
301
|
+
workspace = WorkspaceInfo(workspace_id=payload.workspace_id)
|
|
302
|
+
|
|
303
|
+
return RegisterAgentResponse(agent=agent, workspace=workspace)
|