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,362 @@
1
+ """Bead subscription endpoints for notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from typing import List, Optional
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
10
+ from pydantic import BaseModel, Field, field_validator
11
+
12
+ from beadhub.auth import validate_workspace_id
13
+ from beadhub.aweb_introspection import get_identity_from_auth
14
+
15
+ from ..beads_sync import is_valid_alias
16
+ from ..db import DatabaseInfra, get_db_infra
17
+
18
+ # Bead ID pattern: optionally namespaced "repo:bead-id" or just "bead-id"
19
+ # Each part is alphanumeric with hyphens, 1-100 chars
20
+ _BEAD_ID_PART = r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}"
21
+ _BEAD_ID_PATTERN = re.compile(rf"^({_BEAD_ID_PART}:)?{_BEAD_ID_PART}$")
22
+
23
+
24
+ router = APIRouter(prefix="/v1/subscriptions", tags=["subscriptions"])
25
+
26
+
27
+ def _validate_workspace_id_field(v: str) -> str:
28
+ """Pydantic validator wrapper for workspace_id."""
29
+ try:
30
+ return validate_workspace_id(v)
31
+ except ValueError as e:
32
+ raise ValueError(str(e))
33
+
34
+
35
+ def _validate_alias_field(v: str) -> str:
36
+ """Pydantic validator wrapper for alias."""
37
+ if not is_valid_alias(v):
38
+ raise ValueError("Invalid alias: must be alphanumeric with hyphens/underscores, 1-64 chars")
39
+ return v
40
+
41
+
42
+ class SubscribeRequest(BaseModel):
43
+ workspace_id: str = Field(..., min_length=1)
44
+ alias: str = Field(..., min_length=1, max_length=64)
45
+ bead_id: str = Field(..., min_length=1)
46
+ repo: Optional[str] = None
47
+ event_types: List[str] = Field(default=["status_change"])
48
+
49
+ @field_validator("workspace_id")
50
+ @classmethod
51
+ def validate_workspace_id(cls, v: str) -> str:
52
+ return _validate_workspace_id_field(v)
53
+
54
+ @field_validator("alias")
55
+ @classmethod
56
+ def validate_alias(cls, v: str) -> str:
57
+ return _validate_alias_field(v)
58
+
59
+
60
+ class SubscribeResponse(BaseModel):
61
+ subscription_id: str
62
+ workspace_id: str
63
+ alias: str
64
+ bead_id: str
65
+ repo: Optional[str] = None
66
+ event_types: List[str]
67
+ created_at: Optional[str] = None
68
+
69
+
70
+ class SubscriptionInfo(BaseModel):
71
+ subscription_id: str
72
+ workspace_id: str
73
+ alias: str
74
+ bead_id: str
75
+ repo: Optional[str] = None
76
+ event_types: List[str]
77
+ created_at: str
78
+
79
+
80
+ class ListSubscriptionsResponse(BaseModel):
81
+ subscriptions: List[SubscriptionInfo]
82
+ count: int
83
+
84
+
85
+ class UnsubscribeResponse(BaseModel):
86
+ subscription_id: str
87
+ deleted: bool
88
+
89
+
90
+ @router.post("", response_model=SubscribeResponse)
91
+ async def subscribe(
92
+ request: Request,
93
+ payload: SubscribeRequest,
94
+ db_infra: DatabaseInfra = Depends(get_db_infra),
95
+ ) -> SubscribeResponse:
96
+ """Subscribe to receive notifications when a bead changes.
97
+
98
+ Requires an authenticated project context.
99
+ """
100
+ db = db_infra.get_manager("server")
101
+ identity = await get_identity_from_auth(request, db_infra)
102
+ project_id = identity.project_id
103
+ if identity.agent_id is not None and identity.agent_id != payload.workspace_id:
104
+ raise HTTPException(status_code=403, detail="workspace_id does not match API key identity")
105
+
106
+ # Validate bead_id format
107
+ if not _BEAD_ID_PATTERN.match(payload.bead_id):
108
+ raise HTTPException(
109
+ status_code=400,
110
+ detail=f"Invalid bead_id format: {payload.bead_id[:100]}",
111
+ )
112
+
113
+ # Validate event_types
114
+ # Currently only 'status_change' is implemented; others reserved for future use
115
+ valid_events = {"status_change", "priority_change", "assignee_change", "all"}
116
+ for event_type in payload.event_types:
117
+ if event_type not in valid_events:
118
+ raise HTTPException(
119
+ status_code=400,
120
+ detail=f"Invalid event_type: {event_type}. Valid: {valid_events}",
121
+ )
122
+
123
+ # Validate workspace exists and alias matches (tenant isolation)
124
+ workspace = await db.fetch_one(
125
+ """
126
+ SELECT workspace_id, alias
127
+ FROM {{tables.workspaces}}
128
+ WHERE workspace_id = $1 AND project_id = $2 AND deleted_at IS NULL
129
+ """,
130
+ uuid.UUID(payload.workspace_id),
131
+ uuid.UUID(project_id),
132
+ )
133
+ if not workspace:
134
+ raise HTTPException(
135
+ status_code=403,
136
+ detail="Workspace not found or does not belong to your project",
137
+ )
138
+ if workspace["alias"] != payload.alias:
139
+ raise HTTPException(
140
+ status_code=403,
141
+ detail="Alias does not match workspace_id",
142
+ )
143
+
144
+ # Use upsert to handle duplicate subscriptions (idempotent)
145
+ subscription_id = str(uuid.uuid4())
146
+ sql = """
147
+ INSERT INTO {{tables.subscriptions}}
148
+ (id, project_id, workspace_id, alias, bead_id, repo, event_types)
149
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
150
+ ON CONFLICT (project_id, workspace_id, bead_id, COALESCE(repo, ''))
151
+ DO UPDATE SET event_types = $7
152
+ RETURNING id, event_types, created_at
153
+ """
154
+ row = await db.fetch_one(
155
+ sql,
156
+ uuid.UUID(subscription_id),
157
+ uuid.UUID(project_id),
158
+ uuid.UUID(payload.workspace_id),
159
+ payload.alias,
160
+ payload.bead_id,
161
+ payload.repo,
162
+ payload.event_types,
163
+ )
164
+
165
+ return SubscribeResponse(
166
+ subscription_id=str(row["id"]),
167
+ workspace_id=payload.workspace_id,
168
+ alias=payload.alias,
169
+ bead_id=payload.bead_id,
170
+ repo=payload.repo,
171
+ event_types=list(row["event_types"]),
172
+ created_at=row["created_at"].isoformat() if row.get("created_at") else None,
173
+ )
174
+
175
+
176
+ @router.get("", response_model=ListSubscriptionsResponse)
177
+ async def list_subscriptions(
178
+ request: Request,
179
+ workspace_id: str = Query(..., min_length=1),
180
+ alias: str = Query(..., min_length=1, max_length=64),
181
+ db_infra: DatabaseInfra = Depends(get_db_infra),
182
+ ) -> ListSubscriptionsResponse:
183
+ """List all subscriptions for an agent.
184
+
185
+ Requires an authenticated project context.
186
+ """
187
+ identity = await get_identity_from_auth(request, db_infra)
188
+ project_id = identity.project_id
189
+ # Validate workspace_id
190
+ try:
191
+ workspace_id = validate_workspace_id(workspace_id)
192
+ except ValueError as e:
193
+ raise HTTPException(status_code=422, detail=str(e))
194
+ if identity.agent_id is not None and identity.agent_id != workspace_id:
195
+ raise HTTPException(status_code=403, detail="workspace_id does not match API key identity")
196
+
197
+ db = db_infra.get_manager("server")
198
+
199
+ workspace = await db.fetch_one(
200
+ """
201
+ SELECT workspace_id, alias
202
+ FROM {{tables.workspaces}}
203
+ WHERE workspace_id = $1 AND project_id = $2 AND deleted_at IS NULL
204
+ """,
205
+ uuid.UUID(workspace_id),
206
+ uuid.UUID(project_id),
207
+ )
208
+ if not workspace:
209
+ raise HTTPException(
210
+ status_code=403,
211
+ detail="Workspace not found or does not belong to your project",
212
+ )
213
+ if workspace["alias"] != alias:
214
+ raise HTTPException(
215
+ status_code=403,
216
+ detail="Alias does not match workspace_id",
217
+ )
218
+
219
+ sql = """
220
+ SELECT id, workspace_id, alias, bead_id, repo, event_types, created_at
221
+ FROM {{tables.subscriptions}}
222
+ WHERE project_id = $1 AND workspace_id = $2
223
+ ORDER BY created_at DESC
224
+ """
225
+ rows = await db.fetch_all(sql, uuid.UUID(project_id), uuid.UUID(workspace_id))
226
+
227
+ subscriptions = [
228
+ SubscriptionInfo(
229
+ subscription_id=str(row["id"]),
230
+ workspace_id=str(row["workspace_id"]),
231
+ alias=row["alias"],
232
+ bead_id=row["bead_id"],
233
+ repo=row["repo"],
234
+ event_types=list(row["event_types"]),
235
+ created_at=row["created_at"].isoformat(),
236
+ )
237
+ for row in rows
238
+ ]
239
+
240
+ return ListSubscriptionsResponse(subscriptions=subscriptions, count=len(subscriptions))
241
+
242
+
243
+ @router.delete("/{subscription_id}", response_model=UnsubscribeResponse)
244
+ async def unsubscribe(
245
+ request: Request,
246
+ subscription_id: str = Path(...),
247
+ workspace_id: str = Query(..., min_length=1),
248
+ alias: str = Query(..., min_length=1, max_length=64),
249
+ db_infra: DatabaseInfra = Depends(get_db_infra),
250
+ ) -> UnsubscribeResponse:
251
+ """Unsubscribe from a bead.
252
+
253
+ Requires an authenticated project context.
254
+ """
255
+ identity = await get_identity_from_auth(request, db_infra)
256
+ project_id = identity.project_id
257
+ # Validate workspace_id
258
+ try:
259
+ workspace_id = validate_workspace_id(workspace_id)
260
+ except ValueError as e:
261
+ raise HTTPException(status_code=422, detail=str(e))
262
+ if identity.agent_id is not None and identity.agent_id != workspace_id:
263
+ raise HTTPException(status_code=403, detail="workspace_id does not match API key identity")
264
+
265
+ db = db_infra.get_manager("server")
266
+
267
+ try:
268
+ sub_uuid = uuid.UUID(subscription_id)
269
+ except ValueError:
270
+ raise HTTPException(status_code=400, detail="Invalid subscription_id format")
271
+
272
+ workspace = await db.fetch_one(
273
+ """
274
+ SELECT workspace_id, alias
275
+ FROM {{tables.workspaces}}
276
+ WHERE workspace_id = $1 AND project_id = $2 AND deleted_at IS NULL
277
+ """,
278
+ uuid.UUID(workspace_id),
279
+ uuid.UUID(project_id),
280
+ )
281
+ if not workspace:
282
+ raise HTTPException(
283
+ status_code=403,
284
+ detail="Workspace not found or does not belong to your project",
285
+ )
286
+ if workspace["alias"] != alias:
287
+ raise HTTPException(
288
+ status_code=403,
289
+ detail="Alias does not match workspace_id",
290
+ )
291
+
292
+ # Delete only if owned by this agent within same project (tenant isolation)
293
+ sql = """
294
+ DELETE FROM {{tables.subscriptions}}
295
+ WHERE id = $1 AND project_id = $2 AND workspace_id = $3 AND alias = $4
296
+ RETURNING id
297
+ """
298
+ row = await db.fetch_one(
299
+ sql,
300
+ sub_uuid,
301
+ uuid.UUID(project_id),
302
+ uuid.UUID(workspace_id),
303
+ alias,
304
+ )
305
+
306
+ if not row:
307
+ raise HTTPException(status_code=404, detail="Subscription not found")
308
+
309
+ return UnsubscribeResponse(subscription_id=subscription_id, deleted=True)
310
+
311
+
312
+ async def get_subscribers_for_bead(
313
+ db_infra: DatabaseInfra,
314
+ project_id: str,
315
+ bead_id: str,
316
+ event_type: str,
317
+ repo: Optional[str] = None,
318
+ ) -> List[dict]:
319
+ """Get all agents subscribed to a bead for a specific event type.
320
+
321
+ Used by the sync process to send notifications.
322
+ Filters by project_id for tenant isolation, then matches by bead_id
323
+ and optionally by repo for more precise matching.
324
+
325
+ Args:
326
+ db_infra: Database infrastructure
327
+ project_id: Project UUID for tenant isolation (required)
328
+ bead_id: The bead ID to find subscribers for
329
+ event_type: Event type to match (e.g., "status_change")
330
+ repo: Optional repo filter for more precise matching
331
+ """
332
+ db = db_infra.get_manager("server")
333
+ project_uuid = uuid.UUID(project_id)
334
+
335
+ if repo:
336
+ sql = """
337
+ SELECT workspace_id, alias, repo
338
+ FROM {{tables.subscriptions}}
339
+ WHERE project_id = $1
340
+ AND bead_id = $2
341
+ AND repo = $3
342
+ AND ($4 = ANY(event_types) OR 'all' = ANY(event_types))
343
+ """
344
+ rows = await db.fetch_all(sql, project_uuid, bead_id, repo, event_type)
345
+ else:
346
+ sql = """
347
+ SELECT workspace_id, alias, repo
348
+ FROM {{tables.subscriptions}}
349
+ WHERE project_id = $1
350
+ AND bead_id = $2
351
+ AND ($3 = ANY(event_types) OR 'all' = ANY(event_types))
352
+ """
353
+ rows = await db.fetch_all(sql, project_uuid, bead_id, event_type)
354
+
355
+ return [
356
+ {
357
+ "workspace_id": str(row["workspace_id"]),
358
+ "alias": row["alias"],
359
+ "repo": row["repo"],
360
+ }
361
+ for row in rows
362
+ ]