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
beadhub/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from importlib.metadata import version
2
+
3
+ from .api import create_app
4
+ from .cli import app
5
+ from .db import DatabaseInfra
6
+
7
+ __version__ = version("beadhub")
8
+ __all__ = ["__version__", "create_app", "DatabaseInfra", "app", "main"]
9
+
10
+
11
+ def main() -> None:
12
+ app()
beadhub/api.py ADDED
@@ -0,0 +1,260 @@
1
+ import logging
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from aweb.routes.auth import router as aweb_auth_router
8
+ from aweb.routes.chat import router as aweb_chat_router
9
+ from aweb.routes.messages import router as aweb_messages_router
10
+ from aweb.routes.projects import router as aweb_projects_router
11
+ from aweb.routes.reservations import router as aweb_reservations_router
12
+ from fastapi import FastAPI, HTTPException, Request
13
+ from fastapi.staticfiles import StaticFiles
14
+ from redis.asyncio import Redis
15
+ from redis.asyncio import from_url as async_redis_from_url
16
+
17
+ from .config import get_settings
18
+ from .db import DatabaseInfra
19
+ from .db import db_infra as default_db_infra
20
+ from .logging import configure_logging
21
+ from .routes.agents import router as agents_router
22
+ from .routes.bdh import router as bdh_router
23
+ from .routes.beads import router as beads_router
24
+ from .routes.claims import router as claims_router
25
+ from .routes.escalations import router as escalations_router
26
+ from .routes.init import router as init_router
27
+ from .routes.mcp import router as mcp_router
28
+ from .routes.policies import router as policies_router
29
+ from .routes.status import router as status_router
30
+ from .routes.subscriptions import router as subscriptions_router
31
+ from .routes.workspaces import router as workspaces_router
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def _make_standalone_lifespan():
37
+ """Create lifespan for standalone mode (creates own DB and Redis connections)."""
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(app: FastAPI):
41
+ json_format = os.getenv("BEADHUB_LOG_JSON", "true").lower() == "true"
42
+ settings = get_settings()
43
+ configure_logging(log_level=settings.log_level, json_format=json_format)
44
+ logger.info("Starting BeadHub server (standalone mode)")
45
+
46
+ redis: Redis | None = None
47
+ redis_connected = False
48
+ db_initialized = False
49
+
50
+ try:
51
+ # Phase 1: Initialize all resources (don't set app.state yet)
52
+ redis = await async_redis_from_url(settings.redis_url, decode_responses=True)
53
+ await redis.ping()
54
+ redis_connected = True
55
+ logger.info("Connected to Redis")
56
+
57
+ await default_db_infra.initialize()
58
+ db_initialized = True
59
+ logger.info("Database initialized")
60
+
61
+ # Phase 2: Only assign to app.state after ALL initialization succeeds
62
+ app.state.redis = redis
63
+ app.state.db = default_db_infra
64
+
65
+ except Exception:
66
+ # Log which phase failed
67
+ if not redis_connected:
68
+ logger.exception("Failed to connect to Redis")
69
+ elif not db_initialized:
70
+ logger.exception("Failed to initialize database")
71
+
72
+ # Clean up any initialized resources on failure
73
+ if db_initialized:
74
+ await default_db_infra.close()
75
+ if redis is not None:
76
+ await redis.aclose()
77
+ raise
78
+
79
+ try:
80
+ yield
81
+ finally:
82
+ logger.info("Shutting down BeadHub server")
83
+ await redis.aclose()
84
+ await default_db_infra.close()
85
+
86
+ return lifespan
87
+
88
+
89
+ def _make_library_lifespan(db_infra: DatabaseInfra, redis: Redis):
90
+ """Create lifespan for library mode (uses externally provided connections)."""
91
+
92
+ @asynccontextmanager
93
+ async def lifespan(app: FastAPI):
94
+ json_format = os.getenv("BEADHUB_LOG_JSON", "true").lower() == "true"
95
+ log_level = os.getenv("BEADHUB_LOG_LEVEL", "info")
96
+ configure_logging(log_level=log_level, json_format=json_format)
97
+ logger.info("Starting BeadHub server (library mode)")
98
+
99
+ # Use externally provided connections - no initialization needed
100
+ app.state.redis = redis
101
+ app.state.db = db_infra
102
+
103
+ try:
104
+ yield
105
+ finally:
106
+ # Don't close connections in library mode - caller manages them
107
+ logger.info("BeadHub server stopping (library mode)")
108
+
109
+ return lifespan
110
+
111
+
112
+ def create_app(
113
+ *,
114
+ db_infra: Optional[DatabaseInfra] = None,
115
+ redis: Optional[Redis] = None,
116
+ serve_frontend: bool = True,
117
+ enable_bootstrap_routes: bool = True,
118
+ ) -> FastAPI:
119
+ """Create BeadHub FastAPI application.
120
+
121
+ Args:
122
+ db_infra: External DatabaseInfra instance (library mode).
123
+ If None, creates own connections (standalone mode).
124
+ redis: External async Redis client (library mode).
125
+ If None, creates own connection (standalone mode).
126
+ serve_frontend: If True, serve the dashboard frontend from /frontend/dist.
127
+ Set to False when embedding in another app that serves its own UI.
128
+ enable_bootstrap_routes: If True, expose bootstrap routes such as `/v1/init`.
129
+ Embedded/proxy deployments should set this to False.
130
+
131
+ Library mode requires both db_infra and redis to be provided.
132
+ Standalone mode requires neither (will create its own).
133
+
134
+ Examples:
135
+ Standalone mode (simple deployment)::
136
+
137
+ app = create_app()
138
+ # Run with: uvicorn beadhub.api:create_app --factory
139
+
140
+ Library mode (embedding in another FastAPI app)::
141
+
142
+ from beadhub.api import create_app
143
+ from beadhub.db import DatabaseInfra
144
+ from redis.asyncio import Redis
145
+
146
+ # Initialize shared infrastructure
147
+ db_infra = DatabaseInfra()
148
+ await db_infra.initialize()
149
+ redis = await Redis.from_url("redis://localhost:6379")
150
+
151
+ # Create BeadHub app with shared connections
152
+ beadhub_app = create_app(db_infra=db_infra, redis=redis)
153
+
154
+ # Mount under your main app
155
+ main_app.mount("/beadhub", beadhub_app)
156
+ """
157
+ # Validate mode consistency
158
+ if (db_infra is None) != (redis is None):
159
+ raise ValueError(
160
+ "Library mode requires both db_infra and redis, or neither for standalone mode"
161
+ )
162
+
163
+ library_mode = db_infra is not None
164
+
165
+ # Validate db_infra is initialized in library mode
166
+ if library_mode:
167
+ assert db_infra is not None # Type narrowing for mypy
168
+ if not db_infra.is_initialized:
169
+ raise ValueError(
170
+ "db_infra must be initialized before passing to create_app() in library mode. "
171
+ "Call 'await db_infra.initialize()' before creating the app."
172
+ )
173
+ assert redis is not None # Required when db_infra is provided
174
+ lifespan = _make_library_lifespan(db_infra, redis)
175
+ else:
176
+ lifespan = _make_standalone_lifespan()
177
+
178
+ app = FastAPI(title="BeadHub OSS Core", version="0.1.0", lifespan=lifespan)
179
+
180
+ @app.get("/health", tags=["internal"])
181
+ async def health(request: Request) -> dict:
182
+ checks = {}
183
+ healthy = True
184
+
185
+ # Check Redis
186
+ try:
187
+ redis: Redis = request.app.state.redis
188
+ await redis.ping()
189
+ checks["redis"] = "ok"
190
+ except Exception as e:
191
+ checks["redis"] = f"error: {e}"
192
+ healthy = False
193
+
194
+ # Check Database
195
+ try:
196
+ db_infra: DatabaseInfra = request.app.state.db
197
+ db = db_infra.get_manager("server")
198
+ await db.fetch_value("SELECT 1")
199
+ checks["database"] = "ok"
200
+ except Exception as e:
201
+ checks["database"] = f"error: {e}"
202
+ healthy = False
203
+
204
+ return {"status": "ok" if healthy else "unhealthy", "checks": checks}
205
+
206
+ # aweb protocol routes (BeadHub is an aweb server).
207
+ # Note: BeadHub overrides `/v1/init` with an extended init endpoint.
208
+ if enable_bootstrap_routes:
209
+ app.include_router(init_router)
210
+ app.include_router(aweb_auth_router)
211
+ app.include_router(aweb_chat_router)
212
+ app.include_router(aweb_messages_router)
213
+ app.include_router(aweb_projects_router)
214
+ app.include_router(aweb_reservations_router)
215
+
216
+ # beadhub endpoints.
217
+ app.include_router(bdh_router)
218
+ app.include_router(agents_router)
219
+ app.include_router(beads_router)
220
+ app.include_router(claims_router)
221
+ app.include_router(escalations_router)
222
+ app.include_router(policies_router)
223
+ app.include_router(status_router)
224
+ app.include_router(subscriptions_router)
225
+ app.include_router(workspaces_router)
226
+ app.include_router(mcp_router)
227
+
228
+ # Serve frontend dashboard if available and enabled
229
+ if serve_frontend:
230
+ # Look for frontend dist relative to this file's location
231
+ frontend_dist = Path(__file__).parent.parent.parent / "frontend" / "dist"
232
+ if frontend_dist.exists():
233
+ # Serve static assets
234
+ app.mount(
235
+ "/assets",
236
+ StaticFiles(directory=frontend_dist / "assets"),
237
+ name="assets",
238
+ )
239
+
240
+ # Serve index.html for all unmatched routes (SPA routing)
241
+ from fastapi.responses import FileResponse
242
+
243
+ @app.get("/{full_path:path}", include_in_schema=False)
244
+ async def serve_spa(full_path: str):
245
+ # API routes are handled by routers registered before this catch-all
246
+ # This only catches non-API paths for SPA routing
247
+ index_path = frontend_dist / "index.html"
248
+ if index_path.exists():
249
+ return FileResponse(index_path)
250
+ raise HTTPException(status_code=404, detail="Frontend not found")
251
+
252
+ logger.info("Frontend dashboard enabled at /")
253
+ else:
254
+ logger.debug("Frontend not found at %s, skipping", frontend_dist)
255
+
256
+ return app
257
+
258
+
259
+ # Module-level app for uvicorn: `uvicorn beadhub.api:app`
260
+ app = create_app()
beadhub/auth.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any, Optional, Protocol
5
+
6
+ from fastapi import HTTPException, Request
7
+
8
+ from beadhub.aweb_introspection import get_identity_from_auth
9
+
10
+
11
+ class DatabaseLike(Protocol):
12
+ def get_manager(self, name: str = "server") -> Any: ...
13
+
14
+
15
+ def validate_workspace_id(workspace_id: str) -> str:
16
+ """Validate workspace_id is a valid UUID string and return normalized format."""
17
+ if workspace_id is None:
18
+ raise ValueError("workspace_id cannot be empty")
19
+ workspace_id = str(workspace_id).strip()
20
+ if not workspace_id:
21
+ raise ValueError("workspace_id cannot be empty")
22
+ try:
23
+ return str(uuid.UUID(workspace_id))
24
+ except ValueError:
25
+ raise ValueError("Invalid workspace_id format")
26
+
27
+
28
+ async def get_workspace_project_id(
29
+ db: DatabaseLike,
30
+ workspace_id: str,
31
+ ) -> Optional[str]:
32
+ """Return project_id for workspace_id or None if not found."""
33
+ try:
34
+ ws_uuid = uuid.UUID(workspace_id)
35
+ except ValueError:
36
+ return None
37
+
38
+ server_db = db.get_manager("server")
39
+ row = await server_db.fetch_one(
40
+ """
41
+ SELECT project_id
42
+ FROM {{tables.workspaces}}
43
+ WHERE workspace_id = $1 AND deleted_at IS NULL
44
+ """,
45
+ ws_uuid,
46
+ )
47
+ if not row:
48
+ return None
49
+ return str(row["project_id"])
50
+
51
+
52
+ async def verify_workspace_access(
53
+ request: Request,
54
+ workspace_id: str,
55
+ db: DatabaseLike,
56
+ ) -> str:
57
+ """Verify workspace_id belongs to the authenticated project, return project_id.
58
+
59
+ This enforces the invariant documented in `security-patterns.md`:
60
+ - project scope is derived from auth (API key or signed proxy context)
61
+ - workspace_id is validated and must belong to that project
62
+ - in direct Bearer mode, workspace-scoped operations MUST use the caller's identity
63
+ """
64
+ try:
65
+ workspace_id = validate_workspace_id(workspace_id)
66
+ except ValueError as e:
67
+ raise HTTPException(status_code=422, detail=str(e))
68
+
69
+ identity = await get_identity_from_auth(request, db)
70
+ project_id = identity.project_id
71
+
72
+ server_db = db.get_manager("server")
73
+ row = await server_db.fetch_one(
74
+ """
75
+ SELECT project_id, deleted_at
76
+ FROM {{tables.workspaces}}
77
+ WHERE workspace_id = $1
78
+ """,
79
+ uuid.UUID(workspace_id),
80
+ )
81
+ if not row:
82
+ raise HTTPException(status_code=404, detail="Workspace not found")
83
+ if row.get("deleted_at") is not None:
84
+ raise HTTPException(status_code=410, detail="Workspace was deleted")
85
+
86
+ ws_project_id = str(row["project_id"])
87
+ if ws_project_id != project_id:
88
+ raise HTTPException(
89
+ status_code=403,
90
+ detail="Workspace not found or does not belong to your project",
91
+ )
92
+
93
+ # If auth provides an agent/actor identity, workspace-scoped operations must use it.
94
+ # This is enforced after existence checks so ghost workspaces still return 404/410.
95
+ if identity.agent_id is not None and identity.agent_id != workspace_id:
96
+ raise HTTPException(
97
+ status_code=403,
98
+ detail="workspace_id does not match API key identity",
99
+ )
100
+
101
+ return project_id
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from uuid import UUID
5
+
6
+ from aweb.auth import parse_bearer_token, verify_bearer_token_details
7
+ from fastapi import HTTPException, Request
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class AwebIdentity:
12
+ project_id: str
13
+ project_slug: str
14
+ project_name: str
15
+ agent_id: str
16
+ alias: str
17
+ human_name: str
18
+
19
+
20
+ async def resolve_aweb_identity(request: Request, db) -> AwebIdentity:
21
+ """Resolve the caller's aweb identity context.
22
+
23
+ BeadHub implements the aweb protocol and owns the aweb schema.
24
+ """
25
+ token = parse_bearer_token(request)
26
+ if token is None:
27
+ raise HTTPException(status_code=401, detail="Authentication required")
28
+
29
+ details = await verify_bearer_token_details(db, token, manager_name="aweb")
30
+ project_id = (details.get("project_id") or "").strip()
31
+ agent_id = (details.get("agent_id") or "").strip()
32
+ if not project_id or not agent_id:
33
+ raise HTTPException(status_code=401, detail="Invalid API key")
34
+
35
+ aweb_db = db.get_manager("aweb")
36
+ agent = await aweb_db.fetch_one(
37
+ """
38
+ SELECT alias, human_name
39
+ FROM {{tables.agents}}
40
+ WHERE agent_id = $1 AND deleted_at IS NULL
41
+ """,
42
+ UUID(agent_id),
43
+ )
44
+ if not agent:
45
+ raise HTTPException(status_code=401, detail="Invalid API key")
46
+
47
+ project = await aweb_db.fetch_one(
48
+ """
49
+ SELECT slug, name
50
+ FROM {{tables.projects}}
51
+ WHERE project_id = $1 AND deleted_at IS NULL
52
+ """,
53
+ UUID(project_id),
54
+ )
55
+ if not project:
56
+ raise HTTPException(status_code=401, detail="Invalid API key")
57
+
58
+ return AwebIdentity(
59
+ project_id=project_id,
60
+ project_slug=project["slug"],
61
+ project_name=project.get("name") or "",
62
+ agent_id=agent_id,
63
+ alias=agent["alias"],
64
+ human_name=agent.get("human_name") or "",
65
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from aweb.auth import DatabaseLike, parse_bearer_token, verify_bearer_token_details
6
+ from fastapi import HTTPException, Request
7
+
8
+ from .internal_auth import parse_internal_auth_context
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AuthIdentity:
13
+ project_id: str
14
+ agent_id: str | None
15
+ api_key_id: str | None
16
+ user_id: str | None
17
+ auth_mode: str # "proxy" or "bearer"
18
+
19
+
20
+ async def get_identity_from_auth(request: Request, db: DatabaseLike) -> AuthIdentity:
21
+ """Resolve the authenticated identity context for BeadHub requests.
22
+
23
+ Priority order:
24
+ 1) Trusted proxy/wrapper auth context (`X-BH-Auth` + `X-Project-ID`)
25
+ 2) Local aweb Bearer API key (default; BeadHub implements the aweb protocol)
26
+ """
27
+ internal = parse_internal_auth_context(request)
28
+ if internal is not None:
29
+ principal_type = (internal.get("principal_type") or "").strip()
30
+ principal_id = (internal.get("principal_id") or "").strip() or None
31
+ actor_id = (internal.get("actor_id") or "").strip() or None
32
+ return AuthIdentity(
33
+ project_id=internal["project_id"],
34
+ agent_id=actor_id,
35
+ api_key_id=principal_id if principal_type == "k" else None,
36
+ user_id=principal_id if principal_type == "u" else None,
37
+ auth_mode="proxy",
38
+ )
39
+
40
+ token = parse_bearer_token(request)
41
+ if token is None:
42
+ raise HTTPException(status_code=401, detail="Authentication required")
43
+
44
+ details = await verify_bearer_token_details(db, token, manager_name="aweb")
45
+ project_id = (details.get("project_id") or "").strip()
46
+ if not project_id:
47
+ raise HTTPException(status_code=401, detail="Invalid API key")
48
+
49
+ agent_id = (details.get("agent_id") or "").strip() or None
50
+ api_key_id = (details.get("api_key_id") or "").strip() or None
51
+ user_id = (details.get("user_id") or "").strip() or None
52
+ return AuthIdentity(
53
+ project_id=project_id,
54
+ agent_id=agent_id,
55
+ api_key_id=api_key_id,
56
+ user_id=user_id,
57
+ auth_mode="bearer",
58
+ )
59
+
60
+
61
+ async def get_project_from_auth(request: Request, db: DatabaseLike) -> str:
62
+ """Resolve the authenticated project_id for BeadHub requests.
63
+
64
+ Priority order:
65
+ 1) Trusted proxy/wrapper auth context (`X-BH-Auth` + `X-Project-ID`)
66
+ 2) Local aweb auth (default; BeadHub implements the aweb protocol)
67
+ """
68
+ # 1) Proxy headers (signed internal context)
69
+ identity = await get_identity_from_auth(request, db)
70
+ return identity.project_id