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,141 @@
1
+ """Claims API - View active bead claims."""
2
+
3
+ from datetime import datetime
4
+ from typing import List, Optional
5
+ from uuid import UUID
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
8
+ from pydantic import BaseModel
9
+
10
+ from beadhub.auth import validate_workspace_id
11
+ from beadhub.aweb_introspection import get_project_from_auth
12
+
13
+ from ..db import DatabaseInfra, get_db_infra
14
+ from ..pagination import encode_cursor, validate_pagination_params
15
+
16
+ router = APIRouter(prefix="/v1", tags=["claims"])
17
+
18
+
19
+ class Claim(BaseModel):
20
+ """A bead claim - indicates a workspace is working on a bead."""
21
+
22
+ bead_id: str
23
+ workspace_id: str
24
+ alias: str
25
+ human_name: str
26
+ claimed_at: str
27
+ project_id: str
28
+
29
+
30
+ class ClaimsResponse(BaseModel):
31
+ """Response for GET /v1/claims."""
32
+
33
+ claims: List[Claim]
34
+ has_more: bool = False
35
+ next_cursor: Optional[str] = None
36
+
37
+
38
+ @router.get("/claims")
39
+ async def list_claims(
40
+ request: Request,
41
+ workspace_id: Optional[str] = Query(None, description="Filter to specific workspace"),
42
+ limit: Optional[int] = Query(None, description="Maximum items per page", ge=1, le=200),
43
+ cursor: Optional[str] = Query(None, description="Pagination cursor from previous response"),
44
+ db_infra: DatabaseInfra = Depends(get_db_infra),
45
+ ) -> ClaimsResponse:
46
+ """
47
+ List active bead claims for a project.
48
+
49
+ Claims indicate which workspaces are actively working on which beads.
50
+ When an agent runs `bdh update <bead_id> --status in_progress`, they
51
+ claim that bead exclusively within the project.
52
+
53
+ Args:
54
+ workspace_id: Optional. Filter to claims by a specific workspace.
55
+ limit: Maximum number of claims to return (default 50, max 200).
56
+ cursor: Pagination cursor from previous response for fetching next page.
57
+
58
+ Returns:
59
+ List of active claims with bead_id, workspace info, and claim time.
60
+ Ordered by most recently claimed first.
61
+ Includes has_more and next_cursor for pagination.
62
+ """
63
+ project_id = await get_project_from_auth(request, db_infra)
64
+
65
+ server_db = db_infra.get_manager("server")
66
+
67
+ # Validate pagination params
68
+ try:
69
+ validated_limit, cursor_data = validate_pagination_params(limit, cursor)
70
+ except ValueError as e:
71
+ raise HTTPException(status_code=422, detail=str(e))
72
+
73
+ # Validate workspace_id if provided
74
+ validated_workspace_id = None
75
+ if workspace_id:
76
+ try:
77
+ validated_workspace_id = validate_workspace_id(workspace_id)
78
+ except ValueError as e:
79
+ raise HTTPException(status_code=422, detail=str(e))
80
+
81
+ # Build query with cursor-based pagination
82
+ conditions: list[str] = []
83
+ params: list[object] = []
84
+ param_idx = 1
85
+
86
+ conditions.append(f"project_id = ${param_idx}")
87
+ params.append(UUID(project_id))
88
+ param_idx += 1
89
+
90
+ if validated_workspace_id:
91
+ conditions.append(f"workspace_id = ${param_idx}")
92
+ params.append(validated_workspace_id)
93
+ param_idx += 1
94
+
95
+ # Apply cursor (claimed_at < cursor_timestamp for DESC order)
96
+ if cursor_data and "claimed_at" in cursor_data:
97
+ try:
98
+ cursor_timestamp = datetime.fromisoformat(cursor_data["claimed_at"])
99
+ except (ValueError, TypeError) as e:
100
+ raise HTTPException(status_code=422, detail=f"Invalid cursor timestamp: {e}")
101
+ conditions.append(f"claimed_at < ${param_idx}")
102
+ params.append(cursor_timestamp)
103
+ param_idx += 1
104
+
105
+ # Fetch limit + 1 to detect has_more
106
+ params.append(validated_limit + 1)
107
+
108
+ where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
109
+ query = f"""
110
+ SELECT bead_id, workspace_id, alias, human_name, claimed_at, project_id
111
+ FROM {{{{tables.bead_claims}}}}
112
+ {where_clause}
113
+ ORDER BY claimed_at DESC
114
+ LIMIT ${param_idx}
115
+ """
116
+
117
+ rows = await server_db.fetch_all(query, *params)
118
+
119
+ # Check if there are more results
120
+ has_more = len(rows) > validated_limit
121
+ rows = rows[:validated_limit] # Trim to requested limit
122
+
123
+ claims = [
124
+ Claim(
125
+ bead_id=row["bead_id"],
126
+ workspace_id=str(row["workspace_id"]),
127
+ alias=row["alias"],
128
+ human_name=row["human_name"],
129
+ claimed_at=row["claimed_at"].isoformat(),
130
+ project_id=str(row["project_id"]),
131
+ )
132
+ for row in rows
133
+ ]
134
+
135
+ # Generate next_cursor if there are more results
136
+ next_cursor = None
137
+ if has_more and claims:
138
+ last_claim = claims[-1]
139
+ next_cursor = encode_cursor({"claimed_at": last_claim.claimed_at})
140
+
141
+ return ClaimsResponse(claims=claims, has_more=has_more, next_cursor=next_cursor)
@@ -0,0 +1,471 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import List, Optional
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
9
+ from pydantic import BaseModel, Field, field_validator
10
+ from redis.asyncio import Redis
11
+
12
+ from beadhub.auth import validate_workspace_id
13
+ from beadhub.aweb_introspection import get_identity_from_auth, get_project_from_auth
14
+
15
+ from ..beads_sync import is_valid_alias
16
+ from ..db import DatabaseInfra, get_db_infra
17
+ from ..events import EscalationCreatedEvent, EscalationRespondedEvent, publish_event
18
+ from ..pagination import encode_cursor, validate_pagination_params
19
+ from ..presence import get_workspace_project_slug
20
+ from ..redis_client import get_redis
21
+
22
+ router = APIRouter(prefix="/v1/escalations", tags=["escalations"])
23
+
24
+ # Valid escalation status values
25
+ VALID_ESCALATION_STATUSES = frozenset({"pending", "responded", "expired"})
26
+
27
+ # Error message for invalid alias format
28
+ INVALID_ALIAS_MESSAGE = "Invalid alias: must be alphanumeric with hyphens/underscores, 1-64 chars"
29
+
30
+
31
+ def _validate_workspace_id_field(v: str) -> str:
32
+ """Pydantic validator wrapper for workspace_id."""
33
+ try:
34
+ return validate_workspace_id(v)
35
+ except ValueError as e:
36
+ raise ValueError(str(e))
37
+
38
+
39
+ def _validate_alias_field(v: str) -> str:
40
+ """Pydantic validator wrapper for alias."""
41
+ if not is_valid_alias(v):
42
+ raise ValueError(INVALID_ALIAS_MESSAGE)
43
+ return v
44
+
45
+
46
+ class CreateEscalationRequest(BaseModel):
47
+ workspace_id: str = Field(..., min_length=1)
48
+ alias: str = Field(..., min_length=1, max_length=64)
49
+ subject: str = Field(..., min_length=1)
50
+ situation: str = Field(..., min_length=1)
51
+ options: Optional[List[str]] = None
52
+ expires_in_hours: int = Field(4, gt=0)
53
+ member_email: Optional[str] = Field(None, description="Email of team member to notify")
54
+
55
+ @field_validator("workspace_id")
56
+ @classmethod
57
+ def validate_workspace_id(cls, v: str) -> str:
58
+ return _validate_workspace_id_field(v)
59
+
60
+ @field_validator("alias")
61
+ @classmethod
62
+ def validate_alias(cls, v: str) -> str:
63
+ return _validate_alias_field(v)
64
+
65
+
66
+ class EscalationSummary(BaseModel):
67
+ escalation_id: str
68
+ alias: str
69
+ subject: str
70
+ status: str
71
+ created_at: str
72
+ expires_at: Optional[str] = None
73
+
74
+
75
+ class CreateEscalationResponse(BaseModel):
76
+ escalation_id: str
77
+ status: str
78
+ created_at: str
79
+ expires_at: Optional[str] = None
80
+
81
+
82
+ class EscalationDetail(BaseModel):
83
+ escalation_id: str
84
+ workspace_id: str
85
+ alias: str
86
+ member_email: Optional[str] = None
87
+ subject: str
88
+ situation: str
89
+ options: Optional[List[str]] = None
90
+ status: str
91
+ response: Optional[str] = None
92
+ response_note: Optional[str] = None
93
+ created_at: str
94
+ responded_at: Optional[str] = None
95
+ expires_at: Optional[str] = None
96
+
97
+
98
+ class ListEscalationsResponse(BaseModel):
99
+ escalations: List[EscalationSummary]
100
+ has_more: bool = False
101
+ next_cursor: Optional[str] = None
102
+
103
+
104
+ class RespondEscalationRequest(BaseModel):
105
+ response: str = Field(..., min_length=1)
106
+ note: Optional[str] = None
107
+
108
+
109
+ class RespondEscalationResponse(BaseModel):
110
+ escalation_id: str
111
+ status: str
112
+ response: str
113
+ response_note: Optional[str] = None
114
+ responded_at: str
115
+
116
+
117
+ @router.post("", response_model=CreateEscalationResponse)
118
+ async def create_escalation(
119
+ request: Request,
120
+ payload: CreateEscalationRequest,
121
+ db_infra: DatabaseInfra = Depends(get_db_infra),
122
+ redis: Redis = Depends(get_redis),
123
+ ) -> CreateEscalationResponse:
124
+ db = db_infra.get_manager("server")
125
+ identity = await get_identity_from_auth(request, db_infra)
126
+ project_id = identity.project_id
127
+ if identity.agent_id is not None and identity.agent_id != payload.workspace_id:
128
+ raise HTTPException(status_code=403, detail="workspace_id does not match API key identity")
129
+
130
+ workspace = await db.fetch_one(
131
+ """
132
+ SELECT workspace_id, project_id, alias
133
+ FROM {{tables.workspaces}}
134
+ WHERE workspace_id = $1 AND deleted_at IS NULL
135
+ """,
136
+ payload.workspace_id,
137
+ )
138
+ if not workspace:
139
+ raise HTTPException(
140
+ status_code=403,
141
+ detail="Workspace not found or does not belong to your project",
142
+ )
143
+ if workspace["alias"] != payload.alias:
144
+ raise HTTPException(
145
+ status_code=403,
146
+ detail="Alias does not match workspace_id",
147
+ )
148
+
149
+ workspace_project_id = str(workspace["project_id"])
150
+ if project_id != workspace_project_id:
151
+ raise HTTPException(
152
+ status_code=403,
153
+ detail="Workspace not found or does not belong to your project",
154
+ )
155
+
156
+ now = datetime.now(timezone.utc)
157
+ expires_at = now + timedelta(hours=payload.expires_in_hours)
158
+
159
+ row = await db.fetch_one(
160
+ """
161
+ INSERT INTO {{tables.escalations}} (
162
+ project_id,
163
+ workspace_id,
164
+ alias,
165
+ member_email,
166
+ subject,
167
+ situation,
168
+ options,
169
+ status,
170
+ created_at,
171
+ expires_at
172
+ )
173
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8, $9)
174
+ RETURNING id, status, created_at, expires_at
175
+ """,
176
+ project_id,
177
+ payload.workspace_id,
178
+ payload.alias,
179
+ payload.member_email,
180
+ payload.subject,
181
+ payload.situation,
182
+ json.dumps(payload.options) if payload.options else None,
183
+ now,
184
+ expires_at,
185
+ )
186
+
187
+ # Publish event
188
+ project_slug = await get_workspace_project_slug(redis, payload.workspace_id)
189
+ event = EscalationCreatedEvent(
190
+ workspace_id=payload.workspace_id,
191
+ escalation_id=str(row["id"]),
192
+ alias=payload.alias,
193
+ subject=payload.subject,
194
+ project_slug=project_slug,
195
+ )
196
+ await publish_event(redis, event)
197
+
198
+ return CreateEscalationResponse(
199
+ escalation_id=str(row["id"]),
200
+ status=row["status"],
201
+ created_at=row["created_at"].isoformat(),
202
+ expires_at=row["expires_at"].isoformat() if row["expires_at"] else None,
203
+ )
204
+
205
+
206
+ @router.get("", response_model=ListEscalationsResponse)
207
+ async def list_escalations(
208
+ request: Request,
209
+ workspace_id: Optional[str] = Query(None, min_length=1),
210
+ repo_id: Optional[str] = Query(None, min_length=36, max_length=36),
211
+ status: Optional[str] = Query(None, max_length=20),
212
+ alias: Optional[str] = Query(None, max_length=64),
213
+ limit: Optional[int] = Query(None, description="Maximum items per page", ge=1, le=200),
214
+ cursor: Optional[str] = Query(None, description="Pagination cursor from previous response"),
215
+ db_infra: DatabaseInfra = Depends(get_db_infra),
216
+ ) -> ListEscalationsResponse:
217
+ """
218
+ List escalations with cursor-based pagination.
219
+
220
+ Filter by:
221
+ - workspace_id: Show escalations for a specific workspace
222
+ - project_slug: Show escalations for all workspaces in a project
223
+ - repo_id: Show escalations for all workspaces in a repo (UUID)
224
+ - No filter: Show all escalations (OSS mode)
225
+
226
+ Args:
227
+ limit: Maximum number of escalations to return (default 50, max 200).
228
+ cursor: Pagination cursor from previous response for fetching next page.
229
+
230
+ Returns:
231
+ List of escalations ordered by most recently created first.
232
+ Includes has_more and next_cursor for pagination.
233
+ """
234
+ db = db_infra.get_manager("server")
235
+
236
+ # Validate pagination params
237
+ try:
238
+ validated_limit, cursor_data = validate_pagination_params(limit, cursor)
239
+ except ValueError as e:
240
+ raise HTTPException(status_code=422, detail=str(e))
241
+
242
+ project_id = await get_project_from_auth(request, db_infra)
243
+
244
+ conditions: list[str] = []
245
+ params: list[object] = []
246
+ param_idx = 1
247
+
248
+ conditions.append(f"project_id = ${param_idx}")
249
+ params.append(UUID(project_id))
250
+ param_idx += 1
251
+
252
+ # Determine workspace filter
253
+ if workspace_id:
254
+ try:
255
+ validated_workspace_id = validate_workspace_id(workspace_id)
256
+ workspace_check = await db.fetch_one(
257
+ """
258
+ SELECT 1 FROM {{tables.workspaces}}
259
+ WHERE workspace_id = $1 AND project_id = $2 AND deleted_at IS NULL
260
+ """,
261
+ UUID(validated_workspace_id),
262
+ UUID(project_id),
263
+ )
264
+ if not workspace_check:
265
+ raise HTTPException(
266
+ status_code=403,
267
+ detail="Workspace not found or does not belong to your project",
268
+ )
269
+ conditions.append(f"workspace_id = ${param_idx}")
270
+ params.append(UUID(validated_workspace_id))
271
+ param_idx += 1
272
+ except ValueError as e:
273
+ raise HTTPException(status_code=422, detail=str(e))
274
+ elif repo_id:
275
+ try:
276
+ repo_uuid = UUID(repo_id)
277
+ except ValueError:
278
+ raise HTTPException(status_code=422, detail="Invalid repo_id format: expected UUID")
279
+ conditions.append(
280
+ f"workspace_id IN (SELECT workspace_id FROM {{{{tables.workspaces}}}} WHERE project_id = ${param_idx} AND repo_id = ${param_idx + 1} AND deleted_at IS NULL)"
281
+ )
282
+ params.append(UUID(project_id))
283
+ params.append(repo_uuid)
284
+ param_idx += 2
285
+
286
+ if status:
287
+ if status not in VALID_ESCALATION_STATUSES:
288
+ raise HTTPException(
289
+ status_code=422,
290
+ detail=f"Invalid status: must be one of {sorted(VALID_ESCALATION_STATUSES)}",
291
+ )
292
+ conditions.append(f"status = ${param_idx}")
293
+ params.append(status)
294
+ param_idx += 1
295
+ if alias:
296
+ if not is_valid_alias(alias):
297
+ raise HTTPException(status_code=422, detail=INVALID_ALIAS_MESSAGE)
298
+ conditions.append(f"alias = ${param_idx}")
299
+ params.append(alias)
300
+ param_idx += 1
301
+
302
+ # Apply cursor (created_at < cursor_timestamp for DESC order)
303
+ if cursor_data and "created_at" in cursor_data:
304
+ try:
305
+ cursor_timestamp = datetime.fromisoformat(cursor_data["created_at"])
306
+ except (ValueError, TypeError) as e:
307
+ raise HTTPException(status_code=422, detail=f"Invalid cursor timestamp: {e}")
308
+ conditions.append(f"created_at < ${param_idx}")
309
+ params.append(cursor_timestamp)
310
+ param_idx += 1
311
+
312
+ # Fetch limit + 1 to detect has_more
313
+ params.append(validated_limit + 1)
314
+
315
+ base_query = f"""
316
+ SELECT id, alias, subject, status, created_at, expires_at
317
+ FROM {{{{tables.escalations}}}}
318
+ {"WHERE " + " AND ".join(conditions) if conditions else ""}
319
+ ORDER BY created_at DESC
320
+ LIMIT ${param_idx}
321
+ """
322
+
323
+ rows = await db.fetch_all(base_query, *params)
324
+
325
+ # Check if there are more results
326
+ has_more = len(rows) > validated_limit
327
+ rows = rows[:validated_limit] # Trim to requested limit
328
+
329
+ items = [
330
+ EscalationSummary(
331
+ escalation_id=str(r["id"]),
332
+ alias=r["alias"],
333
+ subject=r["subject"],
334
+ status=r["status"],
335
+ created_at=r["created_at"].isoformat(),
336
+ expires_at=r["expires_at"].isoformat() if r["expires_at"] else None,
337
+ )
338
+ for r in rows
339
+ ]
340
+
341
+ # Generate next_cursor if there are more results
342
+ next_cursor = None
343
+ if has_more and items:
344
+ last_item = items[-1]
345
+ next_cursor = encode_cursor({"created_at": last_item.created_at})
346
+
347
+ return ListEscalationsResponse(escalations=items, has_more=has_more, next_cursor=next_cursor)
348
+
349
+
350
+ @router.get("/{escalation_id}", response_model=EscalationDetail)
351
+ async def get_escalation(
352
+ request: Request,
353
+ escalation_id: str = Path(..., min_length=1),
354
+ workspace_id: Optional[str] = Query(None, min_length=1),
355
+ db_infra: DatabaseInfra = Depends(get_db_infra),
356
+ ) -> EscalationDetail:
357
+ db = db_infra.get_manager("server")
358
+
359
+ project_id = await get_project_from_auth(request, db_infra)
360
+ validated_workspace_id: str | None = None
361
+ if workspace_id:
362
+ try:
363
+ validated_workspace_id = validate_workspace_id(workspace_id)
364
+ except ValueError as e:
365
+ raise HTTPException(status_code=422, detail=str(e))
366
+
367
+ row = await db.fetch_one(
368
+ """
369
+ SELECT
370
+ e.id,
371
+ e.workspace_id,
372
+ e.alias,
373
+ e.member_email,
374
+ e.subject,
375
+ e.situation,
376
+ e.options,
377
+ e.status,
378
+ e.response,
379
+ e.response_note,
380
+ e.created_at,
381
+ e.responded_at,
382
+ e.expires_at
383
+ FROM {{tables.escalations}} AS e
384
+ JOIN {{tables.workspaces}} AS w
385
+ ON e.workspace_id = w.workspace_id
386
+ WHERE e.id = $1 AND e.project_id = $2 AND w.deleted_at IS NULL
387
+ AND ($3::uuid IS NULL OR e.workspace_id = $3::uuid)
388
+ """,
389
+ escalation_id,
390
+ UUID(project_id),
391
+ UUID(validated_workspace_id) if validated_workspace_id else None,
392
+ )
393
+
394
+ if row is None:
395
+ raise HTTPException(status_code=404, detail="Escalation not found")
396
+
397
+ try:
398
+ options = json.loads(row["options"]) if row["options"] else None
399
+ except json.JSONDecodeError:
400
+ options = None
401
+
402
+ return EscalationDetail(
403
+ escalation_id=str(row["id"]),
404
+ workspace_id=str(row["workspace_id"]),
405
+ alias=row["alias"],
406
+ member_email=row["member_email"],
407
+ subject=row["subject"],
408
+ situation=row["situation"],
409
+ options=options,
410
+ status=row["status"],
411
+ response=row["response"],
412
+ response_note=row["response_note"],
413
+ created_at=row["created_at"].isoformat(),
414
+ responded_at=row["responded_at"].isoformat() if row["responded_at"] else None,
415
+ expires_at=row["expires_at"].isoformat() if row["expires_at"] else None,
416
+ )
417
+
418
+
419
+ @router.post("/{escalation_id}/respond", response_model=RespondEscalationResponse)
420
+ async def respond_escalation(
421
+ request: Request,
422
+ escalation_id: str = Path(..., min_length=1),
423
+ payload: RespondEscalationRequest = ..., # type: ignore[assignment] # FastAPI pattern
424
+ db_infra: DatabaseInfra = Depends(get_db_infra),
425
+ redis: Redis = Depends(get_redis),
426
+ ) -> RespondEscalationResponse:
427
+ project_id = await get_project_from_auth(request, db_infra)
428
+ db = db_infra.get_manager("server")
429
+ now = datetime.now(timezone.utc)
430
+
431
+ row = await db.fetch_one(
432
+ """
433
+ UPDATE {{tables.escalations}} AS e
434
+ SET status = 'responded',
435
+ response = $1,
436
+ response_note = $2,
437
+ responded_at = $3
438
+ FROM {{tables.workspaces}} AS w
439
+ WHERE e.id = $4
440
+ AND e.workspace_id = w.workspace_id
441
+ AND e.project_id = $5
442
+ AND w.deleted_at IS NULL
443
+ RETURNING e.id, e.workspace_id, e.status, e.response, e.response_note, e.responded_at
444
+ """,
445
+ payload.response,
446
+ payload.note,
447
+ now,
448
+ escalation_id,
449
+ UUID(project_id),
450
+ )
451
+
452
+ if row is None:
453
+ raise HTTPException(status_code=404, detail="Escalation not found")
454
+
455
+ # Publish event to notify the workspace that created the escalation
456
+ project_slug = await get_workspace_project_slug(redis, str(row["workspace_id"]))
457
+ event = EscalationRespondedEvent(
458
+ workspace_id=str(row["workspace_id"]),
459
+ escalation_id=str(row["id"]),
460
+ response=payload.response,
461
+ project_slug=project_slug,
462
+ )
463
+ await publish_event(redis, event)
464
+
465
+ return RespondEscalationResponse(
466
+ escalation_id=str(row["id"]),
467
+ status=row["status"],
468
+ response=row["response"],
469
+ response_note=row["response_note"],
470
+ responded_at=row["responded_at"].isoformat(),
471
+ )