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.
Files changed (54) hide show
  1. beadhub/__init__.py +12 -0
  2. beadhub/api.py +260 -0
  3. beadhub/auth.py +101 -0
  4. beadhub/aweb_context.py +65 -0
  5. beadhub/aweb_introspection.py +70 -0
  6. beadhub/beads_sync.py +514 -0
  7. beadhub/cli.py +330 -0
  8. beadhub/config.py +65 -0
  9. beadhub/db.py +129 -0
  10. beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
  11. beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
  12. beadhub/defaults/invariants/03-communication-chat.md +60 -0
  13. beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
  14. beadhub/defaults/invariants/05-collaborate.md +12 -0
  15. beadhub/defaults/roles/backend.md +55 -0
  16. beadhub/defaults/roles/coordinator.md +44 -0
  17. beadhub/defaults/roles/frontend.md +77 -0
  18. beadhub/defaults/roles/implementer.md +73 -0
  19. beadhub/defaults/roles/reviewer.md +56 -0
  20. beadhub/defaults/roles/startup-expert.md +93 -0
  21. beadhub/defaults.py +262 -0
  22. beadhub/events.py +704 -0
  23. beadhub/internal_auth.py +121 -0
  24. beadhub/jsonl.py +68 -0
  25. beadhub/logging.py +62 -0
  26. beadhub/migrations/beads/001_initial.sql +70 -0
  27. beadhub/migrations/beads/002_search_indexes.sql +20 -0
  28. beadhub/migrations/server/001_initial.sql +279 -0
  29. beadhub/names.py +33 -0
  30. beadhub/notifications.py +275 -0
  31. beadhub/pagination.py +125 -0
  32. beadhub/presence.py +495 -0
  33. beadhub/rate_limit.py +152 -0
  34. beadhub/redis_client.py +11 -0
  35. beadhub/roles.py +35 -0
  36. beadhub/routes/__init__.py +1 -0
  37. beadhub/routes/agents.py +303 -0
  38. beadhub/routes/bdh.py +655 -0
  39. beadhub/routes/beads.py +778 -0
  40. beadhub/routes/claims.py +141 -0
  41. beadhub/routes/escalations.py +471 -0
  42. beadhub/routes/init.py +348 -0
  43. beadhub/routes/mcp.py +338 -0
  44. beadhub/routes/policies.py +833 -0
  45. beadhub/routes/repos.py +538 -0
  46. beadhub/routes/status.py +568 -0
  47. beadhub/routes/subscriptions.py +362 -0
  48. beadhub/routes/workspaces.py +1642 -0
  49. beadhub/workspace_config.py +202 -0
  50. beadhub-0.1.0.dist-info/METADATA +254 -0
  51. beadhub-0.1.0.dist-info/RECORD +54 -0
  52. beadhub-0.1.0.dist-info/WHEEL +4 -0
  53. beadhub-0.1.0.dist-info/entry_points.txt +2 -0
  54. beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)