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/claims.py
ADDED
|
@@ -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
|
+
)
|