yeschef-cli 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.
- yeschef/__init__.py +3 -0
- yeschef/__main__.py +6 -0
- yeschef/agent/__init__.py +6 -0
- yeschef/agent/backends/__init__.py +53 -0
- yeschef/agent/backends/anthropic_compat.py +119 -0
- yeschef/agent/backends/base.py +50 -0
- yeschef/agent/backends/cli.py +99 -0
- yeschef/agent/backends/openai_compat.py +118 -0
- yeschef/agent/config.py +98 -0
- yeschef/agent/detect.py +98 -0
- yeschef/agent/harness.py +1009 -0
- yeschef/cli.py +1073 -0
- yeschef/hub/__init__.py +16 -0
- yeschef/hub/api.py +595 -0
- yeschef/hub/app.py +43 -0
- yeschef/hub/dashboard.html +206 -0
- yeschef/hub/events.py +78 -0
- yeschef/hub/mcp_server.py +941 -0
- yeschef/hub/schema.sql +106 -0
- yeschef/hub/store.py +1621 -0
- yeschef/models.py +431 -0
- yeschef/procs.py +109 -0
- yeschef/replay.py +224 -0
- yeschef/resources/__init__.py +0 -0
- yeschef/resources/agents/__init__.py +0 -0
- yeschef/resources/agents/yeschef-expediter.md +74 -0
- yeschef/resources/skill/SKILL.md +212 -0
- yeschef/resources/skill/__init__.py +0 -0
- yeschef/sdk/__init__.py +9 -0
- yeschef/sdk/client.py +359 -0
- yeschef/settings.py +100 -0
- yeschef/tools/__init__.py +5 -0
- yeschef/tools/executor.py +296 -0
- yeschef_cli-0.1.0.dist-info/METADATA +254 -0
- yeschef_cli-0.1.0.dist-info/RECORD +38 -0
- yeschef_cli-0.1.0.dist-info/WHEEL +4 -0
- yeschef_cli-0.1.0.dist-info/entry_points.txt +3 -0
- yeschef_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
yeschef/hub/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Hub: SQLite store, agent REST/SSE API, and the FastMCP tool surface."""
|
|
2
|
+
|
|
3
|
+
from .api import HubConfig, create_app
|
|
4
|
+
from .app import app_factory, build_hub, config_from_env
|
|
5
|
+
from .events import EventBus
|
|
6
|
+
from .store import Store
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"EventBus",
|
|
10
|
+
"HubConfig",
|
|
11
|
+
"Store",
|
|
12
|
+
"app_factory",
|
|
13
|
+
"build_hub",
|
|
14
|
+
"config_from_env",
|
|
15
|
+
"create_app",
|
|
16
|
+
]
|
yeschef/hub/api.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Agent-facing REST + SSE API.
|
|
2
|
+
|
|
3
|
+
Authentication: agents present the bearer token minted at registration. Admin token (if
|
|
4
|
+
configured) unlocks registration and the cross-identity `/watch` stream.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Body, Depends, FastAPI, Header, Query, Request
|
|
18
|
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
19
|
+
|
|
20
|
+
from ..models import (
|
|
21
|
+
DEFAULT_TASK_TIMEOUT_S,
|
|
22
|
+
MAX_LONG_POLL_S,
|
|
23
|
+
AgentKind,
|
|
24
|
+
ErrorCode,
|
|
25
|
+
HubError,
|
|
26
|
+
RoomPolicy,
|
|
27
|
+
TaskState,
|
|
28
|
+
now,
|
|
29
|
+
)
|
|
30
|
+
from .store import Store
|
|
31
|
+
|
|
32
|
+
SSE_KEEPALIVE_S = 15.0
|
|
33
|
+
|
|
34
|
+
# Must live at module scope: `from __future__ import annotations` turns every annotation
|
|
35
|
+
# into a string, and FastAPI resolves those against module globals only.
|
|
36
|
+
AuthHeader = Annotated[str | None, Header(alias="Authorization")]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class HubConfig:
|
|
41
|
+
db_path: str = "~/.yeschef/hub.db"
|
|
42
|
+
admin_token: str | None = None
|
|
43
|
+
register_token: str | None = None
|
|
44
|
+
sweep_interval_s: float = 10.0
|
|
45
|
+
default_identity: str = "claude:local"
|
|
46
|
+
cors_origins: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _bearer(header: str | None) -> str | None:
|
|
50
|
+
if not header:
|
|
51
|
+
return None
|
|
52
|
+
scheme, _, token = header.partition(" ")
|
|
53
|
+
if scheme.lower() != "bearer" or not token:
|
|
54
|
+
return None
|
|
55
|
+
return token.strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Auth:
|
|
59
|
+
"""Resolves the calling identity for agent-facing routes.
|
|
60
|
+
|
|
61
|
+
A hub with no tokens configured runs open on the LAN (and says so at startup). As
|
|
62
|
+
soon as any token is configured, reads and admin actions require one — otherwise
|
|
63
|
+
task specs and room transcripts would stay world-readable on a hub the operator
|
|
64
|
+
believes is locked down.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, store: Store, config: HubConfig) -> None:
|
|
68
|
+
self.store = store
|
|
69
|
+
self.config = config
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def enforced(self) -> bool:
|
|
73
|
+
return self.config.admin_token is not None or self.config.register_token is not None
|
|
74
|
+
|
|
75
|
+
def is_privileged(self, header: str | None) -> bool:
|
|
76
|
+
token = _bearer(header)
|
|
77
|
+
return token is not None and token == self.config.admin_token
|
|
78
|
+
|
|
79
|
+
def admin(self, header: str | None) -> None:
|
|
80
|
+
if self.config.admin_token is None:
|
|
81
|
+
return
|
|
82
|
+
if _bearer(header) != self.config.admin_token:
|
|
83
|
+
raise HubError(ErrorCode.UNAUTHORIZED, "admin token required", 401)
|
|
84
|
+
|
|
85
|
+
def reader(self, header: str | None) -> str | None:
|
|
86
|
+
"""Any valid identity may read. Returns the caller's name, or None in open mode."""
|
|
87
|
+
if not self.enforced:
|
|
88
|
+
return None
|
|
89
|
+
token = _bearer(header)
|
|
90
|
+
if token is None:
|
|
91
|
+
raise HubError(ErrorCode.UNAUTHORIZED, "bearer token required", 401)
|
|
92
|
+
if token == self.config.admin_token:
|
|
93
|
+
return "admin"
|
|
94
|
+
name = self.store.identify_token(token)
|
|
95
|
+
if name is None:
|
|
96
|
+
raise HubError(ErrorCode.UNAUTHORIZED, "unknown token", 401)
|
|
97
|
+
return name
|
|
98
|
+
|
|
99
|
+
def agent(self, name: str, header: str | None) -> str:
|
|
100
|
+
token = _bearer(header)
|
|
101
|
+
if token is None:
|
|
102
|
+
raise HubError(ErrorCode.UNAUTHORIZED, "bearer token required", 401)
|
|
103
|
+
if self.config.admin_token is not None and token == self.config.admin_token:
|
|
104
|
+
return name
|
|
105
|
+
if not self.store.verify_token(name, token):
|
|
106
|
+
raise HubError(ErrorCode.UNAUTHORIZED, "invalid token for agent", 401)
|
|
107
|
+
self.store.heartbeat(name)
|
|
108
|
+
return name
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _require_room_access(caller: str | None, room) -> None:
|
|
112
|
+
"""Open mode (caller is None) reads freely; an authenticated caller must belong."""
|
|
113
|
+
if caller is None or caller == "admin":
|
|
114
|
+
return
|
|
115
|
+
if caller not in room.members:
|
|
116
|
+
raise HubError(ErrorCode.FORBIDDEN, "not a member of this room", 403)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_router(store: Store, config: HubConfig) -> APIRouter:
|
|
120
|
+
router = APIRouter(prefix="/api/v1")
|
|
121
|
+
auth = Auth(store, config)
|
|
122
|
+
|
|
123
|
+
def as_agent(name: str, authorization: AuthHeader = None) -> str:
|
|
124
|
+
return auth.agent(name, authorization)
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------- agents
|
|
127
|
+
|
|
128
|
+
@router.post("/agents/register")
|
|
129
|
+
def register(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
|
|
130
|
+
bearer = _bearer(authorization)
|
|
131
|
+
gate_tokens = {t for t in (config.register_token, config.admin_token) if t}
|
|
132
|
+
privileged = bearer is not None and bearer in gate_tokens
|
|
133
|
+
if config.register_token is not None and not privileged:
|
|
134
|
+
raise HubError(ErrorCode.UNAUTHORIZED, "registration token required", 401)
|
|
135
|
+
# `kind` is not self-declared: a claude-kind agent is exempt from round-robin
|
|
136
|
+
# floor control, so anyone could otherwise register as one and talk without limit.
|
|
137
|
+
kind = AgentKind(payload.get("kind", AgentKind.WORKER))
|
|
138
|
+
if kind is not AgentKind.WORKER and not privileged:
|
|
139
|
+
raise HubError(ErrorCode.FORBIDDEN, "only workers may self-register", 403)
|
|
140
|
+
agent, token = store.register_agent(
|
|
141
|
+
name=payload["name"],
|
|
142
|
+
kind=kind,
|
|
143
|
+
node=payload.get("node"),
|
|
144
|
+
backend=payload.get("backend"),
|
|
145
|
+
tags=payload.get("tags") or [],
|
|
146
|
+
presented_token=None if privileged else bearer,
|
|
147
|
+
privileged=privileged,
|
|
148
|
+
)
|
|
149
|
+
return {"agent": agent.to_dict(), "token": token}
|
|
150
|
+
|
|
151
|
+
@router.post("/agents/{name}/heartbeat")
|
|
152
|
+
def heartbeat(name: str, agent: str = Depends(as_agent)) -> dict:
|
|
153
|
+
return {"ok": True, "agent": name}
|
|
154
|
+
|
|
155
|
+
@router.get("/agents")
|
|
156
|
+
def list_agents(kind: str | None = None, authorization: AuthHeader = None) -> dict:
|
|
157
|
+
auth.reader(authorization)
|
|
158
|
+
agents = store.list_agents(AgentKind(kind) if kind else None)
|
|
159
|
+
return {"agents": [a.to_dict() for a in agents]}
|
|
160
|
+
|
|
161
|
+
@router.get("/agents/{name}/events")
|
|
162
|
+
async def agent_events(
|
|
163
|
+
name: str, request: Request, agent: str = Depends(as_agent)
|
|
164
|
+
) -> StreamingResponse:
|
|
165
|
+
return StreamingResponse(
|
|
166
|
+
_event_stream(store, name, request),
|
|
167
|
+
media_type="text/event-stream",
|
|
168
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# -------------------------------------------------------------- rooms
|
|
172
|
+
|
|
173
|
+
@router.post("/rooms")
|
|
174
|
+
def create_room(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
|
|
175
|
+
creator = payload["as_agent"]
|
|
176
|
+
auth.agent(creator, authorization)
|
|
177
|
+
room = store.create_room(
|
|
178
|
+
topic=payload.get("topic", "room"),
|
|
179
|
+
created_by=creator,
|
|
180
|
+
participants=payload.get("participants") or [],
|
|
181
|
+
policy=RoomPolicy.from_dict(payload.get("policy")),
|
|
182
|
+
open_room=bool(payload.get("open")),
|
|
183
|
+
)
|
|
184
|
+
return {"room": room.to_dict()}
|
|
185
|
+
|
|
186
|
+
@router.get("/rooms")
|
|
187
|
+
def list_rooms(
|
|
188
|
+
as_agent: str | None = Query(None),
|
|
189
|
+
include_archived: bool = False,
|
|
190
|
+
authorization: AuthHeader = None,
|
|
191
|
+
) -> dict:
|
|
192
|
+
"""Rooms for one agent, or every room when called with the admin token."""
|
|
193
|
+
if as_agent:
|
|
194
|
+
auth.agent(as_agent, authorization)
|
|
195
|
+
else:
|
|
196
|
+
auth.admin(authorization)
|
|
197
|
+
rooms = store.list_rooms(agent=as_agent, include_archived=include_archived)
|
|
198
|
+
return {"rooms": [r.to_dict() for r in rooms]}
|
|
199
|
+
|
|
200
|
+
@router.get("/rooms/{room_id}")
|
|
201
|
+
def get_room(room_id: str, authorization: AuthHeader = None) -> dict:
|
|
202
|
+
caller = auth.reader(authorization)
|
|
203
|
+
room = store.require_room(room_id)
|
|
204
|
+
_require_room_access(caller, room)
|
|
205
|
+
return {"room": room.to_dict()}
|
|
206
|
+
|
|
207
|
+
@router.post("/rooms/{room_id}/join")
|
|
208
|
+
def join_room(
|
|
209
|
+
room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
210
|
+
) -> dict:
|
|
211
|
+
who = payload["as_agent"]
|
|
212
|
+
auth.agent(who, authorization)
|
|
213
|
+
privileged = auth.is_privileged(authorization)
|
|
214
|
+
return {"room": store.join_room(room_id, who, privileged=privileged).to_dict()}
|
|
215
|
+
|
|
216
|
+
@router.post("/rooms/{room_id}/leave")
|
|
217
|
+
def leave_room(
|
|
218
|
+
room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
219
|
+
) -> dict:
|
|
220
|
+
who = payload["as_agent"]
|
|
221
|
+
auth.agent(who, authorization)
|
|
222
|
+
store.leave_room(room_id, who)
|
|
223
|
+
return {"ok": True}
|
|
224
|
+
|
|
225
|
+
@router.post("/rooms/{room_id}/archive")
|
|
226
|
+
def archive_room(
|
|
227
|
+
room_id: str, payload: dict = Body(default={}), authorization: AuthHeader = None
|
|
228
|
+
) -> dict:
|
|
229
|
+
who = payload.get("as_agent")
|
|
230
|
+
if who:
|
|
231
|
+
auth.agent(who, authorization)
|
|
232
|
+
else:
|
|
233
|
+
auth.admin(authorization)
|
|
234
|
+
reason = payload.get("reason") or f"archived by {who or 'admin'}"
|
|
235
|
+
privileged = who is None or auth.is_privileged(authorization)
|
|
236
|
+
archived = store.archive_room(room_id, reason, by=who, privileged=privileged)
|
|
237
|
+
return {"room": archived.to_dict()}
|
|
238
|
+
|
|
239
|
+
@router.post("/rooms/{room_id}/messages")
|
|
240
|
+
def post_message(
|
|
241
|
+
room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
242
|
+
) -> dict:
|
|
243
|
+
sender = payload["as_agent"]
|
|
244
|
+
auth.agent(sender, authorization)
|
|
245
|
+
message = store.post_message(
|
|
246
|
+
room_id=room_id,
|
|
247
|
+
sender=sender,
|
|
248
|
+
body=payload["body"],
|
|
249
|
+
data=payload.get("data"),
|
|
250
|
+
reply_to=payload.get("reply_to"),
|
|
251
|
+
mentions=payload.get("mentions"),
|
|
252
|
+
client_msg_id=payload.get("client_msg_id"),
|
|
253
|
+
tokens=payload.get("tokens"),
|
|
254
|
+
)
|
|
255
|
+
return {"message": message.to_dict()}
|
|
256
|
+
|
|
257
|
+
@router.post("/rooms/{room_id}/yield")
|
|
258
|
+
def yield_floor(
|
|
259
|
+
room_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
260
|
+
) -> dict:
|
|
261
|
+
who = payload["as_agent"]
|
|
262
|
+
auth.agent(who, authorization)
|
|
263
|
+
return {"room": store.yield_floor(room_id, who).to_dict()}
|
|
264
|
+
|
|
265
|
+
@router.get("/rooms/{room_id}/messages")
|
|
266
|
+
def get_messages(
|
|
267
|
+
room_id: str,
|
|
268
|
+
after: int = 0,
|
|
269
|
+
limit: int = 100,
|
|
270
|
+
tail: bool = False,
|
|
271
|
+
authorization: AuthHeader = None,
|
|
272
|
+
) -> dict:
|
|
273
|
+
caller = auth.reader(authorization)
|
|
274
|
+
_require_room_access(caller, store.require_room(room_id))
|
|
275
|
+
messages = store.fetch_messages(room_id, after_seq=after, limit=min(limit, 500), tail=tail)
|
|
276
|
+
return {"messages": [m.to_dict() for m in messages]}
|
|
277
|
+
|
|
278
|
+
@router.get("/inbox")
|
|
279
|
+
def inbox(
|
|
280
|
+
as_agent: str = Query(...),
|
|
281
|
+
after: int = 0,
|
|
282
|
+
limit: int = 100,
|
|
283
|
+
room_id: str | None = None,
|
|
284
|
+
authorization: AuthHeader = None,
|
|
285
|
+
) -> dict:
|
|
286
|
+
auth.agent(as_agent, authorization)
|
|
287
|
+
messages, cursor = store.fetch_inbox(as_agent, after, min(limit, 500), room_id)
|
|
288
|
+
return {"messages": messages, "cursor": cursor}
|
|
289
|
+
|
|
290
|
+
# -------------------------------------------------------------- tasks
|
|
291
|
+
|
|
292
|
+
@router.post("/tasks")
|
|
293
|
+
def submit_task(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
|
|
294
|
+
creator = payload["as_agent"]
|
|
295
|
+
auth.agent(creator, authorization)
|
|
296
|
+
task = store.submit_task(
|
|
297
|
+
title=payload["title"],
|
|
298
|
+
spec=payload["spec"],
|
|
299
|
+
created_by=creator,
|
|
300
|
+
assignee=payload.get("assignee"),
|
|
301
|
+
selector=payload.get("selector"),
|
|
302
|
+
priority=int(payload.get("priority", 0)),
|
|
303
|
+
timeout_s=float(payload.get("timeout_s", DEFAULT_TASK_TIMEOUT_S)),
|
|
304
|
+
dedupe_key=payload.get("dedupe_key"),
|
|
305
|
+
)
|
|
306
|
+
return {"task": task.to_dict()}
|
|
307
|
+
|
|
308
|
+
@router.get("/tasks")
|
|
309
|
+
def list_tasks(
|
|
310
|
+
state: str | None = None,
|
|
311
|
+
assignee: str | None = None,
|
|
312
|
+
created_by: str | None = None,
|
|
313
|
+
limit: int = 100,
|
|
314
|
+
authorization: AuthHeader = None,
|
|
315
|
+
) -> dict:
|
|
316
|
+
caller = auth.reader(authorization)
|
|
317
|
+
if caller is not None and caller != "admin" and not (assignee or created_by):
|
|
318
|
+
# A plain agent sees its own work, not the whole fleet's task specs.
|
|
319
|
+
own = store.list_tasks(state=TaskState(state) if state else None, assignee=caller)
|
|
320
|
+
mine = store.list_tasks(state=TaskState(state) if state else None, created_by=caller)
|
|
321
|
+
merged = {t.id: t for t in [*own, *mine]}
|
|
322
|
+
return {"tasks": [t.to_dict() for t in merged.values()][:limit]}
|
|
323
|
+
tasks = store.list_tasks(
|
|
324
|
+
state=TaskState(state) if state else None,
|
|
325
|
+
assignee=assignee,
|
|
326
|
+
created_by=created_by,
|
|
327
|
+
limit=limit,
|
|
328
|
+
)
|
|
329
|
+
return {"tasks": [t.to_dict() for t in tasks]}
|
|
330
|
+
|
|
331
|
+
@router.get("/tasks/next")
|
|
332
|
+
def next_task(as_agent: str = Query(...), authorization: AuthHeader = None) -> dict:
|
|
333
|
+
auth.agent(as_agent, authorization)
|
|
334
|
+
task = store.next_task_for(as_agent)
|
|
335
|
+
return {"task": task.to_dict() if task else None}
|
|
336
|
+
|
|
337
|
+
@router.get("/tasks/{task_id}")
|
|
338
|
+
def get_task(task_id: str, authorization: AuthHeader = None) -> dict:
|
|
339
|
+
caller = auth.reader(authorization)
|
|
340
|
+
task = store.require_task(task_id)
|
|
341
|
+
if (
|
|
342
|
+
caller is not None
|
|
343
|
+
and caller != "admin"
|
|
344
|
+
and caller
|
|
345
|
+
not in (
|
|
346
|
+
task.created_by,
|
|
347
|
+
task.assignee,
|
|
348
|
+
)
|
|
349
|
+
):
|
|
350
|
+
raise HubError(ErrorCode.FORBIDDEN, "not your task", 403)
|
|
351
|
+
return {"task": task.to_dict(), "events": [e.to_dict() for e in store.task_events(task_id)]}
|
|
352
|
+
|
|
353
|
+
@router.post("/tasks/{task_id}/claim")
|
|
354
|
+
def claim_task(
|
|
355
|
+
task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
356
|
+
) -> dict:
|
|
357
|
+
who = payload["as_agent"]
|
|
358
|
+
auth.agent(who, authorization)
|
|
359
|
+
return {"task": store.claim_task(task_id, who).to_dict()}
|
|
360
|
+
|
|
361
|
+
@router.post("/tasks/{task_id}/progress")
|
|
362
|
+
def task_progress(
|
|
363
|
+
task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
364
|
+
) -> dict:
|
|
365
|
+
who = payload["as_agent"]
|
|
366
|
+
auth.agent(who, authorization)
|
|
367
|
+
task = store.update_progress(task_id, who, payload.get("pct"), payload.get("message"))
|
|
368
|
+
return {"task": task.to_dict()}
|
|
369
|
+
|
|
370
|
+
@router.post("/tasks/{task_id}/result")
|
|
371
|
+
def task_result(
|
|
372
|
+
task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
373
|
+
) -> dict:
|
|
374
|
+
who = payload["as_agent"]
|
|
375
|
+
auth.agent(who, authorization)
|
|
376
|
+
return {"task": store.complete_task(task_id, who, payload.get("result")).to_dict()}
|
|
377
|
+
|
|
378
|
+
@router.post("/tasks/{task_id}/fail")
|
|
379
|
+
def task_fail(
|
|
380
|
+
task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
381
|
+
) -> dict:
|
|
382
|
+
who = payload["as_agent"]
|
|
383
|
+
auth.agent(who, authorization)
|
|
384
|
+
return {
|
|
385
|
+
"task": store.fail_task(
|
|
386
|
+
task_id, who, payload.get("error", "unknown"), result=payload.get("result")
|
|
387
|
+
).to_dict()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
@router.post("/tasks/{task_id}/input_required")
|
|
391
|
+
def task_input_required(
|
|
392
|
+
task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
393
|
+
) -> dict:
|
|
394
|
+
who = payload["as_agent"]
|
|
395
|
+
auth.agent(who, authorization)
|
|
396
|
+
return {"task": store.request_input(task_id, who, payload["question"]).to_dict()}
|
|
397
|
+
|
|
398
|
+
@router.post("/tasks/{task_id}/input")
|
|
399
|
+
def task_input(
|
|
400
|
+
task_id: str, payload: dict = Body(...), authorization: AuthHeader = None
|
|
401
|
+
) -> dict:
|
|
402
|
+
who = payload["as_agent"]
|
|
403
|
+
auth.agent(who, authorization)
|
|
404
|
+
return {"task": store.provide_input(task_id, who, payload["message"]).to_dict()}
|
|
405
|
+
|
|
406
|
+
@router.post("/tasks/{task_id}/cancel")
|
|
407
|
+
def task_cancel(
|
|
408
|
+
task_id: str, payload: dict = Body(default={}), authorization: AuthHeader = None
|
|
409
|
+
) -> dict:
|
|
410
|
+
who = payload.get("as_agent")
|
|
411
|
+
if who:
|
|
412
|
+
auth.agent(who, authorization)
|
|
413
|
+
else:
|
|
414
|
+
auth.admin(authorization)
|
|
415
|
+
privileged = who is None or auth.is_privileged(authorization)
|
|
416
|
+
cancelled = store.cancel_task(task_id, who or "admin", privileged=privileged)
|
|
417
|
+
return {"task": cancelled.to_dict()}
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------- artifacts
|
|
420
|
+
|
|
421
|
+
@router.post("/artifacts")
|
|
422
|
+
def upload_artifact(payload: dict = Body(...), authorization: AuthHeader = None) -> dict:
|
|
423
|
+
who = payload["as_agent"]
|
|
424
|
+
auth.agent(who, authorization)
|
|
425
|
+
import base64
|
|
426
|
+
|
|
427
|
+
content = base64.b64decode(payload["content_b64"])
|
|
428
|
+
meta = store.save_artifact(
|
|
429
|
+
name=payload.get("name", "artifact"),
|
|
430
|
+
mime=payload.get("mime", "application/octet-stream"),
|
|
431
|
+
content=content,
|
|
432
|
+
created_by=who,
|
|
433
|
+
)
|
|
434
|
+
return {"artifact": meta}
|
|
435
|
+
|
|
436
|
+
@router.get("/artifacts/{artifact_id}")
|
|
437
|
+
def download_artifact(artifact_id: str, authorization: AuthHeader = None) -> dict:
|
|
438
|
+
auth.reader(authorization)
|
|
439
|
+
import base64
|
|
440
|
+
|
|
441
|
+
meta, content = store.get_artifact(artifact_id)
|
|
442
|
+
return {"artifact": meta, "content_b64": base64.b64encode(content).decode()}
|
|
443
|
+
|
|
444
|
+
# -------------------------------------------------------------- watch
|
|
445
|
+
|
|
446
|
+
@router.get("/watch")
|
|
447
|
+
async def watch(
|
|
448
|
+
request: Request, identity: str = Query(...), authorization: AuthHeader = None
|
|
449
|
+
) -> StreamingResponse:
|
|
450
|
+
auth.admin(authorization)
|
|
451
|
+
store.ensure_identity(identity)
|
|
452
|
+
return StreamingResponse(
|
|
453
|
+
_event_stream(store, identity, request),
|
|
454
|
+
media_type="text/event-stream",
|
|
455
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return router
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def _event_stream(store: Store, agent: str, request: Request) -> AsyncIterator[str]:
|
|
462
|
+
"""SSE stream. The open connection doubles as the agent's heartbeat."""
|
|
463
|
+
async with store.bus.subscribe(agent) as queue:
|
|
464
|
+
store.heartbeat(agent)
|
|
465
|
+
yield _sse({"kind": "connected", "agent": agent})
|
|
466
|
+
while True:
|
|
467
|
+
if await request.is_disconnected():
|
|
468
|
+
break
|
|
469
|
+
try:
|
|
470
|
+
event = await asyncio.wait_for(queue.get(), timeout=SSE_KEEPALIVE_S)
|
|
471
|
+
except TimeoutError:
|
|
472
|
+
store.heartbeat(agent)
|
|
473
|
+
yield ": keepalive\n\n"
|
|
474
|
+
continue
|
|
475
|
+
store.heartbeat(agent)
|
|
476
|
+
yield _sse(event)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _sse(payload: dict) -> str:
|
|
480
|
+
return f"data: {json.dumps(payload)}\n\n"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def create_app(store: Store, config: HubConfig, mcp_app: Any | None = None) -> FastAPI:
|
|
484
|
+
@contextlib.asynccontextmanager
|
|
485
|
+
async def lifespan(app: FastAPI):
|
|
486
|
+
store.bus.bind(asyncio.get_running_loop())
|
|
487
|
+
sweeper = asyncio.create_task(_sweep_loop(store, config.sweep_interval_s))
|
|
488
|
+
try:
|
|
489
|
+
if mcp_app is not None and hasattr(mcp_app, "lifespan"):
|
|
490
|
+
async with mcp_app.lifespan(app):
|
|
491
|
+
yield
|
|
492
|
+
else:
|
|
493
|
+
yield
|
|
494
|
+
finally:
|
|
495
|
+
sweeper.cancel()
|
|
496
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
497
|
+
await sweeper
|
|
498
|
+
|
|
499
|
+
app = FastAPI(title="yeschef hub", version="0.1.0", lifespan=lifespan)
|
|
500
|
+
|
|
501
|
+
@app.exception_handler(HubError)
|
|
502
|
+
async def hub_error_handler(request: Request, exc: HubError) -> JSONResponse:
|
|
503
|
+
return JSONResponse(status_code=exc.http_status, content=exc.to_dict())
|
|
504
|
+
|
|
505
|
+
@app.get("/healthz")
|
|
506
|
+
def healthz() -> dict:
|
|
507
|
+
agents = store.list_agents()
|
|
508
|
+
return {
|
|
509
|
+
"ok": True,
|
|
510
|
+
"agents": len(agents),
|
|
511
|
+
"online": sum(1 for a in agents if str(a.status()) == "online"),
|
|
512
|
+
"queued_tasks": len(store.list_tasks(state=TaskState.QUEUED)),
|
|
513
|
+
"lifetime": store.lifetime_stats(),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
@app.get("/overview")
|
|
517
|
+
def overview() -> dict:
|
|
518
|
+
"""Read-only fleet snapshot for the dashboard — curated to omit task specs and
|
|
519
|
+
result bodies, so it is safe to serve un-authenticated within the same
|
|
520
|
+
LAN/Tailscale boundary as /healthz (never expose the hub through a funnel)."""
|
|
521
|
+
ref = now()
|
|
522
|
+
agents = store.list_agents()
|
|
523
|
+
active: dict[str, int] = {}
|
|
524
|
+
for t in store.list_tasks(limit=500):
|
|
525
|
+
if t.state in (TaskState.CLAIMED, TaskState.WORKING, TaskState.INPUT_REQUIRED):
|
|
526
|
+
if t.assignee:
|
|
527
|
+
active[t.assignee] = active.get(t.assignee, 0) + 1
|
|
528
|
+
workers = []
|
|
529
|
+
for a in agents:
|
|
530
|
+
if a.kind is not AgentKind.WORKER:
|
|
531
|
+
continue
|
|
532
|
+
d = a.to_dict(ref)
|
|
533
|
+
d["active_tasks"] = active.get(a.name, 0)
|
|
534
|
+
d["heartbeat_age_s"] = round(ref - a.last_seen, 1)
|
|
535
|
+
workers.append(d)
|
|
536
|
+
workers.sort(key=lambda w: (w["status"] != "online", w["name"]))
|
|
537
|
+
|
|
538
|
+
def task_row(t) -> dict:
|
|
539
|
+
return {
|
|
540
|
+
"id": t.id,
|
|
541
|
+
"title": t.title,
|
|
542
|
+
"state": str(t.state),
|
|
543
|
+
"assignee": t.assignee,
|
|
544
|
+
"selector": t.selector,
|
|
545
|
+
"progress": {"pct": t.progress_pct, "message": t.progress_msg},
|
|
546
|
+
"attempts": t.attempts,
|
|
547
|
+
"error": t.error,
|
|
548
|
+
"age_s": round(ref - t.created_at, 1) if t.created_at else None,
|
|
549
|
+
"ran_s": (
|
|
550
|
+
round(t.finished_at - t.claimed_at, 1)
|
|
551
|
+
if t.finished_at and t.claimed_at
|
|
552
|
+
else None
|
|
553
|
+
),
|
|
554
|
+
"files": len((t.result or {}).get("files") or []),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
tasks = [task_row(t) for t in store.list_tasks(limit=60)]
|
|
558
|
+
rooms = [
|
|
559
|
+
{
|
|
560
|
+
"id": r.id,
|
|
561
|
+
"topic": r.topic,
|
|
562
|
+
"members": [m for m in r.members if not m.startswith(("claude:", "operator:"))],
|
|
563
|
+
"is_dm": r.dm_key is not None,
|
|
564
|
+
"archived": r.archived,
|
|
565
|
+
"archived_reason": r.archived_reason,
|
|
566
|
+
}
|
|
567
|
+
for r in store.list_rooms(include_archived=True)[:20]
|
|
568
|
+
]
|
|
569
|
+
return {
|
|
570
|
+
"now": ref,
|
|
571
|
+
"workers": workers,
|
|
572
|
+
"sessions": [a.to_dict(ref) for a in agents if a.kind is AgentKind.CLAUDE],
|
|
573
|
+
"tasks": tasks,
|
|
574
|
+
"rooms": rooms,
|
|
575
|
+
"lifetime": store.lifetime_stats(),
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
@app.get("/", response_class=HTMLResponse)
|
|
579
|
+
def dashboard() -> str:
|
|
580
|
+
return (Path(__file__).with_name("dashboard.html")).read_text()
|
|
581
|
+
|
|
582
|
+
app.include_router(build_router(store, config))
|
|
583
|
+
if mcp_app is not None:
|
|
584
|
+
app.mount("/mcp", mcp_app)
|
|
585
|
+
return app
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
async def _sweep_loop(store: Store, interval: float) -> None:
|
|
589
|
+
while True:
|
|
590
|
+
await asyncio.sleep(interval)
|
|
591
|
+
with contextlib.suppress(Exception):
|
|
592
|
+
await asyncio.to_thread(store.sweep)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
__all__ = ["HubConfig", "create_app", "build_router", "MAX_LONG_POLL_S"]
|
yeschef/hub/app.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Assemble the hub: one ASGI app serving /mcp for Claude Code and /api/v1 for agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
from .api import HubConfig, create_app
|
|
10
|
+
from .events import EventBus
|
|
11
|
+
from .store import Store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_from_env() -> HubConfig:
|
|
15
|
+
from ..settings import env, home
|
|
16
|
+
|
|
17
|
+
return HubConfig(
|
|
18
|
+
db_path=env("DB", str(home() / "hub.db")),
|
|
19
|
+
admin_token=env("ADMIN_TOKEN"),
|
|
20
|
+
register_token=env("REGISTER_TOKEN"),
|
|
21
|
+
default_identity=env("IDENTITY", "claude:local"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_hub(config: HubConfig | None = None) -> tuple[FastAPI, Store]:
|
|
26
|
+
config = config or config_from_env()
|
|
27
|
+
db_path = (
|
|
28
|
+
config.db_path if config.db_path == ":memory:" else str(Path(config.db_path).expanduser())
|
|
29
|
+
)
|
|
30
|
+
store = Store(db_path, EventBus())
|
|
31
|
+
|
|
32
|
+
from .mcp_server import build_mcp
|
|
33
|
+
|
|
34
|
+
mcp = build_mcp(store, config)
|
|
35
|
+
mcp_app = mcp.http_app(path="/")
|
|
36
|
+
app = create_app(store, config, mcp_app=mcp_app)
|
|
37
|
+
return app, store
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def app_factory() -> FastAPI:
|
|
41
|
+
"""Entry point for `uvicorn yeschef.hub.app:app_factory --factory`."""
|
|
42
|
+
app, _ = build_hub()
|
|
43
|
+
return app
|