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/models.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Shared types for hub, SDK, and harness.
|
|
2
|
+
|
|
3
|
+
Task states deliberately mirror the MCP Tasks extension vocabulary (SEP-2663) so the
|
|
4
|
+
tool surface can be upgraded to spec-native tasks without renaming anything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now() -> float:
|
|
18
|
+
"""Hub-assigned UTC epoch seconds. Agents never write times."""
|
|
19
|
+
return time.time()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _iso(ts: float | None) -> str | None:
|
|
23
|
+
if not ts:
|
|
24
|
+
return None
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
|
|
27
|
+
return _dt.datetime.fromtimestamp(ts, _dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_id(prefix: str, length: int = 10) -> str:
|
|
31
|
+
body = "".join(secrets.choice(_ALPHABET) for _ in range(length))
|
|
32
|
+
return f"{prefix}_{body}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TaskState(StrEnum):
|
|
36
|
+
QUEUED = "queued"
|
|
37
|
+
CLAIMED = "claimed"
|
|
38
|
+
WORKING = "working"
|
|
39
|
+
INPUT_REQUIRED = "input_required"
|
|
40
|
+
COMPLETED = "completed"
|
|
41
|
+
FAILED = "failed"
|
|
42
|
+
CANCELLED = "cancelled"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def terminal(self) -> bool:
|
|
46
|
+
return self in (TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def active(self) -> bool:
|
|
50
|
+
"""Held by an agent — subject to heartbeat reclamation."""
|
|
51
|
+
return self in (TaskState.CLAIMED, TaskState.WORKING, TaskState.INPUT_REQUIRED)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AgentKind(StrEnum):
|
|
55
|
+
WORKER = "worker"
|
|
56
|
+
CLAUDE = "claude"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentStatus(StrEnum):
|
|
60
|
+
ONLINE = "online"
|
|
61
|
+
BUSY = "busy"
|
|
62
|
+
OFFLINE = "offline"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TurnPolicy(StrEnum):
|
|
66
|
+
FREE = "free"
|
|
67
|
+
ROUND_ROBIN = "round_robin"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ReplyWhen(StrEnum):
|
|
71
|
+
MENTIONED = "mentioned"
|
|
72
|
+
ROUND_ROBIN = "round_robin"
|
|
73
|
+
ALWAYS = "always"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EventKind(StrEnum):
|
|
77
|
+
MESSAGE = "message"
|
|
78
|
+
TASK_ASSIGNED = "task_assigned"
|
|
79
|
+
TASK_CANCELLED = "task_cancelled"
|
|
80
|
+
TASK_UPDATED = "task_updated"
|
|
81
|
+
ROOM_INVITE = "room_invite"
|
|
82
|
+
FLOOR_GRANTED = "floor_granted"
|
|
83
|
+
ROOM_ARCHIVED = "room_archived"
|
|
84
|
+
SHUTDOWN = "shutdown"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ErrorCode(StrEnum):
|
|
88
|
+
NOT_FOUND = "not_found"
|
|
89
|
+
CONFLICT = "conflict"
|
|
90
|
+
FORBIDDEN = "forbidden"
|
|
91
|
+
UNAUTHORIZED = "unauthorized"
|
|
92
|
+
INVALID = "invalid"
|
|
93
|
+
POLICY_EXCEEDED = "policy_exceeded"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class HubError(Exception):
|
|
97
|
+
"""Error that crosses the wire as {"error": {"code", "message"}}."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, code: ErrorCode, message: str, http_status: int = 400) -> None:
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
self.code = code
|
|
102
|
+
self.message = message
|
|
103
|
+
self.http_status = http_status
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict:
|
|
106
|
+
return {"error": {"code": str(self.code), "message": self.message}}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def not_found(what: str) -> HubError:
|
|
110
|
+
return HubError(ErrorCode.NOT_FOUND, f"{what} not found", 404)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
HEARTBEAT_TTL_S = 30.0
|
|
114
|
+
"""Agent considered offline after this long without a heartbeat or open event stream."""
|
|
115
|
+
|
|
116
|
+
MAX_LONG_POLL_S = 60.0
|
|
117
|
+
"""Cap on any explicit wait, staying clear of Claude Code's ~2 min auto-background."""
|
|
118
|
+
|
|
119
|
+
MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
|
|
120
|
+
|
|
121
|
+
DEFAULT_TASK_TIMEOUT_S = 3600.0
|
|
122
|
+
|
|
123
|
+
INPUT_REQUIRED_TTL_S = 30 * 60.0
|
|
124
|
+
"""A parked question fails the task after this long — stranded input_required tasks
|
|
125
|
+
sat for 41 minutes with nobody notified before this existed."""
|
|
126
|
+
|
|
127
|
+
DEFAULT_MAX_ROOM_MESSAGES = 200
|
|
128
|
+
"""Backstop applied to any room created without a message cap.
|
|
129
|
+
|
|
130
|
+
The point is not the number — it is that no room is ever unbounded. Two agents set to
|
|
131
|
+
reply on every message will otherwise talk to each other until the hardware gives out.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
DEFAULT_ROOM_IDLE_TIMEOUT_S = 24 * 3600.0
|
|
135
|
+
"""Backstop applied to any room created without an idle timeout."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(slots=True)
|
|
139
|
+
class ToolCall:
|
|
140
|
+
"""A model's request to run a worker-side tool.
|
|
141
|
+
|
|
142
|
+
Lives here rather than in `agent.backends` so `tools` and `agent` can both use it
|
|
143
|
+
without importing each other.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
id: str
|
|
147
|
+
name: str
|
|
148
|
+
arguments: dict
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(slots=True)
|
|
152
|
+
class ToolResult:
|
|
153
|
+
call_id: str
|
|
154
|
+
name: str
|
|
155
|
+
content: str
|
|
156
|
+
is_error: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(slots=True)
|
|
160
|
+
class RoomPolicy:
|
|
161
|
+
"""Hub-enforced guards. Every autonomous room is bounded by construction."""
|
|
162
|
+
|
|
163
|
+
turn_policy: TurnPolicy = TurnPolicy.FREE
|
|
164
|
+
max_messages: int | None = None
|
|
165
|
+
max_total_tokens: int | None = None
|
|
166
|
+
idle_timeout_s: float | None = None
|
|
167
|
+
stop_phrase: str | None = None
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict:
|
|
170
|
+
return {
|
|
171
|
+
"turn_policy": str(self.turn_policy),
|
|
172
|
+
"max_messages": self.max_messages,
|
|
173
|
+
"max_total_tokens": self.max_total_tokens,
|
|
174
|
+
"idle_timeout_s": self.idle_timeout_s,
|
|
175
|
+
"stop_phrase": self.stop_phrase,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_dict(cls, raw: dict | None) -> RoomPolicy:
|
|
180
|
+
raw = raw or {}
|
|
181
|
+
return cls(
|
|
182
|
+
turn_policy=TurnPolicy(raw.get("turn_policy") or TurnPolicy.FREE),
|
|
183
|
+
max_messages=raw.get("max_messages"),
|
|
184
|
+
max_total_tokens=raw.get("max_total_tokens"),
|
|
185
|
+
idle_timeout_s=raw.get("idle_timeout_s"),
|
|
186
|
+
stop_phrase=raw.get("stop_phrase"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def bounded(
|
|
190
|
+
self,
|
|
191
|
+
max_messages: int = DEFAULT_MAX_ROOM_MESSAGES,
|
|
192
|
+
idle_timeout_s: float = DEFAULT_ROOM_IDLE_TIMEOUT_S,
|
|
193
|
+
) -> RoomPolicy:
|
|
194
|
+
"""Return this policy with backstops filled in where the caller left gaps.
|
|
195
|
+
|
|
196
|
+
Every room goes through here, so an unbounded conversation cannot be created
|
|
197
|
+
by omission — only a caller who names a larger explicit limit gets one.
|
|
198
|
+
"""
|
|
199
|
+
return RoomPolicy(
|
|
200
|
+
turn_policy=self.turn_policy,
|
|
201
|
+
max_messages=self.max_messages if self.max_messages is not None else max_messages,
|
|
202
|
+
max_total_tokens=self.max_total_tokens,
|
|
203
|
+
idle_timeout_s=(
|
|
204
|
+
self.idle_timeout_s if self.idle_timeout_s is not None else idle_timeout_s
|
|
205
|
+
),
|
|
206
|
+
stop_phrase=self.stop_phrase,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(slots=True)
|
|
211
|
+
class Agent:
|
|
212
|
+
name: str
|
|
213
|
+
kind: AgentKind
|
|
214
|
+
node: str | None = None
|
|
215
|
+
backend: str | None = None
|
|
216
|
+
tags: list[str] = field(default_factory=list)
|
|
217
|
+
last_seen: float = 0.0
|
|
218
|
+
created_at: float = 0.0
|
|
219
|
+
|
|
220
|
+
def status(self, ref: float | None = None) -> AgentStatus:
|
|
221
|
+
ref = ref if ref is not None else now()
|
|
222
|
+
if ref - self.last_seen > HEARTBEAT_TTL_S:
|
|
223
|
+
return AgentStatus.OFFLINE
|
|
224
|
+
return AgentStatus.ONLINE
|
|
225
|
+
|
|
226
|
+
def matches(self, selector: str) -> bool:
|
|
227
|
+
"""`name` match, or `tag:value` / bare tag selector.
|
|
228
|
+
|
|
229
|
+
`a|b` is a union: any part matching means the agent matches, so tag-faithful
|
|
230
|
+
dispatch can still spill onto idle capacity (`tier:fast|tier:build`).
|
|
231
|
+
"""
|
|
232
|
+
return any(
|
|
233
|
+
part == "*" or part == self.name or part in self.tags
|
|
234
|
+
for part in (p.strip() for p in selector.split("|"))
|
|
235
|
+
if part
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def to_dict(self, ref: float | None = None) -> dict:
|
|
239
|
+
return {
|
|
240
|
+
"name": self.name,
|
|
241
|
+
"kind": str(self.kind),
|
|
242
|
+
"node": self.node,
|
|
243
|
+
"backend": self.backend,
|
|
244
|
+
"tags": list(self.tags),
|
|
245
|
+
"status": str(self.status(ref)),
|
|
246
|
+
"heartbeat_age_s": round((ref if ref is not None else now()) - self.last_seen, 1),
|
|
247
|
+
"last_seen": self.last_seen,
|
|
248
|
+
"created_at": self.created_at,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(slots=True)
|
|
253
|
+
class Room:
|
|
254
|
+
id: str
|
|
255
|
+
topic: str
|
|
256
|
+
created_by: str
|
|
257
|
+
open: bool = False
|
|
258
|
+
policy: RoomPolicy = field(default_factory=RoomPolicy)
|
|
259
|
+
archived: bool = False
|
|
260
|
+
archived_reason: str | None = None
|
|
261
|
+
dm_key: str | None = None
|
|
262
|
+
floor_holder: str | None = None
|
|
263
|
+
created_at: float = 0.0
|
|
264
|
+
members: list[str] = field(default_factory=list)
|
|
265
|
+
|
|
266
|
+
def to_dict(self) -> dict:
|
|
267
|
+
return {
|
|
268
|
+
"id": self.id,
|
|
269
|
+
"topic": self.topic,
|
|
270
|
+
"created_by": self.created_by,
|
|
271
|
+
"open": self.open,
|
|
272
|
+
"is_dm": self.dm_key is not None,
|
|
273
|
+
"floor_holder": self.floor_holder,
|
|
274
|
+
"policy": self.policy.to_dict(),
|
|
275
|
+
"archived": self.archived,
|
|
276
|
+
"archived_reason": self.archived_reason,
|
|
277
|
+
"members": list(self.members),
|
|
278
|
+
"created_at": self.created_at,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(slots=True)
|
|
283
|
+
class Message:
|
|
284
|
+
id: str
|
|
285
|
+
room_id: str
|
|
286
|
+
seq: int
|
|
287
|
+
sender: str
|
|
288
|
+
body: str
|
|
289
|
+
data: dict | None = None
|
|
290
|
+
reply_to: str | None = None
|
|
291
|
+
mentions: list[str] = field(default_factory=list)
|
|
292
|
+
created_at: float = 0.0
|
|
293
|
+
|
|
294
|
+
def to_dict(self) -> dict:
|
|
295
|
+
return {
|
|
296
|
+
"id": self.id,
|
|
297
|
+
"room_id": self.room_id,
|
|
298
|
+
"seq": self.seq,
|
|
299
|
+
"sender": self.sender,
|
|
300
|
+
"body": self.body,
|
|
301
|
+
"data": self.data,
|
|
302
|
+
"reply_to": self.reply_to,
|
|
303
|
+
"mentions": list(self.mentions),
|
|
304
|
+
"created_at": self.created_at,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(slots=True)
|
|
309
|
+
class Task:
|
|
310
|
+
id: str
|
|
311
|
+
title: str
|
|
312
|
+
spec: str
|
|
313
|
+
created_by: str
|
|
314
|
+
state: TaskState = TaskState.QUEUED
|
|
315
|
+
assignee: str | None = None
|
|
316
|
+
selector: str | None = None
|
|
317
|
+
priority: int = 0
|
|
318
|
+
timeout_s: float = DEFAULT_TASK_TIMEOUT_S
|
|
319
|
+
dedupe_key: str | None = None
|
|
320
|
+
project: str | None = None
|
|
321
|
+
output_mode: str | None = None
|
|
322
|
+
data: str | None = None
|
|
323
|
+
input_required_at: float | None = None
|
|
324
|
+
room_id: str | None = None
|
|
325
|
+
progress_pct: float | None = None
|
|
326
|
+
progress_msg: str | None = None
|
|
327
|
+
result: dict | None = None
|
|
328
|
+
error: str | None = None
|
|
329
|
+
attempts: int = 0
|
|
330
|
+
created_at: float = 0.0
|
|
331
|
+
claimed_at: float | None = None
|
|
332
|
+
finished_at: float | None = None
|
|
333
|
+
|
|
334
|
+
def to_dict(self) -> dict:
|
|
335
|
+
return {
|
|
336
|
+
"id": self.id,
|
|
337
|
+
"title": self.title,
|
|
338
|
+
"spec": self.spec,
|
|
339
|
+
"created_by": self.created_by,
|
|
340
|
+
"state": str(self.state),
|
|
341
|
+
"assignee": self.assignee,
|
|
342
|
+
"selector": self.selector,
|
|
343
|
+
"project": self.project,
|
|
344
|
+
"output_mode": self.output_mode,
|
|
345
|
+
"data": self.data,
|
|
346
|
+
"priority": self.priority,
|
|
347
|
+
"timeout_s": self.timeout_s,
|
|
348
|
+
"room_id": self.room_id,
|
|
349
|
+
"progress": {"pct": self.progress_pct, "message": self.progress_msg},
|
|
350
|
+
"result": self.result,
|
|
351
|
+
"error": self.error,
|
|
352
|
+
"attempts": self.attempts,
|
|
353
|
+
"created_at": self.created_at,
|
|
354
|
+
"claimed_at": self.claimed_at,
|
|
355
|
+
"finished_at": self.finished_at,
|
|
356
|
+
"created_iso": _iso(self.created_at),
|
|
357
|
+
"claimed_iso": _iso(self.claimed_at),
|
|
358
|
+
"finished_iso": _iso(self.finished_at),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def to_summary(self) -> dict:
|
|
362
|
+
"""Slim view for acks, waits, and listings — no spec or result bodies.
|
|
363
|
+
|
|
364
|
+
The spec is text the caller wrote and the result has its own tool; echoing
|
|
365
|
+
either in every reply re-bills an orchestrating model for its own words on
|
|
366
|
+
each poll of a long task.
|
|
367
|
+
"""
|
|
368
|
+
return {
|
|
369
|
+
"id": self.id,
|
|
370
|
+
"title": self.title,
|
|
371
|
+
"state": str(self.state),
|
|
372
|
+
"assignee": self.assignee,
|
|
373
|
+
"created_by": self.created_by,
|
|
374
|
+
"project": self.project,
|
|
375
|
+
"priority": self.priority or None,
|
|
376
|
+
"room_id": self.room_id,
|
|
377
|
+
"progress": {"pct": self.progress_pct, "message": self.progress_msg},
|
|
378
|
+
"error": self.error,
|
|
379
|
+
"attempts": self.attempts,
|
|
380
|
+
"created_at": self.created_at,
|
|
381
|
+
"finished_at": self.finished_at,
|
|
382
|
+
"input_expires_at": (
|
|
383
|
+
round(
|
|
384
|
+
(self.input_required_at or self.claimed_at or self.created_at)
|
|
385
|
+
+ INPUT_REQUIRED_TTL_S,
|
|
386
|
+
1,
|
|
387
|
+
)
|
|
388
|
+
if str(self.state) == "input_required"
|
|
389
|
+
else None
|
|
390
|
+
),
|
|
391
|
+
"flags": [
|
|
392
|
+
k
|
|
393
|
+
for k in (
|
|
394
|
+
"truncated",
|
|
395
|
+
"no_files",
|
|
396
|
+
"all_tools_failed",
|
|
397
|
+
"code_in_text_only",
|
|
398
|
+
"unverified_claims",
|
|
399
|
+
"tool_text_unparsed",
|
|
400
|
+
"echoes_spec",
|
|
401
|
+
"partial",
|
|
402
|
+
)
|
|
403
|
+
if (self.result or {}).get(k)
|
|
404
|
+
]
|
|
405
|
+
or None,
|
|
406
|
+
"age_s": round(now() - self.created_at, 1) if self.created_at else None,
|
|
407
|
+
"ran_s": (
|
|
408
|
+
round(self.finished_at - self.claimed_at, 1)
|
|
409
|
+
if self.finished_at and self.claimed_at
|
|
410
|
+
else None
|
|
411
|
+
),
|
|
412
|
+
"files": len((self.result or {}).get("files") or []),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass(slots=True)
|
|
417
|
+
class TaskEvent:
|
|
418
|
+
id: str
|
|
419
|
+
task_id: str
|
|
420
|
+
kind: str
|
|
421
|
+
payload: dict
|
|
422
|
+
created_at: float
|
|
423
|
+
|
|
424
|
+
def to_dict(self) -> dict:
|
|
425
|
+
return {
|
|
426
|
+
"id": self.id,
|
|
427
|
+
"task_id": self.task_id,
|
|
428
|
+
"kind": self.kind,
|
|
429
|
+
"payload": self.payload,
|
|
430
|
+
"created_at": self.created_at,
|
|
431
|
+
}
|
yeschef/procs.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Track detached hub/agent processes so one terminal can start and stop the fleet.
|
|
2
|
+
|
|
3
|
+
Best-effort process supervision: enough for `up --detach` / `join --detach` / `down`
|
|
4
|
+
on a workstation, not a replacement for the launchd and systemd units under
|
|
5
|
+
examples/deploy/ for a real deployment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .settings import home
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_dir() -> Path:
|
|
22
|
+
path = home() / "run"
|
|
23
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class Proc:
|
|
29
|
+
label: str
|
|
30
|
+
pid: int
|
|
31
|
+
kind: str # "hub" | "agent"
|
|
32
|
+
log: str
|
|
33
|
+
command: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _record_path(label: str) -> Path:
|
|
37
|
+
safe = label.replace("/", "_").replace(":", "_")
|
|
38
|
+
return _run_dir() / f"{safe}.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def spawn(label: str, kind: str, command: list[str]) -> Proc:
|
|
42
|
+
"""Start a detached child that outlives this process, logging to a file."""
|
|
43
|
+
existing = get(label)
|
|
44
|
+
if existing and is_alive(existing.pid):
|
|
45
|
+
raise RuntimeError(f"'{label}' is already running (pid {existing.pid})")
|
|
46
|
+
|
|
47
|
+
log_path = _run_dir() / f"{label.replace('/', '_').replace(':', '_')}.log"
|
|
48
|
+
log_file = open(log_path, "ab") # noqa: SIM115 - handed to the child, closed on exit
|
|
49
|
+
kwargs: dict = {"stdout": log_file, "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL}
|
|
50
|
+
if os.name == "nt":
|
|
51
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
52
|
+
else:
|
|
53
|
+
kwargs["start_new_session"] = True
|
|
54
|
+
|
|
55
|
+
process = subprocess.Popen(command, **kwargs) # noqa: S603 - command is built by us
|
|
56
|
+
log_file.close()
|
|
57
|
+
proc = Proc(label=label, pid=process.pid, kind=kind, log=str(log_path), command=command)
|
|
58
|
+
_record_path(label).write_text(json.dumps(asdict(proc), indent=2))
|
|
59
|
+
return proc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_alive(pid: int) -> bool:
|
|
63
|
+
if os.name == "nt":
|
|
64
|
+
out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True)
|
|
65
|
+
return str(pid) in out.stdout
|
|
66
|
+
try:
|
|
67
|
+
os.kill(pid, 0)
|
|
68
|
+
except ProcessLookupError:
|
|
69
|
+
return False
|
|
70
|
+
except PermissionError:
|
|
71
|
+
return True
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get(label: str) -> Proc | None:
|
|
76
|
+
path = _record_path(label)
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return None
|
|
79
|
+
raw = json.loads(path.read_text())
|
|
80
|
+
return Proc(**raw)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def list_all() -> list[Proc]:
|
|
84
|
+
return [Proc(**json.loads(p.read_text())) for p in sorted(_run_dir().glob("*.json"))]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def stop(label: str) -> bool:
|
|
88
|
+
proc = get(label)
|
|
89
|
+
if proc is None:
|
|
90
|
+
return False
|
|
91
|
+
if is_alive(proc.pid):
|
|
92
|
+
try:
|
|
93
|
+
if os.name == "nt":
|
|
94
|
+
os.kill(proc.pid, signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
|
|
95
|
+
else:
|
|
96
|
+
os.kill(proc.pid, signal.SIGTERM)
|
|
97
|
+
except (ProcessLookupError, PermissionError):
|
|
98
|
+
pass
|
|
99
|
+
_record_path(proc.label).unlink(missing_ok=True)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def stop_all() -> list[str]:
|
|
104
|
+
return [proc.label for proc in list_all() if stop(proc.label)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cli_executable() -> list[str]:
|
|
108
|
+
"""How to re-invoke this CLI for a detached child."""
|
|
109
|
+
return [sys.executable, "-m", "yeschef"]
|