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/cli.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import typer
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from .config import get_settings
|
|
9
|
+
from .workspace_config import get_workspace_id
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="BeadHub OSS Core CLI")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_workspace_id(override: str | None) -> str:
|
|
15
|
+
"""Resolve workspace_id from override or .beadhub file.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
override: Explicit workspace_id from --workspace-id flag.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
workspace_id string.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
typer.Exit: If no workspace_id is available.
|
|
25
|
+
"""
|
|
26
|
+
workspace_id = get_workspace_id(override=override)
|
|
27
|
+
if not workspace_id:
|
|
28
|
+
typer.echo(
|
|
29
|
+
"Error: No workspace_id available.\n"
|
|
30
|
+
"Either:\n"
|
|
31
|
+
" - Run 'bdh :init' to create a .beadhub file\n"
|
|
32
|
+
" - Pass --workspace-id explicitly",
|
|
33
|
+
err=True,
|
|
34
|
+
)
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
return workspace_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_api_key(override: str | None) -> str | None:
|
|
40
|
+
if override:
|
|
41
|
+
return override
|
|
42
|
+
return os.getenv("BEADHUB_API_KEY")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _handle_api_call(
|
|
46
|
+
method: str,
|
|
47
|
+
url: str,
|
|
48
|
+
allow_statuses: set[int] | None = None,
|
|
49
|
+
api_key: str | None = None,
|
|
50
|
+
**kwargs,
|
|
51
|
+
) -> httpx.Response:
|
|
52
|
+
"""
|
|
53
|
+
Execute an HTTP request with proper error handling.
|
|
54
|
+
Handles network errors, timeouts, and HTTP status errors gracefully.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
method: HTTP method (GET, POST, DELETE)
|
|
58
|
+
url: Request URL
|
|
59
|
+
allow_statuses: Set of status codes to allow through (e.g., {404, 409})
|
|
60
|
+
api_key: API key to include as Authorization: Bearer header
|
|
61
|
+
**kwargs: Additional arguments passed to httpx
|
|
62
|
+
"""
|
|
63
|
+
# Build headers with Authorization if provided (or from BEADHUB_API_KEY env var).
|
|
64
|
+
headers = kwargs.pop("headers", {})
|
|
65
|
+
api_key = api_key or os.getenv("BEADHUB_API_KEY")
|
|
66
|
+
if api_key:
|
|
67
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if method == "GET":
|
|
71
|
+
resp = httpx.get(url, timeout=30, headers=headers, **kwargs)
|
|
72
|
+
elif method == "POST":
|
|
73
|
+
resp = httpx.post(url, timeout=30, headers=headers, **kwargs)
|
|
74
|
+
elif method == "DELETE":
|
|
75
|
+
resp = httpx.delete(url, timeout=30, headers=headers, **kwargs)
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
78
|
+
|
|
79
|
+
# Allow specific status codes through for caller handling
|
|
80
|
+
if allow_statuses and resp.status_code in allow_statuses:
|
|
81
|
+
return resp
|
|
82
|
+
|
|
83
|
+
# Handle common HTTP errors with friendly messages
|
|
84
|
+
if resp.status_code == 401:
|
|
85
|
+
typer.echo("Error: Unauthorized - API key required", err=True)
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
elif resp.status_code == 403:
|
|
88
|
+
typer.echo("Error: Forbidden - insufficient permissions", err=True)
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
elif resp.status_code >= 500:
|
|
91
|
+
typer.echo(f"Error: Server error ({resp.status_code})", err=True)
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
94
|
+
return resp
|
|
95
|
+
|
|
96
|
+
except httpx.ConnectError:
|
|
97
|
+
api_base = _get_api_base()
|
|
98
|
+
typer.echo(f"Error: Cannot connect to BeadHub API at {api_base}", err=True)
|
|
99
|
+
typer.echo("Is the server running? Try: beadhub serve", err=True)
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
except httpx.TimeoutException:
|
|
102
|
+
typer.echo("Error: Request timed out", err=True)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
except httpx.RequestError as e:
|
|
105
|
+
typer.echo(f"Error: Network error - {e}", err=True)
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
escalations_app = typer.Typer(help="Manage escalations")
|
|
110
|
+
beads_app = typer.Typer(help="Beads integration")
|
|
111
|
+
app.add_typer(escalations_app, name="escalations")
|
|
112
|
+
app.add_typer(beads_app, name="beads")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def serve(
|
|
117
|
+
host: str | None = typer.Option(None, help="Host interface to bind"),
|
|
118
|
+
port: int | None = typer.Option(None, help="Port to bind"),
|
|
119
|
+
reload: bool | None = typer.Option(None, help="Enable auto-reload (development only)"),
|
|
120
|
+
log_level: str | None = typer.Option(None, help="Log level for the server"),
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Start the BeadHub API server.
|
|
124
|
+
"""
|
|
125
|
+
settings = get_settings()
|
|
126
|
+
|
|
127
|
+
uvicorn.run(
|
|
128
|
+
"beadhub.api:create_app",
|
|
129
|
+
host=host or settings.host,
|
|
130
|
+
port=port or settings.port,
|
|
131
|
+
reload=reload if reload is not None else settings.reload,
|
|
132
|
+
log_level=log_level or settings.log_level,
|
|
133
|
+
factory=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def status(
|
|
139
|
+
workspace_id: str | None = typer.Option(
|
|
140
|
+
None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
|
|
141
|
+
),
|
|
142
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Show BeadHub workspace status (agent presence and escalations).
|
|
146
|
+
"""
|
|
147
|
+
resolved_workspace_id = _resolve_workspace_id(workspace_id)
|
|
148
|
+
params = {"workspace_id": resolved_workspace_id}
|
|
149
|
+
resp = _handle_api_call("GET", f"{_get_api_base()}/v1/status", params=params)
|
|
150
|
+
data = resp.json()
|
|
151
|
+
|
|
152
|
+
if json_output:
|
|
153
|
+
typer.echo(json.dumps(data, indent=2))
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
workspace = data.get("workspace", {})
|
|
157
|
+
typer.echo(f"Workspace: {workspace.get('workspace_id')}")
|
|
158
|
+
typer.echo(f"Timestamp: {data.get('timestamp')}")
|
|
159
|
+
typer.echo("")
|
|
160
|
+
|
|
161
|
+
agents = data.get("agents", [])
|
|
162
|
+
if agents:
|
|
163
|
+
typer.echo("Agents:")
|
|
164
|
+
for agent in agents:
|
|
165
|
+
typer.echo(
|
|
166
|
+
f" {agent.get('alias', '?'):15} status={agent.get('status','?'):8} "
|
|
167
|
+
f"issue={agent.get('current_issue') or '-'}"
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
typer.echo("Agents: (none)")
|
|
171
|
+
|
|
172
|
+
typer.echo("")
|
|
173
|
+
typer.echo(f"Escalations pending: {data.get('escalations_pending', 0)}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_api_base() -> str:
|
|
177
|
+
return os.getenv("BEADHUB_API_URL", "http://localhost:8000")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@escalations_app.command("list")
|
|
181
|
+
def escalations_list(
|
|
182
|
+
workspace_id: str | None = typer.Option(
|
|
183
|
+
None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
|
|
184
|
+
),
|
|
185
|
+
status: str | None = typer.Option(None, help="Filter by status"),
|
|
186
|
+
agent: str | None = typer.Option(None, help="Filter by agent name"),
|
|
187
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
188
|
+
) -> None:
|
|
189
|
+
"""
|
|
190
|
+
List escalations from the API.
|
|
191
|
+
"""
|
|
192
|
+
resolved_workspace_id = _resolve_workspace_id(workspace_id)
|
|
193
|
+
params: dict[str, str] = {"workspace_id": resolved_workspace_id}
|
|
194
|
+
if status:
|
|
195
|
+
params["status"] = status
|
|
196
|
+
if agent:
|
|
197
|
+
params["alias"] = agent
|
|
198
|
+
|
|
199
|
+
resp = _handle_api_call("GET", f"{_get_api_base()}/v1/escalations", params=params)
|
|
200
|
+
data = resp.json()
|
|
201
|
+
|
|
202
|
+
if json_output:
|
|
203
|
+
typer.echo(json.dumps(data, indent=2))
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
for esc in data.get("escalations", []):
|
|
207
|
+
typer.echo(
|
|
208
|
+
f"{esc['escalation_id']} {esc['status']:9} " f"{esc['alias']:15} {esc['subject']}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@escalations_app.command("view")
|
|
213
|
+
def escalations_view(escalation_id: str) -> None:
|
|
214
|
+
"""
|
|
215
|
+
View escalation details.
|
|
216
|
+
"""
|
|
217
|
+
resp = _handle_api_call(
|
|
218
|
+
"GET", f"{_get_api_base()}/v1/escalations/{escalation_id}", allow_statuses={404}
|
|
219
|
+
)
|
|
220
|
+
if resp.status_code == 404:
|
|
221
|
+
typer.echo("Error: Escalation not found", err=True)
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
typer.echo(json.dumps(resp.json(), indent=2))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@beads_app.command("issues")
|
|
227
|
+
def beads_issues(
|
|
228
|
+
workspace_id: str | None = typer.Option(
|
|
229
|
+
None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
|
|
230
|
+
),
|
|
231
|
+
api_key: str | None = typer.Option(
|
|
232
|
+
None, "--api-key", help="BeadHub API key (defaults to BEADHUB_API_KEY)"
|
|
233
|
+
),
|
|
234
|
+
status: str | None = typer.Option(None, help="Filter by status"),
|
|
235
|
+
assignee: str | None = typer.Option(None, help="Filter by assignee"),
|
|
236
|
+
label: str | None = typer.Option(None, help="Filter by label"),
|
|
237
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""
|
|
240
|
+
List Beads issues from BeadHub.
|
|
241
|
+
"""
|
|
242
|
+
resolved_workspace_id = _resolve_workspace_id(workspace_id)
|
|
243
|
+
resolved_api_key = _resolve_api_key(api_key)
|
|
244
|
+
if not resolved_api_key:
|
|
245
|
+
typer.echo("Error: BEADHUB_API_KEY not set (run `bdh :init` or pass --api-key)", err=True)
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
params: dict[str, str] = {"workspace_id": resolved_workspace_id}
|
|
248
|
+
if status:
|
|
249
|
+
params["status"] = status
|
|
250
|
+
if assignee:
|
|
251
|
+
params["assignee"] = assignee
|
|
252
|
+
if label:
|
|
253
|
+
params["label"] = label
|
|
254
|
+
|
|
255
|
+
resp = _handle_api_call(
|
|
256
|
+
"GET",
|
|
257
|
+
f"{_get_api_base()}/v1/beads/issues",
|
|
258
|
+
params=params,
|
|
259
|
+
api_key=resolved_api_key,
|
|
260
|
+
)
|
|
261
|
+
data = resp.json()
|
|
262
|
+
|
|
263
|
+
if json_output:
|
|
264
|
+
typer.echo(json.dumps(data, indent=2))
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
for issue in data.get("issues", []):
|
|
268
|
+
typer.echo(
|
|
269
|
+
f"{issue['bead_id']} P{issue['priority']} " f"{issue['status']:10} {issue['title']}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@beads_app.command("ready")
|
|
274
|
+
def beads_ready(
|
|
275
|
+
workspace_id: str | None = typer.Option(
|
|
276
|
+
None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
|
|
277
|
+
),
|
|
278
|
+
api_key: str | None = typer.Option(
|
|
279
|
+
None, "--api-key", help="BeadHub API key (defaults to BEADHUB_API_KEY)"
|
|
280
|
+
),
|
|
281
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
282
|
+
) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Show Beads issues that are ready to work on.
|
|
285
|
+
"""
|
|
286
|
+
resolved_workspace_id = _resolve_workspace_id(workspace_id)
|
|
287
|
+
resolved_api_key = _resolve_api_key(api_key)
|
|
288
|
+
if not resolved_api_key:
|
|
289
|
+
typer.echo("Error: BEADHUB_API_KEY not set (run `bdh :init` or pass --api-key)", err=True)
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
params = {"workspace_id": resolved_workspace_id}
|
|
292
|
+
resp = _handle_api_call(
|
|
293
|
+
"GET",
|
|
294
|
+
f"{_get_api_base()}/v1/beads/ready",
|
|
295
|
+
params=params,
|
|
296
|
+
api_key=resolved_api_key,
|
|
297
|
+
)
|
|
298
|
+
data = resp.json()
|
|
299
|
+
|
|
300
|
+
if json_output:
|
|
301
|
+
typer.echo(json.dumps(data, indent=2))
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
for issue in data.get("issues", []):
|
|
305
|
+
typer.echo(f"{issue['bead_id']} P{issue['priority']} {issue['title']}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@escalations_app.command("respond")
|
|
309
|
+
def escalations_respond(
|
|
310
|
+
escalation_id: str,
|
|
311
|
+
choice: str = typer.Option(..., "--choice", help="Chosen response"),
|
|
312
|
+
note: str | None = typer.Option(None, "--note", help="Optional note"),
|
|
313
|
+
) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Respond to an escalation.
|
|
316
|
+
"""
|
|
317
|
+
payload = {"response": choice}
|
|
318
|
+
if note:
|
|
319
|
+
payload["note"] = note
|
|
320
|
+
|
|
321
|
+
resp = _handle_api_call(
|
|
322
|
+
"POST",
|
|
323
|
+
f"{_get_api_base()}/v1/escalations/{escalation_id}/respond",
|
|
324
|
+
allow_statuses={404},
|
|
325
|
+
json=payload,
|
|
326
|
+
)
|
|
327
|
+
if resp.status_code == 404:
|
|
328
|
+
typer.echo("Error: Escalation not found", err=True)
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
typer.echo(json.dumps(resp.json(), indent=2))
|
beadhub/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Settings:
|
|
7
|
+
host: str
|
|
8
|
+
port: int
|
|
9
|
+
log_level: str
|
|
10
|
+
reload: bool
|
|
11
|
+
redis_url: str
|
|
12
|
+
database_url: str
|
|
13
|
+
presence_ttl_seconds: int
|
|
14
|
+
dashboard_human: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_settings() -> Settings:
|
|
18
|
+
"""
|
|
19
|
+
Load settings from environment at call time.
|
|
20
|
+
|
|
21
|
+
Accepts both prefixed (BEADHUB_*) and unprefixed env vars for flexibility.
|
|
22
|
+
"""
|
|
23
|
+
database_url = os.getenv("BEADHUB_DATABASE_URL") or os.getenv("DATABASE_URL")
|
|
24
|
+
if not database_url:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"DATABASE_URL or BEADHUB_DATABASE_URL environment variable is required. "
|
|
27
|
+
"Example: postgresql://user:pass@localhost:5432/beadhub"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
redis_url = (
|
|
31
|
+
os.getenv("BEADHUB_REDIS_URL") or os.getenv("REDIS_URL") or "redis://localhost:6379/0"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
port_str = os.getenv("BEADHUB_PORT", "8000")
|
|
35
|
+
try:
|
|
36
|
+
port = int(port_str)
|
|
37
|
+
if not 1 <= port <= 65535:
|
|
38
|
+
raise ValueError(f"BEADHUB_PORT must be between 1 and 65535, got {port}")
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
if "invalid literal" in str(e):
|
|
41
|
+
raise ValueError(f"BEADHUB_PORT must be a valid integer, got '{port_str}'")
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
presence_ttl_str = os.getenv("BEADHUB_PRESENCE_TTL_SECONDS", "1800")
|
|
45
|
+
try:
|
|
46
|
+
presence_ttl = int(presence_ttl_str)
|
|
47
|
+
if presence_ttl < 10:
|
|
48
|
+
raise ValueError("BEADHUB_PRESENCE_TTL_SECONDS must be at least 10")
|
|
49
|
+
except ValueError as e:
|
|
50
|
+
if "invalid literal" in str(e):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"BEADHUB_PRESENCE_TTL_SECONDS must be a valid integer, got '{presence_ttl_str}'"
|
|
53
|
+
)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
return Settings(
|
|
57
|
+
host=os.getenv("BEADHUB_HOST", "0.0.0.0"),
|
|
58
|
+
port=port,
|
|
59
|
+
log_level=os.getenv("BEADHUB_LOG_LEVEL", "info"),
|
|
60
|
+
reload=os.getenv("BEADHUB_RELOAD", "false").lower() == "true",
|
|
61
|
+
redis_url=redis_url,
|
|
62
|
+
database_url=database_url,
|
|
63
|
+
presence_ttl_seconds=presence_ttl,
|
|
64
|
+
dashboard_human=os.getenv("BEADHUB_DASHBOARD_HUMAN", "admin"),
|
|
65
|
+
)
|
beadhub/db.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from pgdbm import AsyncDatabaseManager, DatabaseConfig
|
|
9
|
+
from pgdbm.migrations import AsyncMigrationManager
|
|
10
|
+
|
|
11
|
+
from .config import get_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DatabaseInfra:
|
|
15
|
+
"""
|
|
16
|
+
Shared pgdbm infrastructure for BeadHub.
|
|
17
|
+
|
|
18
|
+
Creates a single shared pool and schema-specific managers
|
|
19
|
+
for core modules (server, beads).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._shared_pool: Optional[Any] = None
|
|
24
|
+
self._managers: Dict[str, AsyncDatabaseManager] = {}
|
|
25
|
+
self._initialized: bool = False
|
|
26
|
+
self._init_lock: asyncio.Lock = asyncio.Lock()
|
|
27
|
+
self._owns_pool: bool = True
|
|
28
|
+
|
|
29
|
+
async def initialize(self, *, shared_pool: Optional[Any] = None) -> None:
|
|
30
|
+
if self._initialized:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
async with self._init_lock:
|
|
34
|
+
# Double-check after acquiring lock (another coroutine may have initialized)
|
|
35
|
+
if self._initialized:
|
|
36
|
+
return # type: ignore[unreachable] # Valid double-checked locking
|
|
37
|
+
|
|
38
|
+
if shared_pool is None:
|
|
39
|
+
settings = get_settings()
|
|
40
|
+
config = DatabaseConfig(connection_string=settings.database_url)
|
|
41
|
+
shared_pool = await AsyncDatabaseManager.create_shared_pool(config)
|
|
42
|
+
self._owns_pool = True
|
|
43
|
+
else:
|
|
44
|
+
# Host application owns lifecycle of the pool.
|
|
45
|
+
self._owns_pool = False
|
|
46
|
+
|
|
47
|
+
self._shared_pool = shared_pool
|
|
48
|
+
|
|
49
|
+
self._managers["server"] = AsyncDatabaseManager(
|
|
50
|
+
pool=shared_pool,
|
|
51
|
+
schema="server",
|
|
52
|
+
)
|
|
53
|
+
self._managers["beads"] = AsyncDatabaseManager(
|
|
54
|
+
pool=shared_pool,
|
|
55
|
+
schema="beads",
|
|
56
|
+
)
|
|
57
|
+
# BeadHub implements the aweb protocol; keep identity/coordination tables
|
|
58
|
+
# in a dedicated schema for clean extraction.
|
|
59
|
+
self._managers["aweb"] = AsyncDatabaseManager(
|
|
60
|
+
pool=shared_pool,
|
|
61
|
+
schema="aweb",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
base_dir = Path(__file__).resolve().parent
|
|
65
|
+
migrations_root = base_dir / "migrations"
|
|
66
|
+
|
|
67
|
+
for name, db in self._managers.items():
|
|
68
|
+
# Ensure schema exists before applying migrations
|
|
69
|
+
await db.execute(f'CREATE SCHEMA IF NOT EXISTS "{db.schema}"')
|
|
70
|
+
|
|
71
|
+
if name == "aweb":
|
|
72
|
+
# Apply aweb protocol migrations using the same module_name as the
|
|
73
|
+
# standalone aweb server so migration history is shared.
|
|
74
|
+
import aweb as aweb_pkg
|
|
75
|
+
|
|
76
|
+
aweb_root = Path(aweb_pkg.__file__).resolve().parent
|
|
77
|
+
module_migrations = aweb_root / "migrations" / "aweb"
|
|
78
|
+
module_name = "aweb-aweb"
|
|
79
|
+
else:
|
|
80
|
+
module_migrations = migrations_root / name
|
|
81
|
+
module_name = f"beadhub-{name}"
|
|
82
|
+
|
|
83
|
+
if module_migrations.is_dir():
|
|
84
|
+
manager = AsyncMigrationManager(
|
|
85
|
+
db,
|
|
86
|
+
migrations_path=str(module_migrations),
|
|
87
|
+
module_name=module_name,
|
|
88
|
+
)
|
|
89
|
+
await manager.apply_pending_migrations()
|
|
90
|
+
|
|
91
|
+
self._initialized = True
|
|
92
|
+
|
|
93
|
+
async def close(self) -> None:
|
|
94
|
+
if self._shared_pool is not None and self._owns_pool:
|
|
95
|
+
await self._shared_pool.close()
|
|
96
|
+
|
|
97
|
+
self._managers.clear()
|
|
98
|
+
self._shared_pool = None
|
|
99
|
+
self._initialized = False
|
|
100
|
+
self._owns_pool = True
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_initialized(self) -> bool:
|
|
104
|
+
"""Check if the database infrastructure is initialized."""
|
|
105
|
+
return self._initialized
|
|
106
|
+
|
|
107
|
+
def get_manager(self, name: str = "server") -> AsyncDatabaseManager:
|
|
108
|
+
if not self._initialized:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"DatabaseInfra is not initialized. " "Call 'await db_infra.initialize()' first."
|
|
111
|
+
)
|
|
112
|
+
manager = self._managers.get(name)
|
|
113
|
+
if manager is None:
|
|
114
|
+
available = ", ".join(sorted(self._managers.keys())) or "(none)"
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"Unknown database manager '{name}'. Available managers: {available}"
|
|
117
|
+
)
|
|
118
|
+
return manager
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
db_infra = DatabaseInfra()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_db_infra(request: Request) -> DatabaseInfra:
|
|
125
|
+
"""FastAPI dependency that returns the DatabaseInfra from app state.
|
|
126
|
+
|
|
127
|
+
Works in both standalone and library modes since both set app.state.db.
|
|
128
|
+
"""
|
|
129
|
+
return request.app.state.db
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: tracking.bdh-only
|
|
3
|
+
title: Use bdh for tracking
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Track all tasks and issues with `bdh`. Do not create markdown TODO lists or external trackers.
|
|
7
|
+
|
|
8
|
+
- Run `bdh :policy` for full command reference and workflow details
|
|
9
|
+
- Use `bdh ready` to find unblocked work
|
|
10
|
+
- Link discovered work with `--deps discovered-from:<parent-id>`
|
|
11
|
+
- Store planning docs in `history/` if needed
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: communication.mail-first
|
|
3
|
+
title: Mail-first communication
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Default to mail (`bdh :aweb mail`) for coordination.
|
|
7
|
+
|
|
8
|
+
Use mail for:
|
|
9
|
+
- Status updates and progress reports
|
|
10
|
+
- Review requests and feedback
|
|
11
|
+
- FYI notifications
|
|
12
|
+
- Non-blocking questions
|
|
13
|
+
|
|
14
|
+
## Sending Messages
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bdh :aweb mail send <agent> "Status update: completed bd-42"
|
|
18
|
+
bdh :aweb mail send <agent> "Review request: PR #123 ready" --subject "Review needed"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Checking Your Inbox
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bdh :aweb mail list # Show unread messages
|
|
25
|
+
bdh :aweb mail list --all # Include read messages
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Reading Messages
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bdh :aweb mail open <sender> # Read unread mail from sender (acknowledges)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use chat (`bdh :aweb chat`) only when you need a synchronous answer to proceed. See the **communication.chat** invariant for chat details.
|
|
35
|
+
|
|
36
|
+
**Respond immediately to WAITING notifications** — someone is blocked waiting for your reply.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: communication.chat
|
|
3
|
+
title: Chat for synchronous coordination
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use chat (`bdh :aweb chat`) when you need a **synchronous answer** to proceed. Sessions are persistent and messages are never lost.
|
|
7
|
+
|
|
8
|
+
## Subcommands
|
|
9
|
+
|
|
10
|
+
| Subcommand | Purpose |
|
|
11
|
+
|------------|---------|
|
|
12
|
+
| `chat send <alias> "msg"` | Send a message (60s default wait) |
|
|
13
|
+
| `chat send <alias> "msg" --start-conversation` | Start a new exchange (5 min default wait) |
|
|
14
|
+
| `chat send <alias> "msg" --leave-conversation` | Send final message and exit |
|
|
15
|
+
| `chat open <alias>` | Read unread messages (marks as read) |
|
|
16
|
+
| `chat pending` | List chat sessions with unread messages |
|
|
17
|
+
| `chat history <alias>` | Show conversation history |
|
|
18
|
+
| `chat hang-on <alias> "msg"` | Request more time before replying |
|
|
19
|
+
|
|
20
|
+
## Starting vs Continuing Conversations
|
|
21
|
+
|
|
22
|
+
**Starting a new exchange** — initiates and waits for the target to notice:
|
|
23
|
+
```bash
|
|
24
|
+
bdh :aweb chat send <agent> "Can we discuss the API design?" --start-conversation
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Continuing a conversation** — reply and wait briefly:
|
|
28
|
+
```bash
|
|
29
|
+
bdh :aweb chat send <agent> "What about the error handling?"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Signing off** — send final message, exit immediately:
|
|
33
|
+
```bash
|
|
34
|
+
bdh :aweb chat send <agent> "Got it, thanks!" --leave-conversation
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Wait Behavior
|
|
38
|
+
|
|
39
|
+
`--start-conversation` waits 5 minutes by default; a plain `send` waits 60 seconds. Override with `--wait N` (seconds).
|
|
40
|
+
|
|
41
|
+
## Receiving Messages
|
|
42
|
+
|
|
43
|
+
Check for pending conversations:
|
|
44
|
+
```bash
|
|
45
|
+
bdh :aweb chat pending
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Notifications appear on any bdh command:
|
|
49
|
+
```
|
|
50
|
+
WAITING: agent-p1 is waiting for you
|
|
51
|
+
"Is project_id nullable?"
|
|
52
|
+
→ Reply: bdh :aweb chat send agent-p1 "your reply"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**WAITING** means the sender is actively waiting — reply promptly.
|
|
56
|
+
|
|
57
|
+
If you need more time before replying:
|
|
58
|
+
```bash
|
|
59
|
+
bdh :aweb chat hang-on agent-p1 "Looking into it, give me a few minutes"
|
|
60
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: identity.no-impersonation
|
|
3
|
+
title: No workspace impersonation
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Never run `bdh` from another workspace or worktree.
|
|
7
|
+
|
|
8
|
+
`bdh` derives your identity from the `.beadhub` file in the current worktree. Running `bdh` from another repo or worktree can impersonate that workspace's agent, causing:
|
|
9
|
+
|
|
10
|
+
- Messages sent as the wrong agent
|
|
11
|
+
- Work claimed under the wrong identity
|
|
12
|
+
- Confusion in coordination
|
|
13
|
+
|
|
14
|
+
**Always verify** you're in the correct worktree before running bdh commands:
|
|
15
|
+
```bash
|
|
16
|
+
bdh :aweb whoami # Check your identity
|
|
17
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: teamwork.collaborate
|
|
3
|
+
title: Collaborate toward the goal
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are part of a team working toward a shared goal. Optimize for the project outcome, not your individual activity.
|
|
7
|
+
|
|
8
|
+
- Help teammates when they're blocked — respond promptly to messages
|
|
9
|
+
- Pick up work that advances the goal, even if it's not "yours"
|
|
10
|
+
- Escalate blockers early rather than spinning alone
|
|
11
|
+
- Keep changes small and reviewable so others can build on your work
|
|
12
|
+
|