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/presence.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from redis.asyncio import Redis
|
|
8
|
+
|
|
9
|
+
from .roles import ROLE_MAX_LENGTH, is_valid_role, normalize_role
|
|
10
|
+
|
|
11
|
+
DEFAULT_PRESENCE_TTL_SECONDS = 1800 # 30 minutes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_key_component(value: str) -> str:
|
|
15
|
+
"""URL-encode a value for safe use in Redis keys.
|
|
16
|
+
|
|
17
|
+
Prevents key collision attacks where values containing colons could
|
|
18
|
+
create ambiguous key boundaries. For example, without encoding:
|
|
19
|
+
project_id="abc", alias="xyz:def" -> "idx:alias:abc:xyz:def"
|
|
20
|
+
project_id="abc:xyz", alias="def" -> "idx:alias:abc:xyz:def" (COLLISION!)
|
|
21
|
+
|
|
22
|
+
With encoding:
|
|
23
|
+
project_id="abc", alias="xyz:def" -> "idx:alias:abc:xyz%3Adef"
|
|
24
|
+
project_id="abc:xyz", alias="def" -> "idx:alias:abc%3Axyz:def" (DISTINCT)
|
|
25
|
+
"""
|
|
26
|
+
return quote(value, safe="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _presence_key(workspace_id: str) -> str:
|
|
30
|
+
"""Presence key: one agent per workspace."""
|
|
31
|
+
return f"presence:{workspace_id}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _project_workspaces_index_key(project_id: str) -> str:
|
|
35
|
+
"""Secondary index: workspace_ids by project_id."""
|
|
36
|
+
return f"idx:project_workspaces:{project_id}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _repo_workspaces_index_key(repo_id: str) -> str:
|
|
40
|
+
"""Secondary index: workspace_ids by repo_id."""
|
|
41
|
+
return f"idx:repo_workspaces:{repo_id}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _branch_workspaces_index_key(repo_id: str, branch: str) -> str:
|
|
45
|
+
"""Secondary index: workspace_ids by repo_id and branch."""
|
|
46
|
+
return f"idx:branch_workspaces:{repo_id}:{_safe_key_component(branch)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _project_slug_workspaces_index_key(project_slug: str) -> str:
|
|
50
|
+
"""Secondary index: workspace_ids by project_slug."""
|
|
51
|
+
return f"idx:project_slug_workspaces:{_safe_key_component(project_slug)}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _all_workspaces_index_key() -> str:
|
|
55
|
+
"""Global index: all workspace_ids with active presence."""
|
|
56
|
+
return "idx:all_workspaces"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _alias_index_key(project_id: str, alias: str) -> str:
|
|
60
|
+
"""Secondary index: workspace_id by (project_id, alias).
|
|
61
|
+
|
|
62
|
+
Enables O(1) alias collision checking instead of SCAN.
|
|
63
|
+
Key maps to a single workspace_id (aliases are unique per project).
|
|
64
|
+
"""
|
|
65
|
+
return f"idx:alias:{project_id}:{_safe_key_component(alias)}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def update_agent_presence(
|
|
69
|
+
redis: Redis,
|
|
70
|
+
workspace_id: str,
|
|
71
|
+
alias: str,
|
|
72
|
+
program: Optional[str],
|
|
73
|
+
model: Optional[str],
|
|
74
|
+
human_name: Optional[str] = None,
|
|
75
|
+
project_id: Optional[str] = None,
|
|
76
|
+
project_slug: Optional[str] = None,
|
|
77
|
+
repo_id: Optional[str] = None,
|
|
78
|
+
member_email: str = "",
|
|
79
|
+
status: str = "active",
|
|
80
|
+
current_branch: Optional[str] = None,
|
|
81
|
+
role: Optional[str] = None,
|
|
82
|
+
ttl_seconds: int = DEFAULT_PRESENCE_TTL_SECONDS,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Upsert agent presence in Redis and return the ISO timestamp used.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
workspace_id: UUID identifying the workspace.
|
|
89
|
+
alias: Human-friendly workspace identifier for addressing.
|
|
90
|
+
human_name: Name of the human who owns this workspace.
|
|
91
|
+
project_id: UUID of the project (for secondary index).
|
|
92
|
+
project_slug: Human-readable project slug.
|
|
93
|
+
repo_id: UUID of the repo (for secondary index).
|
|
94
|
+
current_branch: Optional branch name.
|
|
95
|
+
role: Brief description of workspace purpose (max 50 chars).
|
|
96
|
+
ttl_seconds: How long until presence expires if not refreshed. Default 5 minutes.
|
|
97
|
+
"""
|
|
98
|
+
key = _presence_key(workspace_id)
|
|
99
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
100
|
+
|
|
101
|
+
fields = {
|
|
102
|
+
"workspace_id": workspace_id,
|
|
103
|
+
"alias": alias,
|
|
104
|
+
"human_name": human_name or "",
|
|
105
|
+
"project_id": project_id or "",
|
|
106
|
+
"project_slug": project_slug or "",
|
|
107
|
+
"repo_id": repo_id or "",
|
|
108
|
+
"member_email": member_email,
|
|
109
|
+
"program": program or "",
|
|
110
|
+
"model": model or "",
|
|
111
|
+
"status": status,
|
|
112
|
+
"current_branch": current_branch or "",
|
|
113
|
+
"last_seen": now,
|
|
114
|
+
}
|
|
115
|
+
if role is not None and len(role) <= ROLE_MAX_LENGTH and is_valid_role(role):
|
|
116
|
+
fields["role"] = normalize_role(role)
|
|
117
|
+
|
|
118
|
+
await redis.hset(key, mapping=fields)
|
|
119
|
+
await redis.expire(key, ttl_seconds)
|
|
120
|
+
|
|
121
|
+
# Update secondary indexes
|
|
122
|
+
# Index TTL is 2x presence TTL to ensure index entries outlive presence keys,
|
|
123
|
+
# allowing lazy cleanup to detect stale entries via EXISTS checks.
|
|
124
|
+
# Note: workspace → project is immutable (see architecture docs), so project_id
|
|
125
|
+
# and project_slug don't change for a given workspace. Branch and repo indexes
|
|
126
|
+
# may have transient staleness (up to TTL*2) when workspaces switch branches.
|
|
127
|
+
|
|
128
|
+
# Global all_workspaces index (always maintained)
|
|
129
|
+
all_idx_key = _all_workspaces_index_key()
|
|
130
|
+
await redis.sadd(all_idx_key, workspace_id)
|
|
131
|
+
await redis.expire(all_idx_key, ttl_seconds * 2)
|
|
132
|
+
|
|
133
|
+
if project_id:
|
|
134
|
+
idx_key = _project_workspaces_index_key(project_id)
|
|
135
|
+
await redis.sadd(idx_key, workspace_id)
|
|
136
|
+
await redis.expire(idx_key, ttl_seconds * 2)
|
|
137
|
+
|
|
138
|
+
# Alias index for O(1) collision checking (1:1 mapping, not a set)
|
|
139
|
+
alias_idx_key = _alias_index_key(project_id, alias)
|
|
140
|
+
await redis.set(alias_idx_key, workspace_id, ex=ttl_seconds * 2)
|
|
141
|
+
|
|
142
|
+
if project_slug:
|
|
143
|
+
idx_key = _project_slug_workspaces_index_key(project_slug)
|
|
144
|
+
await redis.sadd(idx_key, workspace_id)
|
|
145
|
+
await redis.expire(idx_key, ttl_seconds * 2)
|
|
146
|
+
|
|
147
|
+
if repo_id:
|
|
148
|
+
idx_key = _repo_workspaces_index_key(repo_id)
|
|
149
|
+
await redis.sadd(idx_key, workspace_id)
|
|
150
|
+
await redis.expire(idx_key, ttl_seconds * 2)
|
|
151
|
+
|
|
152
|
+
if current_branch:
|
|
153
|
+
idx_key = _branch_workspaces_index_key(repo_id, current_branch)
|
|
154
|
+
await redis.sadd(idx_key, workspace_id)
|
|
155
|
+
await redis.expire(idx_key, ttl_seconds * 2)
|
|
156
|
+
|
|
157
|
+
return now
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def get_agent_presence(
|
|
161
|
+
redis: Redis,
|
|
162
|
+
workspace_id: str,
|
|
163
|
+
) -> Optional[Dict[str, str]]:
|
|
164
|
+
"""
|
|
165
|
+
Fetch an agent's presence record from Redis.
|
|
166
|
+
|
|
167
|
+
One agent per workspace architecture: workspace_id is the only key.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
workspace_id: UUID identifying the workspace.
|
|
171
|
+
"""
|
|
172
|
+
key = _presence_key(workspace_id)
|
|
173
|
+
data: Dict[str, str] = await redis.hgetall(key)
|
|
174
|
+
if not data:
|
|
175
|
+
return None
|
|
176
|
+
return data
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def list_agent_presences(
|
|
180
|
+
redis: Redis,
|
|
181
|
+
workspace_id: Optional[str] = None,
|
|
182
|
+
) -> List[Dict[str, str]]:
|
|
183
|
+
"""
|
|
184
|
+
List agent presence records.
|
|
185
|
+
|
|
186
|
+
Uses the all_workspaces secondary index for O(M) lookup where M is the
|
|
187
|
+
number of active workspaces, instead of O(N) SCAN over all Redis keys.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
workspace_id: If provided, return the presence for this workspace only.
|
|
191
|
+
Otherwise, list all presences.
|
|
192
|
+
"""
|
|
193
|
+
if workspace_id:
|
|
194
|
+
# One agent per workspace - direct lookup
|
|
195
|
+
key = _presence_key(workspace_id)
|
|
196
|
+
data: Dict[str, str] = await redis.hgetall(key)
|
|
197
|
+
return [data] if data else []
|
|
198
|
+
|
|
199
|
+
# List all presences using secondary index (avoids SCAN)
|
|
200
|
+
idx_key = _all_workspaces_index_key()
|
|
201
|
+
workspace_ids = await _filter_valid_workspace_ids(redis, idx_key)
|
|
202
|
+
|
|
203
|
+
if not workspace_ids:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
# Batch fetch all presence hashes with pipeline (N round-trips → 1)
|
|
207
|
+
pipe = redis.pipeline()
|
|
208
|
+
for ws_id in workspace_ids:
|
|
209
|
+
pipe.hgetall(_presence_key(ws_id))
|
|
210
|
+
presence_data = await pipe.execute()
|
|
211
|
+
|
|
212
|
+
results: List[Dict[str, str]] = []
|
|
213
|
+
for data in presence_data:
|
|
214
|
+
if data:
|
|
215
|
+
results.append(data)
|
|
216
|
+
|
|
217
|
+
return results
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def list_agent_presences_by_workspace_ids(
|
|
221
|
+
redis: Redis,
|
|
222
|
+
workspace_ids: List[str],
|
|
223
|
+
) -> List[Dict[str, str]]:
|
|
224
|
+
"""
|
|
225
|
+
Fetch presence records for specific workspace IDs.
|
|
226
|
+
|
|
227
|
+
This avoids scanning global indexes when callers already know which
|
|
228
|
+
workspaces they need to enrich with presence.
|
|
229
|
+
"""
|
|
230
|
+
if not workspace_ids:
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
pipe = redis.pipeline()
|
|
234
|
+
for ws_id in workspace_ids:
|
|
235
|
+
pipe.hgetall(_presence_key(ws_id))
|
|
236
|
+
presence_data = await pipe.execute()
|
|
237
|
+
|
|
238
|
+
results: List[Dict[str, str]] = []
|
|
239
|
+
for data in presence_data:
|
|
240
|
+
if data:
|
|
241
|
+
results.append(data)
|
|
242
|
+
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def _filter_valid_workspace_ids(
|
|
247
|
+
redis: Redis,
|
|
248
|
+
idx_key: str,
|
|
249
|
+
) -> List[str]:
|
|
250
|
+
"""
|
|
251
|
+
Filter workspace_ids from a secondary index, removing stale entries.
|
|
252
|
+
|
|
253
|
+
Uses Redis pipeline to batch EXISTS checks (N+1 round-trips → 2:
|
|
254
|
+
one SMEMBERS + one pipeline for all EXISTS checks).
|
|
255
|
+
|
|
256
|
+
Stale entries (presence expired but index entry remains) are lazily
|
|
257
|
+
removed from the index. There's a theoretical race where a workspace
|
|
258
|
+
could be removed from the index just as it refreshes presence, but
|
|
259
|
+
the entry gets re-added on next presence update.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
redis: Redis client.
|
|
263
|
+
idx_key: Key for the secondary index (Set).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of workspace_ids with active presence.
|
|
267
|
+
"""
|
|
268
|
+
members = await redis.smembers(idx_key)
|
|
269
|
+
if not members:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
# Normalize to strings
|
|
273
|
+
workspace_ids = [
|
|
274
|
+
ws_id.decode("utf-8") if isinstance(ws_id, bytes) else ws_id for ws_id in members
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
# Batch EXISTS checks with pipeline (N round-trips → 1)
|
|
278
|
+
pipe = redis.pipeline()
|
|
279
|
+
for ws_id in workspace_ids:
|
|
280
|
+
pipe.exists(_presence_key(ws_id))
|
|
281
|
+
exists_results = await pipe.execute()
|
|
282
|
+
|
|
283
|
+
# Separate valid and stale workspace_ids
|
|
284
|
+
valid_workspace_ids: List[str] = []
|
|
285
|
+
stale_workspace_ids: List[str] = []
|
|
286
|
+
for ws_id, exists in zip(workspace_ids, exists_results):
|
|
287
|
+
if exists:
|
|
288
|
+
valid_workspace_ids.append(ws_id)
|
|
289
|
+
else:
|
|
290
|
+
stale_workspace_ids.append(ws_id)
|
|
291
|
+
|
|
292
|
+
# Lazy cleanup: remove stale entries from index
|
|
293
|
+
if stale_workspace_ids:
|
|
294
|
+
cleanup_pipe = redis.pipeline()
|
|
295
|
+
for ws_id in stale_workspace_ids:
|
|
296
|
+
cleanup_pipe.srem(idx_key, ws_id)
|
|
297
|
+
await cleanup_pipe.execute()
|
|
298
|
+
|
|
299
|
+
return valid_workspace_ids
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def get_workspace_ids_by_project_slug(
|
|
303
|
+
redis: Redis,
|
|
304
|
+
project_slug: str,
|
|
305
|
+
) -> List[str]:
|
|
306
|
+
"""
|
|
307
|
+
Get all workspace_ids that belong to a project by slug.
|
|
308
|
+
|
|
309
|
+
Uses secondary index for O(M) lookup where M is workspaces in project.
|
|
310
|
+
Stale entries (presence expired but index entry remains) are filtered
|
|
311
|
+
out and lazily removed from the index.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
project_slug: Human-readable project slug.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
List of workspace_ids with matching project_slug.
|
|
318
|
+
"""
|
|
319
|
+
idx_key = _project_slug_workspaces_index_key(project_slug)
|
|
320
|
+
return await _filter_valid_workspace_ids(redis, idx_key)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def get_workspace_ids_by_project_id(
|
|
324
|
+
redis: Redis,
|
|
325
|
+
project_id: str,
|
|
326
|
+
) -> List[str]:
|
|
327
|
+
"""
|
|
328
|
+
Get all workspace_ids that belong to a project by ID.
|
|
329
|
+
|
|
330
|
+
Uses secondary index for O(1) lookup. Stale entries (presence expired but
|
|
331
|
+
index entry remains) are filtered out and lazily removed from the index.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
project_id: Project UUID.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
List of workspace_ids in the project.
|
|
338
|
+
"""
|
|
339
|
+
idx_key = _project_workspaces_index_key(project_id)
|
|
340
|
+
return await _filter_valid_workspace_ids(redis, idx_key)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def get_workspace_ids_by_repo_id(
|
|
344
|
+
redis: Redis,
|
|
345
|
+
repo_id: str,
|
|
346
|
+
) -> List[str]:
|
|
347
|
+
"""
|
|
348
|
+
Get all workspace_ids that belong to a repo by ID.
|
|
349
|
+
|
|
350
|
+
Uses secondary index for O(1) lookup. Stale entries (presence expired but
|
|
351
|
+
index entry remains) are filtered out and lazily removed from the index.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
repo_id: Repo UUID.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
List of workspace_ids in the repo.
|
|
358
|
+
"""
|
|
359
|
+
idx_key = _repo_workspaces_index_key(repo_id)
|
|
360
|
+
return await _filter_valid_workspace_ids(redis, idx_key)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def get_workspace_ids_by_branch(
|
|
364
|
+
redis: Redis,
|
|
365
|
+
repo_id: str,
|
|
366
|
+
branch: str,
|
|
367
|
+
) -> List[str]:
|
|
368
|
+
"""
|
|
369
|
+
Get all workspace_ids working on a specific branch of a repo.
|
|
370
|
+
|
|
371
|
+
Uses secondary index for O(1) lookup. Stale entries (presence expired but
|
|
372
|
+
index entry remains) are filtered out and lazily removed from the index.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
repo_id: Repo UUID.
|
|
376
|
+
branch: Branch name.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
List of workspace_ids on the branch.
|
|
380
|
+
"""
|
|
381
|
+
idx_key = _branch_workspaces_index_key(repo_id, branch)
|
|
382
|
+
return await _filter_valid_workspace_ids(redis, idx_key)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def get_all_workspace_ids(
|
|
386
|
+
redis: Redis,
|
|
387
|
+
) -> List[str]:
|
|
388
|
+
"""
|
|
389
|
+
Get all workspace_ids with active presence.
|
|
390
|
+
|
|
391
|
+
Uses the global all_workspaces index. Stale entries (presence expired but
|
|
392
|
+
index entry remains) are filtered out and lazily removed from the index.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
List of all active workspace_ids.
|
|
396
|
+
"""
|
|
397
|
+
idx_key = _all_workspaces_index_key()
|
|
398
|
+
return await _filter_valid_workspace_ids(redis, idx_key)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def get_workspace_id_by_alias(
|
|
402
|
+
redis: Redis,
|
|
403
|
+
project_id: str,
|
|
404
|
+
alias: str,
|
|
405
|
+
) -> Optional[str]:
|
|
406
|
+
"""
|
|
407
|
+
Get the workspace_id using a specific alias within a project.
|
|
408
|
+
|
|
409
|
+
Uses the alias secondary index for O(1) lookup. Returns the workspace_id
|
|
410
|
+
if the alias is in use and the workspace has active presence, None otherwise.
|
|
411
|
+
|
|
412
|
+
Note: This is for presence-based collision checking only. The database
|
|
413
|
+
(workspaces table) is the authoritative source for alias ownership.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
project_id: Project UUID.
|
|
417
|
+
alias: The alias to look up.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
workspace_id if alias is in use with active presence, None otherwise.
|
|
421
|
+
"""
|
|
422
|
+
idx_key = _alias_index_key(project_id, alias)
|
|
423
|
+
workspace_id = await redis.get(idx_key)
|
|
424
|
+
|
|
425
|
+
if not workspace_id:
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
# Normalize to string (may be bytes depending on Redis client config)
|
|
429
|
+
ws_id = workspace_id.decode("utf-8") if isinstance(workspace_id, bytes) else workspace_id
|
|
430
|
+
|
|
431
|
+
# Verify presence is still active (index may outlive presence due to TTL difference)
|
|
432
|
+
presence_key = _presence_key(ws_id)
|
|
433
|
+
if not await redis.exists(presence_key):
|
|
434
|
+
# Stale index entry - lazy cleanup
|
|
435
|
+
await redis.delete(idx_key)
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
return ws_id
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def get_workspace_project_slug(
|
|
442
|
+
redis: Redis,
|
|
443
|
+
workspace_id: str,
|
|
444
|
+
) -> Optional[str]:
|
|
445
|
+
"""Get the project_slug for a workspace from its presence data.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
redis: Redis client.
|
|
449
|
+
workspace_id: Workspace UUID.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
project_slug if available, None otherwise.
|
|
453
|
+
"""
|
|
454
|
+
data = await redis.hget(_presence_key(workspace_id), "project_slug")
|
|
455
|
+
if not data:
|
|
456
|
+
return None
|
|
457
|
+
slug = data.decode("utf-8") if isinstance(data, bytes) else data
|
|
458
|
+
return slug if slug else None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def clear_workspace_presence(
|
|
462
|
+
redis: Redis,
|
|
463
|
+
workspace_ids: List[str],
|
|
464
|
+
) -> int:
|
|
465
|
+
"""
|
|
466
|
+
Clear presence for a list of workspaces.
|
|
467
|
+
|
|
468
|
+
Deletes presence keys and removes from all secondary indexes.
|
|
469
|
+
Used when soft-deleting repos or projects.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
workspace_ids: List of workspace UUIDs to clear.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
Number of presence keys deleted.
|
|
476
|
+
"""
|
|
477
|
+
if not workspace_ids:
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
# Delete presence keys
|
|
481
|
+
pipe = redis.pipeline()
|
|
482
|
+
for ws_id in workspace_ids:
|
|
483
|
+
pipe.delete(_presence_key(ws_id))
|
|
484
|
+
results = await pipe.execute()
|
|
485
|
+
deleted_count = sum(1 for r in results if r)
|
|
486
|
+
|
|
487
|
+
# Remove from all secondary indexes (lazy cleanup handles misses)
|
|
488
|
+
# We remove from all possible indexes to ensure cleanup
|
|
489
|
+
all_idx_key = _all_workspaces_index_key()
|
|
490
|
+
pipe = redis.pipeline()
|
|
491
|
+
for ws_id in workspace_ids:
|
|
492
|
+
pipe.srem(all_idx_key, ws_id)
|
|
493
|
+
await pipe.execute()
|
|
494
|
+
|
|
495
|
+
return deleted_count
|
beadhub/rate_limit.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Rate limiting for BeadHub endpoints.
|
|
2
|
+
|
|
3
|
+
Provides Redis-based rate limiting with fixed window counters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException, Request
|
|
13
|
+
from redis.asyncio import Redis
|
|
14
|
+
from redis.exceptions import RedisError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Rate limit configuration for /v1/init endpoint
|
|
19
|
+
# Can be overridden via environment for testing
|
|
20
|
+
INIT_RATE_LIMIT = int(os.getenv("BEADHUB_INIT_RATE_LIMIT", "10")) # requests per window
|
|
21
|
+
INIT_RATE_WINDOW = int(os.getenv("BEADHUB_INIT_RATE_WINDOW", "60")) # seconds
|
|
22
|
+
|
|
23
|
+
# Lua script for atomic increment with expiration
|
|
24
|
+
# This prevents race condition between INCR and EXPIRE
|
|
25
|
+
RATE_LIMIT_SCRIPT = """
|
|
26
|
+
local current = redis.call('INCR', KEYS[1])
|
|
27
|
+
if current == 1 then
|
|
28
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
29
|
+
end
|
|
30
|
+
return current
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_client_ip(request: Request) -> str:
|
|
35
|
+
"""Extract client IP address from request.
|
|
36
|
+
|
|
37
|
+
Uses request.client.host which is set by the ASGI server based on
|
|
38
|
+
the actual TCP connection. This is secure against X-Forwarded-For
|
|
39
|
+
spoofing attacks.
|
|
40
|
+
|
|
41
|
+
For deployments behind a reverse proxy, configure the proxy to set
|
|
42
|
+
the client IP correctly, or use FastAPI's TrustedHostMiddleware.
|
|
43
|
+
Do NOT trust X-Forwarded-For without proper proxy configuration.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
request: FastAPI request object
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Client IP address string
|
|
50
|
+
"""
|
|
51
|
+
# request.client can be None in some test scenarios
|
|
52
|
+
if request.client is None:
|
|
53
|
+
return "unknown"
|
|
54
|
+
return request.client.host
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def check_rate_limit(
|
|
58
|
+
request: Request,
|
|
59
|
+
redis: Redis,
|
|
60
|
+
key_prefix: str,
|
|
61
|
+
limit: int,
|
|
62
|
+
window_seconds: int,
|
|
63
|
+
) -> Optional[int]:
|
|
64
|
+
"""Check if request is within rate limit.
|
|
65
|
+
|
|
66
|
+
Uses a Lua script for atomic increment with expiration. The window
|
|
67
|
+
is fixed from the first request (not sliding).
|
|
68
|
+
|
|
69
|
+
Note: Fixed window allows burst at window boundaries (up to 2x limit
|
|
70
|
+
in a short period). This is acceptable for /v1/init.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
request: FastAPI request object
|
|
74
|
+
redis: Async Redis client
|
|
75
|
+
key_prefix: Prefix for the rate limit key (e.g., "ratelimit:init")
|
|
76
|
+
limit: Maximum requests per window
|
|
77
|
+
window_seconds: Window duration in seconds
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
None if within limit, otherwise seconds until limit resets
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
RedisError: If Redis is unavailable (caller should handle)
|
|
84
|
+
"""
|
|
85
|
+
client_ip = get_client_ip(request)
|
|
86
|
+
key = f"{key_prefix}:{client_ip}"
|
|
87
|
+
|
|
88
|
+
# Atomic increment with expiration using Lua script
|
|
89
|
+
# This prevents race condition where INCR succeeds but EXPIRE fails
|
|
90
|
+
current = await redis.eval(RATE_LIMIT_SCRIPT, 1, key, window_seconds)
|
|
91
|
+
|
|
92
|
+
if current > limit:
|
|
93
|
+
# Over limit - get TTL for Retry-After header
|
|
94
|
+
ttl = await redis.ttl(key)
|
|
95
|
+
if ttl < 0:
|
|
96
|
+
# Key has no expiry (shouldn't happen with Lua script)
|
|
97
|
+
# Delete and allow retry on next request
|
|
98
|
+
await redis.delete(key)
|
|
99
|
+
ttl = window_seconds
|
|
100
|
+
logger.error(
|
|
101
|
+
"Rate limit key without TTL detected and deleted: ip=%s key=%s",
|
|
102
|
+
client_ip,
|
|
103
|
+
key,
|
|
104
|
+
)
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Rate limit exceeded: ip=%s key=%s count=%d limit=%d",
|
|
107
|
+
client_ip,
|
|
108
|
+
key,
|
|
109
|
+
current,
|
|
110
|
+
limit,
|
|
111
|
+
)
|
|
112
|
+
return ttl
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def enforce_init_rate_limit(request: Request, redis: Redis) -> None:
|
|
118
|
+
"""Enforce rate limit for /v1/init endpoint.
|
|
119
|
+
|
|
120
|
+
Fails closed: If Redis is unavailable, returns 503 Service Unavailable
|
|
121
|
+
rather than allowing unlimited requests.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
request: FastAPI request object
|
|
125
|
+
redis: Async Redis client
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
HTTPException: 429 Too Many Requests if rate limit exceeded
|
|
129
|
+
HTTPException: 503 Service Unavailable if Redis is unavailable
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
retry_after = await check_rate_limit(
|
|
133
|
+
request=request,
|
|
134
|
+
redis=redis,
|
|
135
|
+
key_prefix="ratelimit:init",
|
|
136
|
+
limit=INIT_RATE_LIMIT,
|
|
137
|
+
window_seconds=INIT_RATE_WINDOW,
|
|
138
|
+
)
|
|
139
|
+
except RedisError as e:
|
|
140
|
+
# Fail closed: deny request if we can't check rate limit
|
|
141
|
+
logger.error("Redis error during rate limit check: %s", e)
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=503,
|
|
144
|
+
detail="Service temporarily unavailable. Please retry.",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if retry_after is not None:
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=429,
|
|
150
|
+
detail="Rate limit exceeded. Too many initialization requests.",
|
|
151
|
+
headers={"Retry-After": str(retry_after)},
|
|
152
|
+
)
|
beadhub/redis_client.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
from redis.asyncio import Redis
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_redis(request: Request) -> Redis:
|
|
8
|
+
"""
|
|
9
|
+
FastAPI dependency that returns the shared async Redis client from app state.
|
|
10
|
+
"""
|
|
11
|
+
return request.app.state.redis
|
beadhub/roles.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Role validation and normalization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
ROLE_MAX_LENGTH = 50
|
|
8
|
+
ROLE_MAX_WORDS = 2
|
|
9
|
+
ROLE_WORD_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
10
|
+
ROLE_ERROR_MESSAGE = (
|
|
11
|
+
"Invalid role: use 1-2 words (letters/numbers) with hyphens/underscores allowed; max 50 chars"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_role(role: str) -> str:
|
|
16
|
+
"""Normalize role string by trimming, collapsing spaces, and lowercasing."""
|
|
17
|
+
return " ".join(role.strip().split()).lower()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_valid_role(role: str) -> bool:
|
|
21
|
+
"""Check if role is 1-2 words with allowed characters."""
|
|
22
|
+
if not role or not isinstance(role, str):
|
|
23
|
+
return False
|
|
24
|
+
normalized = normalize_role(role)
|
|
25
|
+
if not normalized or len(normalized) > ROLE_MAX_LENGTH:
|
|
26
|
+
return False
|
|
27
|
+
words = normalized.split(" ")
|
|
28
|
+
if len(words) > ROLE_MAX_WORDS:
|
|
29
|
+
return False
|
|
30
|
+
return all(ROLE_WORD_PATTERN.match(word) for word in words)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def role_to_alias_prefix(role: str) -> str:
|
|
34
|
+
"""Convert role to an alias-friendly prefix."""
|
|
35
|
+
return normalize_role(role).replace(" ", "-")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|