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
|
@@ -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
|
+
]
|