relay-codex 0.0.0-stage → 0.5.0
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.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +176 -0
- package/docs/publishing.md +72 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +47 -4
- package/python/Menu.swift +188 -0
- package/python/broker.py +297 -0
- package/python/commands.py +900 -0
- package/python/compatibility.py +227 -0
- package/python/desktop.py +171 -0
- package/python/execution.py +551 -0
- package/python/launch.py +417 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +586 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2288 -0
- package/python/runtime.py +97 -0
- package/python/service.py +737 -0
- package/python/settings.py +110 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +240 -0
- package/tests/test_audit.py +320 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +517 -0
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +479 -0
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
"""Local management commands. Status and diagnostics never return credentials."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from pool import Pool, file_lock
|
|
13
|
+
import settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def account_identity(state, label):
|
|
17
|
+
from router import account_home
|
|
18
|
+
|
|
19
|
+
home = account_home(state, label)
|
|
20
|
+
for name in ("identity.json", "enrolled"):
|
|
21
|
+
path = home / name
|
|
22
|
+
try:
|
|
23
|
+
value = json.loads(path.read_text())
|
|
24
|
+
if value.get("email"):
|
|
25
|
+
return {"email": value["email"]}
|
|
26
|
+
except (OSError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def identify_accounts(state):
|
|
32
|
+
from router import labels, account_home, read_keychain, RouterError
|
|
33
|
+
|
|
34
|
+
updated = 0
|
|
35
|
+
for label in labels(state):
|
|
36
|
+
if account_identity(state, label).get("email"):
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
identity = read_keychain(account_home(state, label), include_identity=True)
|
|
40
|
+
except RouterError:
|
|
41
|
+
continue
|
|
42
|
+
if identity.get("email"):
|
|
43
|
+
settings.atomic_json(
|
|
44
|
+
account_home(state, label) / "identity.json",
|
|
45
|
+
{"email": identity["email"]},
|
|
46
|
+
)
|
|
47
|
+
updated += 1
|
|
48
|
+
return updated
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_label(state, value):
|
|
52
|
+
from router import labels, RouterError
|
|
53
|
+
|
|
54
|
+
if value in labels(state):
|
|
55
|
+
return value
|
|
56
|
+
pool = Pool(state)
|
|
57
|
+
try:
|
|
58
|
+
matching = [
|
|
59
|
+
label
|
|
60
|
+
for label in labels(state)
|
|
61
|
+
if pool.options(label).get("name") == value
|
|
62
|
+
or account_identity(state, label).get("email") == value
|
|
63
|
+
]
|
|
64
|
+
if len(matching) == 1:
|
|
65
|
+
return matching[0]
|
|
66
|
+
finally:
|
|
67
|
+
pool.close()
|
|
68
|
+
raise RouterError("Unknown or ambiguous account name")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def snapshot(state, include_inactive=False):
|
|
72
|
+
from router import labels, VERSION
|
|
73
|
+
|
|
74
|
+
pool = Pool(state)
|
|
75
|
+
try:
|
|
76
|
+
sessions = pool.detail()
|
|
77
|
+
accounts = []
|
|
78
|
+
with pool.db() as db:
|
|
79
|
+
blocks = [
|
|
80
|
+
dict(account=r[0], scope=r[1], next_check=r[2], reason=r[3])
|
|
81
|
+
for r in db.execute(
|
|
82
|
+
"SELECT label,scope,until,reason FROM blocks WHERE until>? ORDER BY until",
|
|
83
|
+
(time.time(),),
|
|
84
|
+
)
|
|
85
|
+
]
|
|
86
|
+
for label in labels(state):
|
|
87
|
+
options = pool.options(label)
|
|
88
|
+
identity = account_identity(state, label)
|
|
89
|
+
usage = pool.cached(label, 86400)
|
|
90
|
+
windows = (usage or {}).get("rateLimits", {})
|
|
91
|
+
accounts.append(
|
|
92
|
+
{
|
|
93
|
+
"id": label,
|
|
94
|
+
"name": options.get("name") or identity.get("email") or label,
|
|
95
|
+
"email": identity.get("email"),
|
|
96
|
+
"enabled": options.get("enabled", True),
|
|
97
|
+
"priority": options.get("priority", 0),
|
|
98
|
+
"max_sessions": options.get("max_sessions", 0),
|
|
99
|
+
"sessions": sum(s["account"] == label for s in sessions),
|
|
100
|
+
"primary": windows.get("primary"),
|
|
101
|
+
"secondary": windows.get("secondary"),
|
|
102
|
+
"usage_known": usage is not None,
|
|
103
|
+
"usage_checked_at": pool.checked_at(label),
|
|
104
|
+
"maintenance": options.get("maintenance", False),
|
|
105
|
+
"next_check": min(
|
|
106
|
+
(b["next_check"] for b in blocks if b["account"] == label),
|
|
107
|
+
default=None,
|
|
108
|
+
),
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
with pool.db() as db:
|
|
112
|
+
for session in sessions:
|
|
113
|
+
saved_intent = db.execute(
|
|
114
|
+
"SELECT details FROM intents WHERE owner=? AND thread=?",
|
|
115
|
+
(session["id"], session.get("thread", "")),
|
|
116
|
+
).fetchone()
|
|
117
|
+
intent = json.loads(saved_intent[0]) if saved_intent else {}
|
|
118
|
+
session["reason"] = intent.get("review_reason") or intent.get("kind")
|
|
119
|
+
session["attempt"] = (intent.get("episode") or {}).get("attempt")
|
|
120
|
+
session["review_required"] = intent.get("review_required", False)
|
|
121
|
+
if session["review_required"]:
|
|
122
|
+
session["state"] = "needs_attention"
|
|
123
|
+
session.setdefault("recovery_enabled", True)
|
|
124
|
+
active = {s["id"] for s in sessions}
|
|
125
|
+
with pool.db() as db:
|
|
126
|
+
saved = {
|
|
127
|
+
r[0]: json.loads(r[1])
|
|
128
|
+
for r in db.execute("SELECT id,details FROM session_details")
|
|
129
|
+
}
|
|
130
|
+
for path in (state / "services").glob("*.job.json"):
|
|
131
|
+
try:
|
|
132
|
+
job = json.loads(path.read_text())
|
|
133
|
+
except (OSError, ValueError):
|
|
134
|
+
continue
|
|
135
|
+
ident = job.get("id", path.name.split(".")[0])
|
|
136
|
+
if ident in active:
|
|
137
|
+
continue
|
|
138
|
+
if not include_inactive and job.get("state") != "needs_attention":
|
|
139
|
+
continue
|
|
140
|
+
prior = saved.get(ident, {})
|
|
141
|
+
sessions.append(
|
|
142
|
+
{
|
|
143
|
+
"id": ident,
|
|
144
|
+
"pid": None,
|
|
145
|
+
"name": job.get("name", ident[:10]),
|
|
146
|
+
"cwd": job.get("cwd"),
|
|
147
|
+
"thread": job.get("resume"),
|
|
148
|
+
"account": prior.get("account") or job.get("first") or "",
|
|
149
|
+
"model": prior.get("model"),
|
|
150
|
+
"state": job.get("state", "unknown"),
|
|
151
|
+
"paused": prior.get("paused", False),
|
|
152
|
+
"last_error": job.get("last_error"),
|
|
153
|
+
"running": False,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
return {
|
|
157
|
+
"version": VERSION,
|
|
158
|
+
"at": time.time(),
|
|
159
|
+
"sessions": sessions,
|
|
160
|
+
"accounts": accounts,
|
|
161
|
+
"blocks": blocks,
|
|
162
|
+
}
|
|
163
|
+
finally:
|
|
164
|
+
pool.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render(value):
|
|
168
|
+
def percent(window):
|
|
169
|
+
return (
|
|
170
|
+
"?" if not window else str(max(0, 100 - window.get("usedPercent", 0))) + "%"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
lines = [
|
|
174
|
+
f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
|
|
175
|
+
"",
|
|
176
|
+
f"{'ACCOUNT':<39}{'STATE':<13}{'SHORT LEFT':<13}{'WEEK LEFT':<13}{'SESSIONS':<10}NEXT CHECK",
|
|
177
|
+
]
|
|
178
|
+
for account in value["accounts"]:
|
|
179
|
+
label = (
|
|
180
|
+
account["name"]
|
|
181
|
+
+ (" [" + account["id"] + "]" if account["name"] != account["id"] else "")
|
|
182
|
+
)[:38]
|
|
183
|
+
state = (
|
|
184
|
+
"maintenance"
|
|
185
|
+
if account["maintenance"]
|
|
186
|
+
else "enabled"
|
|
187
|
+
if account["enabled"]
|
|
188
|
+
else "disabled"
|
|
189
|
+
)
|
|
190
|
+
next_at = (
|
|
191
|
+
time.strftime("%m-%d %H:%M:%S", time.localtime(account["next_check"]))
|
|
192
|
+
if account["next_check"]
|
|
193
|
+
else "—"
|
|
194
|
+
)
|
|
195
|
+
lines.append(
|
|
196
|
+
f"{label:<39}{state:<13}{percent(account['primary']):<13}{percent(account['secondary']):<13}{account['sessions']:<10}{next_at}"
|
|
197
|
+
)
|
|
198
|
+
if value["sessions"]:
|
|
199
|
+
lines += [
|
|
200
|
+
"",
|
|
201
|
+
f"{'SESSION':<12}{'ACCOUNT':<14}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
|
|
202
|
+
]
|
|
203
|
+
for row in value["sessions"]:
|
|
204
|
+
lines.append(
|
|
205
|
+
f"{row['id'][:10]:<12}{row.get('account', ''):<14}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
|
|
206
|
+
)
|
|
207
|
+
if row.get("next_wake"):
|
|
208
|
+
lines.append(
|
|
209
|
+
" next action: "
|
|
210
|
+
+ time.strftime("%m-%d %H:%M:%S", time.localtime(row["next_wake"]))
|
|
211
|
+
)
|
|
212
|
+
lines += [
|
|
213
|
+
"",
|
|
214
|
+
"Percentages are last observed allowance; native account limits remain authoritative.",
|
|
215
|
+
]
|
|
216
|
+
return "\n".join(lines)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def status(args, state):
|
|
220
|
+
from watch import LocalChanges, fingerprint
|
|
221
|
+
|
|
222
|
+
previous = None
|
|
223
|
+
watcher = LocalChanges(state) if args.watch else None
|
|
224
|
+
try:
|
|
225
|
+
while True:
|
|
226
|
+
value = snapshot(state, getattr(args, "all", False))
|
|
227
|
+
current = fingerprint(value)
|
|
228
|
+
if current != previous:
|
|
229
|
+
if args.json:
|
|
230
|
+
print(
|
|
231
|
+
json.dumps(value, indent=None if args.watch else 2), flush=True
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
if args.watch and sys.stdout.isatty():
|
|
235
|
+
print("\033[2J\033[H", end="")
|
|
236
|
+
print(render(value), flush=True)
|
|
237
|
+
previous = current
|
|
238
|
+
if not args.watch:
|
|
239
|
+
return 0
|
|
240
|
+
await watcher.changed(max(1, args.interval))
|
|
241
|
+
finally:
|
|
242
|
+
if watcher:
|
|
243
|
+
watcher.close()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def control(args, state):
|
|
247
|
+
from router import RouterError
|
|
248
|
+
|
|
249
|
+
pool = Pool(state)
|
|
250
|
+
try:
|
|
251
|
+
sessions = pool.detail()
|
|
252
|
+
if args.target == "all":
|
|
253
|
+
matching = sessions
|
|
254
|
+
elif args.target:
|
|
255
|
+
matching = [
|
|
256
|
+
s
|
|
257
|
+
for s in sessions
|
|
258
|
+
if s["id"].startswith(args.target) or s.get("name") == args.target
|
|
259
|
+
]
|
|
260
|
+
else:
|
|
261
|
+
matching = sessions if len(sessions) == 1 else []
|
|
262
|
+
if not matching or args.target != "all" and len(matching) != 1:
|
|
263
|
+
raise RouterError(
|
|
264
|
+
"Choose a unique session ID/name from status, or use all."
|
|
265
|
+
)
|
|
266
|
+
params = {}
|
|
267
|
+
if args.command == "switch":
|
|
268
|
+
params["account"] = resolve_label(state, args.account)
|
|
269
|
+
if getattr(args, "thread", None):
|
|
270
|
+
params["thread"] = args.thread
|
|
271
|
+
for session in matching:
|
|
272
|
+
op = "resume" if args.command == "unpause" else args.command
|
|
273
|
+
ident = pool.command(session["id"], op, params)
|
|
274
|
+
if getattr(args, "wait", False):
|
|
275
|
+
from service import wait_control
|
|
276
|
+
|
|
277
|
+
await wait_control(pool, ident, timeout=args.timeout)
|
|
278
|
+
print(f"{op.capitalize()} applied to {session['id'][:10]}.")
|
|
279
|
+
else:
|
|
280
|
+
print(f"Queued {op} for {session['id'][:10]} (command {ident}).")
|
|
281
|
+
return 0
|
|
282
|
+
finally:
|
|
283
|
+
pool.close()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def manage(args, state, binary):
|
|
287
|
+
async with file_lock(state / "enrollment.lock"):
|
|
288
|
+
return await _manage(args, state, binary)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _manage(args, state, binary):
|
|
292
|
+
from router import labels, account_home, add_account, RouterError, clean_env
|
|
293
|
+
import broker
|
|
294
|
+
|
|
295
|
+
pool = Pool(state)
|
|
296
|
+
try:
|
|
297
|
+
label = resolve_label(state, args.label) if args.action != "add" else args.label
|
|
298
|
+
if args.action in {"enable", "disable", "priority", "cap", "rename", "models"}:
|
|
299
|
+
if args.action in {"enable", "disable"}:
|
|
300
|
+
pool.set_options(label, enabled=args.action == "enable")
|
|
301
|
+
elif args.action == "priority":
|
|
302
|
+
pool.set_options(label, priority=args.value)
|
|
303
|
+
elif args.action == "cap":
|
|
304
|
+
if args.value < 0:
|
|
305
|
+
raise RouterError(
|
|
306
|
+
"Session cap must be nonnegative; 0 is unlimited."
|
|
307
|
+
)
|
|
308
|
+
pool.set_options(label, max_sessions=args.value)
|
|
309
|
+
elif args.action == "models":
|
|
310
|
+
pool.set_options(label, models=args.values)
|
|
311
|
+
else:
|
|
312
|
+
if not args.value.strip():
|
|
313
|
+
raise RouterError("Choose a nonempty name")
|
|
314
|
+
if any(
|
|
315
|
+
pool.options(other).get("name", other) == args.value
|
|
316
|
+
for other in labels(state)
|
|
317
|
+
if other != label
|
|
318
|
+
):
|
|
319
|
+
raise RouterError("That account name is already used")
|
|
320
|
+
pool.set_options(label, name=args.value)
|
|
321
|
+
print(f"{label}: {args.action} updated.")
|
|
322
|
+
return 0
|
|
323
|
+
home = account_home(state, label)
|
|
324
|
+
home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
325
|
+
if not pool.begin_maintenance(label):
|
|
326
|
+
raise RouterError(
|
|
327
|
+
"This account is assigned to a live session. Switch those sessions to another account first; other accounts can keep running."
|
|
328
|
+
)
|
|
329
|
+
try:
|
|
330
|
+
with contextlib.suppress(Exception):
|
|
331
|
+
await broker.exchange(state, {"op": "invalidate", "label": label}, 15)
|
|
332
|
+
async with file_lock(home / "credential.lock"):
|
|
333
|
+
if args.action in {"add", "reauth"}:
|
|
334
|
+
args.label = label
|
|
335
|
+
await asyncio.to_thread(add_account, args, state, binary)
|
|
336
|
+
elif args.action == "remove":
|
|
337
|
+
if args.revoke:
|
|
338
|
+
result = await asyncio.create_subprocess_exec(
|
|
339
|
+
binary,
|
|
340
|
+
"logout",
|
|
341
|
+
env=clean_env(home),
|
|
342
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
343
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
344
|
+
)
|
|
345
|
+
if await result.wait():
|
|
346
|
+
raise RouterError(
|
|
347
|
+
"Native sign-out failed; account was retained."
|
|
348
|
+
)
|
|
349
|
+
marker = home / "enrolled"
|
|
350
|
+
if marker.exists():
|
|
351
|
+
marker.rename(home / "enrolled.removed")
|
|
352
|
+
pool.reset_identity(label)
|
|
353
|
+
pool.set_options(label, enabled=False)
|
|
354
|
+
print(
|
|
355
|
+
f"{label}: removed from routing. "
|
|
356
|
+
+ (
|
|
357
|
+
"Native login revoked."
|
|
358
|
+
if args.revoke
|
|
359
|
+
else "Keychain login retained; use add to enroll again."
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
finally:
|
|
363
|
+
pool.set_options(label, maintenance=False)
|
|
364
|
+
pool.signal()
|
|
365
|
+
return 0
|
|
366
|
+
finally:
|
|
367
|
+
pool.close()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def history(state, label=None):
|
|
371
|
+
pool = Pool(state)
|
|
372
|
+
try:
|
|
373
|
+
with pool.db() as db:
|
|
374
|
+
rows = db.execute(
|
|
375
|
+
"SELECT at,label,snapshot FROM history WHERE (? IS NULL OR label=?) ORDER BY at DESC LIMIT 1000",
|
|
376
|
+
(label, label),
|
|
377
|
+
).fetchall()
|
|
378
|
+
grouped = {}
|
|
379
|
+
for at, name, raw in rows:
|
|
380
|
+
grouped.setdefault(name, []).append((at, json.loads(raw)))
|
|
381
|
+
output = []
|
|
382
|
+
for name, points in grouped.items():
|
|
383
|
+
latest = points[0][1].get("rateLimits", {})
|
|
384
|
+
item = {
|
|
385
|
+
"account": name,
|
|
386
|
+
"samples": len(points),
|
|
387
|
+
"at": points[0][0],
|
|
388
|
+
"primary": latest.get("primary"),
|
|
389
|
+
"secondary": latest.get("secondary"),
|
|
390
|
+
"forecast": None,
|
|
391
|
+
}
|
|
392
|
+
if len(points) > 1:
|
|
393
|
+
first_time, first = points[-1]
|
|
394
|
+
last_time, last = points[0]
|
|
395
|
+
a = first.get("rateLimits", {}).get("primary") or {}
|
|
396
|
+
b = last.get("rateLimits", {}).get("primary") or {}
|
|
397
|
+
elapsed = last_time - first_time
|
|
398
|
+
delta = b.get("usedPercent", 0) - a.get("usedPercent", 0)
|
|
399
|
+
if (
|
|
400
|
+
elapsed >= 60
|
|
401
|
+
and delta > 0
|
|
402
|
+
and a.get("resetsAt") == b.get("resetsAt")
|
|
403
|
+
):
|
|
404
|
+
item["forecast"] = {
|
|
405
|
+
"estimated_seconds_to_depletion": max(0, 100 - b["usedPercent"])
|
|
406
|
+
* elapsed
|
|
407
|
+
/ delta,
|
|
408
|
+
"basis": "Observed primary allowance change in one reset window; estimate only.",
|
|
409
|
+
}
|
|
410
|
+
output.append(item)
|
|
411
|
+
return output
|
|
412
|
+
finally:
|
|
413
|
+
pool.close()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def doctor(args, state, binary):
|
|
417
|
+
import compatibility
|
|
418
|
+
|
|
419
|
+
result = {
|
|
420
|
+
"platform": sys.platform,
|
|
421
|
+
"python": sys.version.split()[0],
|
|
422
|
+
"state": str(state),
|
|
423
|
+
"checks": {},
|
|
424
|
+
}
|
|
425
|
+
checks = result["checks"]
|
|
426
|
+
if args.restore_backup:
|
|
427
|
+
from metadata import restore_backup
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
async with asyncio.timeout(2):
|
|
431
|
+
async with file_lock(state / "metadata.lock"):
|
|
432
|
+
try:
|
|
433
|
+
checks["metadata_before_restore"] = restore_backup(state)
|
|
434
|
+
except sqlite3.Error:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
"Metadata backup is corrupt or unreadable; existing files were retained"
|
|
437
|
+
) from None
|
|
438
|
+
except asyncio.TimeoutError:
|
|
439
|
+
raise ValueError(
|
|
440
|
+
"Stop wrapper sessions before restoring metadata"
|
|
441
|
+
) from None
|
|
442
|
+
try:
|
|
443
|
+
db = sqlite3.connect(state / "pool.sqlite")
|
|
444
|
+
checks["database"] = db.execute("PRAGMA quick_check").fetchone()[0]
|
|
445
|
+
db.close()
|
|
446
|
+
except sqlite3.Error:
|
|
447
|
+
checks["database"] = "corrupt or unreadable"
|
|
448
|
+
checks["state_permissions"] = oct(state.stat().st_mode & 0o777)
|
|
449
|
+
try:
|
|
450
|
+
async with file_lock(state / "compatibility.lock"):
|
|
451
|
+
checks["compatibility"] = await asyncio.to_thread(
|
|
452
|
+
compatibility.check, binary, state
|
|
453
|
+
)
|
|
454
|
+
except Exception:
|
|
455
|
+
checks["compatibility"] = "failed; no credentials were read"
|
|
456
|
+
checks["keychain_reader"] = Path("/usr/bin/security").exists()
|
|
457
|
+
try:
|
|
458
|
+
import broker
|
|
459
|
+
|
|
460
|
+
checks["broker"] = await broker.exchange(state, {"op": "ping"}, 2)
|
|
461
|
+
except Exception:
|
|
462
|
+
checks["broker"] = "not running (starts on demand)"
|
|
463
|
+
if args.self_test:
|
|
464
|
+
source = Path(__file__).parent
|
|
465
|
+
tests = (
|
|
466
|
+
source / "tests" if (source / "tests").is_dir() else source.parent / "tests"
|
|
467
|
+
)
|
|
468
|
+
if not tests.is_dir():
|
|
469
|
+
raise ValueError(
|
|
470
|
+
"Offline checks are missing. Reinstall Relay and try again."
|
|
471
|
+
)
|
|
472
|
+
env = {
|
|
473
|
+
**os.environ,
|
|
474
|
+
"PYTHONPATH": os.pathsep.join(
|
|
475
|
+
[str(source), str(tests), os.environ.get("PYTHONPATH", "")]
|
|
476
|
+
),
|
|
477
|
+
}
|
|
478
|
+
proc = await asyncio.create_subprocess_exec(
|
|
479
|
+
sys.executable,
|
|
480
|
+
"-m",
|
|
481
|
+
"unittest",
|
|
482
|
+
"discover",
|
|
483
|
+
"-s",
|
|
484
|
+
str(tests),
|
|
485
|
+
"-q",
|
|
486
|
+
cwd=str(tests),
|
|
487
|
+
env=env,
|
|
488
|
+
)
|
|
489
|
+
checks["offline_tests_exit_code"] = await proc.wait()
|
|
490
|
+
if args.backup and checks["database"] == "ok":
|
|
491
|
+
target = state / "pool.backup.sqlite"
|
|
492
|
+
source = sqlite3.connect(state / "pool.sqlite")
|
|
493
|
+
dest = sqlite3.connect(target)
|
|
494
|
+
source.backup(dest)
|
|
495
|
+
source.close()
|
|
496
|
+
dest.close()
|
|
497
|
+
target.chmod(0o600)
|
|
498
|
+
checks["backup"] = str(target)
|
|
499
|
+
healthy = (
|
|
500
|
+
checks.get("database") == "ok"
|
|
501
|
+
and isinstance(checks.get("compatibility"), dict)
|
|
502
|
+
and checks.get("offline_tests_exit_code", 0) == 0
|
|
503
|
+
)
|
|
504
|
+
print(json.dumps(result, indent=2))
|
|
505
|
+
return 0 if healthy else 1
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
async def group(args, state, binary):
|
|
509
|
+
from router import RouterError
|
|
510
|
+
import service
|
|
511
|
+
|
|
512
|
+
path = state / "groups.json"
|
|
513
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
514
|
+
if args.action == "list":
|
|
515
|
+
print(json.dumps(data, indent=2))
|
|
516
|
+
return 0
|
|
517
|
+
if args.action == "save":
|
|
518
|
+
data[args.name] = [
|
|
519
|
+
{"name": Path(p).name, "cwd": str(Path(p).expanduser().resolve())}
|
|
520
|
+
for p in args.paths
|
|
521
|
+
]
|
|
522
|
+
settings.atomic_json(path, data)
|
|
523
|
+
print("Saved group " + args.name)
|
|
524
|
+
return 0
|
|
525
|
+
if args.action == "remove":
|
|
526
|
+
data.pop(args.name, None)
|
|
527
|
+
settings.atomic_json(path, data)
|
|
528
|
+
return 0
|
|
529
|
+
if args.name not in data:
|
|
530
|
+
raise RouterError("Unknown task group")
|
|
531
|
+
if args.action == "start":
|
|
532
|
+
import runtime
|
|
533
|
+
|
|
534
|
+
async with file_lock(state / "compatibility.lock"):
|
|
535
|
+
binary, _, _ = await asyncio.to_thread(runtime.select, binary, state)
|
|
536
|
+
for spec in data[args.name]:
|
|
537
|
+
ident = service.start(
|
|
538
|
+
state,
|
|
539
|
+
name=args.name + "/" + spec["name"],
|
|
540
|
+
cwd=spec["cwd"],
|
|
541
|
+
arguments=["--yolo"] if args.yolo else [],
|
|
542
|
+
binary=binary,
|
|
543
|
+
)
|
|
544
|
+
print(ident + " " + spec["name"])
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def add_parsers(sub):
|
|
549
|
+
preview = sub.add_parser(
|
|
550
|
+
"explain", help="Preview a native Codex launch without starting a task"
|
|
551
|
+
)
|
|
552
|
+
preview.add_argument("codex_args", nargs="...")
|
|
553
|
+
diagnostics = sub.add_parser(
|
|
554
|
+
"diagnostics",
|
|
555
|
+
help="Export redacted operational facts without credentials, prompts, or project paths",
|
|
556
|
+
)
|
|
557
|
+
diagnostics.add_argument("output")
|
|
558
|
+
clean = sub.add_parser(
|
|
559
|
+
"clean", help="Preview safe cleanup of unreferenced old runtime files"
|
|
560
|
+
)
|
|
561
|
+
clean.add_argument("--apply", action="store_true")
|
|
562
|
+
clean.add_argument("--older-than", type=int, default=30, dest="days")
|
|
563
|
+
clean.add_argument(
|
|
564
|
+
"--jobs",
|
|
565
|
+
action="store_true",
|
|
566
|
+
help="Also remove old stopped jobs; saved workspace references are retained",
|
|
567
|
+
)
|
|
568
|
+
workspaces = sub.add_parser(
|
|
569
|
+
"workspace",
|
|
570
|
+
help="Save and restore the same conversations across several terminals",
|
|
571
|
+
)
|
|
572
|
+
ws = workspaces.add_subparsers(dest="action", required=True)
|
|
573
|
+
ws.add_parser("list")
|
|
574
|
+
save_ws = ws.add_parser("save")
|
|
575
|
+
save_ws.add_argument("name")
|
|
576
|
+
save_ws.add_argument("sessions", nargs="*")
|
|
577
|
+
for action in ("restore", "remove"):
|
|
578
|
+
child = ws.add_parser(action)
|
|
579
|
+
child.add_argument("name")
|
|
580
|
+
status = sub.add_parser(
|
|
581
|
+
"status", help="Account and session dashboard without live network requests"
|
|
582
|
+
)
|
|
583
|
+
status.add_argument("--json", action="store_true")
|
|
584
|
+
status.add_argument("--watch", action="store_true")
|
|
585
|
+
status.add_argument(
|
|
586
|
+
"--all",
|
|
587
|
+
action="store_true",
|
|
588
|
+
help="Include stopped and completed saved sessions",
|
|
589
|
+
)
|
|
590
|
+
restart = sub.add_parser(
|
|
591
|
+
"restart", help="Restart a stopped session from saved history"
|
|
592
|
+
)
|
|
593
|
+
restart.add_argument("session")
|
|
594
|
+
restart.add_argument("--if-stopped", action="store_true")
|
|
595
|
+
status.add_argument(
|
|
596
|
+
"--interval",
|
|
597
|
+
type=float,
|
|
598
|
+
default=30,
|
|
599
|
+
help="Maximum local filesystem wait; output changes only when data changes",
|
|
600
|
+
)
|
|
601
|
+
for command in ["pause", "unpause", "retry", "wake", "stop", "switch"]:
|
|
602
|
+
p = sub.add_parser(command, help=command + " one live session or all")
|
|
603
|
+
p.add_argument("target", nargs="?")
|
|
604
|
+
p.add_argument("--thread")
|
|
605
|
+
p.add_argument(
|
|
606
|
+
"--wait",
|
|
607
|
+
action="store_true",
|
|
608
|
+
help="Wait for the worker to confirm the action",
|
|
609
|
+
)
|
|
610
|
+
p.add_argument("--timeout", type=float, default=15)
|
|
611
|
+
if command == "switch":
|
|
612
|
+
p.add_argument("--account", required=True)
|
|
613
|
+
account = sub.add_parser("account", help="Manage any number of enrolled accounts")
|
|
614
|
+
child = account.add_subparsers(dest="action", required=True)
|
|
615
|
+
child.add_parser("identify", help="Fill in account emails from saved local logins")
|
|
616
|
+
for action in [
|
|
617
|
+
"add",
|
|
618
|
+
"reauth",
|
|
619
|
+
"remove",
|
|
620
|
+
"enable",
|
|
621
|
+
"disable",
|
|
622
|
+
"rename",
|
|
623
|
+
"priority",
|
|
624
|
+
"cap",
|
|
625
|
+
"models",
|
|
626
|
+
]:
|
|
627
|
+
p = child.add_parser(action)
|
|
628
|
+
p.add_argument("label")
|
|
629
|
+
if action in {"add", "reauth"}:
|
|
630
|
+
p.add_argument("--device-auth", action="store_true")
|
|
631
|
+
if action == "remove":
|
|
632
|
+
p.add_argument("--revoke", action="store_true")
|
|
633
|
+
if action == "rename":
|
|
634
|
+
p.add_argument("value")
|
|
635
|
+
if action in {"priority", "cap"}:
|
|
636
|
+
p.add_argument("value", type=int)
|
|
637
|
+
if action == "models":
|
|
638
|
+
p.add_argument("values", nargs="*")
|
|
639
|
+
config = sub.add_parser("config", help="Set global or named recovery profiles")
|
|
640
|
+
config.add_argument("key", nargs="?")
|
|
641
|
+
config.add_argument("value", nargs="?")
|
|
642
|
+
config.add_argument("--profile")
|
|
643
|
+
logs = sub.add_parser("events", help="Read bounded metadata-only event history")
|
|
644
|
+
logs.add_argument("--limit", type=int, default=30)
|
|
645
|
+
logs.add_argument("--json", action="store_true")
|
|
646
|
+
hist = sub.add_parser("usage", help="Last observed allowance and local estimates")
|
|
647
|
+
hist.add_argument("--account")
|
|
648
|
+
hist.add_argument("--json", action="store_true")
|
|
649
|
+
doc = sub.add_parser(
|
|
650
|
+
"doctor", help="Check installation, database, broker and compatibility"
|
|
651
|
+
)
|
|
652
|
+
doc.add_argument("--self-test", action="store_true")
|
|
653
|
+
doc.add_argument("--backup", action="store_true")
|
|
654
|
+
doc.add_argument(
|
|
655
|
+
"--restore-backup",
|
|
656
|
+
action="store_true",
|
|
657
|
+
help="Restore metadata when wrapper sessions are stopped; retain the replaced database",
|
|
658
|
+
)
|
|
659
|
+
runtime = sub.add_parser(
|
|
660
|
+
"runtime", help="Preflight, snapshot or select a tested native Codex runtime"
|
|
661
|
+
)
|
|
662
|
+
runtime.add_argument("action", choices=["list", "cache", "use", "preflight"])
|
|
663
|
+
runtime.add_argument("value", nargs="?")
|
|
664
|
+
startup = sub.add_parser(
|
|
665
|
+
"startup", help="Choose background sessions to recover at Mac login"
|
|
666
|
+
)
|
|
667
|
+
startup.add_argument("action", choices=["list", "enable", "disable"])
|
|
668
|
+
startup.add_argument("session", nargs="?")
|
|
669
|
+
menu = sub.add_parser(
|
|
670
|
+
"menubar", help="Build or launch the optional native menu app"
|
|
671
|
+
)
|
|
672
|
+
menu.add_argument("--build-only", action="store_true")
|
|
673
|
+
menu.add_argument(
|
|
674
|
+
"--no-startup", action="store_true", help="Disable menu login startup"
|
|
675
|
+
)
|
|
676
|
+
menu.add_argument(
|
|
677
|
+
"--configure-only", action="store_true", help="Only update the login setting"
|
|
678
|
+
)
|
|
679
|
+
start = sub.add_parser(
|
|
680
|
+
"start", help="Start a supervised background task; attach later"
|
|
681
|
+
)
|
|
682
|
+
for flag in ["name", "cwd", "first", "resume", "prompt", "profile"]:
|
|
683
|
+
start.add_argument("--" + flag)
|
|
684
|
+
start.add_argument("codex_args", nargs="...")
|
|
685
|
+
attach = sub.add_parser(
|
|
686
|
+
"attach", help="Attach the native terminal to a background session"
|
|
687
|
+
)
|
|
688
|
+
attach.add_argument("session")
|
|
689
|
+
attach.add_argument("codex_args", nargs="...")
|
|
690
|
+
attach.add_argument(
|
|
691
|
+
"--takeover",
|
|
692
|
+
action="store_true",
|
|
693
|
+
help="Move an existing terminal attachment without stopping its task",
|
|
694
|
+
)
|
|
695
|
+
groups = sub.add_parser("group", help="Save and launch groups of project tasks")
|
|
696
|
+
g = groups.add_subparsers(dest="action", required=True)
|
|
697
|
+
g.add_parser("list")
|
|
698
|
+
save = g.add_parser("save")
|
|
699
|
+
save.add_argument("name")
|
|
700
|
+
save.add_argument("paths", nargs="+")
|
|
701
|
+
rm = g.add_parser("remove")
|
|
702
|
+
rm.add_argument("name")
|
|
703
|
+
launch = g.add_parser("start")
|
|
704
|
+
launch.add_argument("name")
|
|
705
|
+
launch.add_argument("--yolo", action="store_true")
|
|
706
|
+
for command in ["_supervise", "_serve"]:
|
|
707
|
+
p = sub.add_parser(command, help=__import__("argparse").SUPPRESS)
|
|
708
|
+
p.add_argument("session")
|
|
709
|
+
b = sub.add_parser("_broker", help=__import__("argparse").SUPPRESS)
|
|
710
|
+
b.add_argument("--binary")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
async def dispatch(args, state, binary):
|
|
714
|
+
from router import RouterError
|
|
715
|
+
|
|
716
|
+
if args.command in {"explain", "diagnostics", "clean", "workspace"}:
|
|
717
|
+
import tools
|
|
718
|
+
|
|
719
|
+
if args.command == "explain":
|
|
720
|
+
result = tools.explain(args.codex_args, os.getcwd(), binary)
|
|
721
|
+
elif args.command == "diagnostics":
|
|
722
|
+
result = {"written": tools.diagnostics(state, args.output)}
|
|
723
|
+
elif args.command == "clean":
|
|
724
|
+
if args.days < 1:
|
|
725
|
+
raise ValueError("Retention must be at least one day")
|
|
726
|
+
from pool import file_lock
|
|
727
|
+
|
|
728
|
+
async with file_lock(state / "cleanup.lock"):
|
|
729
|
+
if args.apply:
|
|
730
|
+
try:
|
|
731
|
+
async with asyncio.timeout(2):
|
|
732
|
+
async with file_lock(state / "metadata.lock"):
|
|
733
|
+
result = tools.cleanup(
|
|
734
|
+
state, apply=True, days=args.days, jobs=args.jobs
|
|
735
|
+
)
|
|
736
|
+
except asyncio.TimeoutError:
|
|
737
|
+
raise RouterError(
|
|
738
|
+
"Stop supervised sessions before applying cleanup. The dry run works while sessions are active."
|
|
739
|
+
) from None
|
|
740
|
+
else:
|
|
741
|
+
result = tools.cleanup(
|
|
742
|
+
state, apply=args.apply, days=args.days, jobs=args.jobs
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
result = tools.workspace(
|
|
746
|
+
state,
|
|
747
|
+
args.action,
|
|
748
|
+
getattr(args, "name", None),
|
|
749
|
+
getattr(args, "sessions", None),
|
|
750
|
+
binary,
|
|
751
|
+
)
|
|
752
|
+
print(json.dumps(result, indent=2))
|
|
753
|
+
return 0
|
|
754
|
+
if args.command == "startup":
|
|
755
|
+
import desktop
|
|
756
|
+
|
|
757
|
+
if args.action != "list" and not args.session:
|
|
758
|
+
raise ValueError("Choose a saved session ID")
|
|
759
|
+
print(
|
|
760
|
+
json.dumps(desktop.job_startup(state, args.action, args.session), indent=2)
|
|
761
|
+
)
|
|
762
|
+
return 0
|
|
763
|
+
if args.command == "restart":
|
|
764
|
+
import service
|
|
765
|
+
|
|
766
|
+
if args.if_stopped:
|
|
767
|
+
pool = Pool(state)
|
|
768
|
+
try:
|
|
769
|
+
ident = service.resolve(state, args.session)
|
|
770
|
+
if any(s["id"] == ident for s in pool.detail()):
|
|
771
|
+
return 0
|
|
772
|
+
finally:
|
|
773
|
+
pool.close()
|
|
774
|
+
|
|
775
|
+
async with file_lock(state / "restart.lock"):
|
|
776
|
+
ident = service.restart(state, args.session)
|
|
777
|
+
print("Restarted " + ident)
|
|
778
|
+
return 0
|
|
779
|
+
if args.command == "status":
|
|
780
|
+
if args.interval < 0.5:
|
|
781
|
+
raise RouterError("Watch interval must be at least 0.5 seconds")
|
|
782
|
+
return await status(args, state)
|
|
783
|
+
if args.command in {"pause", "unpause", "retry", "wake", "stop", "switch"}:
|
|
784
|
+
return await control(args, state)
|
|
785
|
+
if args.command == "account":
|
|
786
|
+
if args.action == "identify":
|
|
787
|
+
print(
|
|
788
|
+
f"Identified {await asyncio.to_thread(identify_accounts, state)} saved accounts."
|
|
789
|
+
)
|
|
790
|
+
return 0
|
|
791
|
+
return await manage(args, state, binary)
|
|
792
|
+
if args.command == "config":
|
|
793
|
+
if args.key:
|
|
794
|
+
if args.value is None:
|
|
795
|
+
raise RouterError("Provide a setting value")
|
|
796
|
+
settings.edit(state, args.key, args.value, args.profile)
|
|
797
|
+
pool = Pool(state)
|
|
798
|
+
pool.signal()
|
|
799
|
+
pool.close()
|
|
800
|
+
print(json.dumps(settings.load(state, os.getcwd(), args.profile), indent=2))
|
|
801
|
+
return 0
|
|
802
|
+
if args.command == "events":
|
|
803
|
+
pool = Pool(state)
|
|
804
|
+
try:
|
|
805
|
+
with pool.db() as db:
|
|
806
|
+
rows = db.execute(
|
|
807
|
+
"SELECT at,owner,kind,details FROM events ORDER BY id DESC LIMIT ?",
|
|
808
|
+
(max(1, min(args.limit, 2000)),),
|
|
809
|
+
).fetchall()
|
|
810
|
+
value = [
|
|
811
|
+
dict(at=r[0], session=r[1], kind=r[2], details=json.loads(r[3]))
|
|
812
|
+
for r in rows
|
|
813
|
+
]
|
|
814
|
+
print(
|
|
815
|
+
json.dumps(value, indent=2)
|
|
816
|
+
if args.json
|
|
817
|
+
else "\n".join(
|
|
818
|
+
time.strftime("%m-%d %H:%M:%S", time.localtime(v["at"]))
|
|
819
|
+
+ " "
|
|
820
|
+
+ v["session"][:10]
|
|
821
|
+
+ " "
|
|
822
|
+
+ v["kind"]
|
|
823
|
+
+ " "
|
|
824
|
+
+ json.dumps(v["details"])
|
|
825
|
+
for v in value
|
|
826
|
+
)
|
|
827
|
+
)
|
|
828
|
+
return 0
|
|
829
|
+
finally:
|
|
830
|
+
pool.close()
|
|
831
|
+
if args.command == "usage":
|
|
832
|
+
label = resolve_label(state, args.account) if args.account else None
|
|
833
|
+
print(json.dumps(history(state, label), indent=2))
|
|
834
|
+
return 0
|
|
835
|
+
if args.command == "doctor":
|
|
836
|
+
return await doctor(args, state, binary)
|
|
837
|
+
if args.command == "runtime":
|
|
838
|
+
import runtime, compatibility
|
|
839
|
+
|
|
840
|
+
async with file_lock(state / "compatibility.lock"):
|
|
841
|
+
if args.action == "list":
|
|
842
|
+
print(json.dumps(runtime.index(state), indent=2))
|
|
843
|
+
elif args.action == "use":
|
|
844
|
+
runtime.choose(state, args.value)
|
|
845
|
+
print(
|
|
846
|
+
"Wrapper runtime selection updated; existing sessions are unchanged."
|
|
847
|
+
)
|
|
848
|
+
elif args.action == "cache":
|
|
849
|
+
path, result = await asyncio.to_thread(
|
|
850
|
+
runtime.capture, args.value or binary, state
|
|
851
|
+
)
|
|
852
|
+
print(result["version"] + " cached at " + path)
|
|
853
|
+
else:
|
|
854
|
+
result = await asyncio.to_thread(
|
|
855
|
+
compatibility.check, args.value or binary, state
|
|
856
|
+
)
|
|
857
|
+
print(json.dumps(result, indent=2))
|
|
858
|
+
return 0
|
|
859
|
+
if args.command == "menubar":
|
|
860
|
+
import desktop
|
|
861
|
+
|
|
862
|
+
if args.no_startup or args.configure_only:
|
|
863
|
+
desktop.login_item(state, enabled=not args.no_startup)
|
|
864
|
+
if args.configure_only:
|
|
865
|
+
print(
|
|
866
|
+
"Menu login startup "
|
|
867
|
+
+ ("disabled" if args.no_startup else "enabled")
|
|
868
|
+
)
|
|
869
|
+
return 0
|
|
870
|
+
app = await asyncio.to_thread(
|
|
871
|
+
desktop.menubar, state, None, args.build_only, not args.no_startup
|
|
872
|
+
)
|
|
873
|
+
print(str(app))
|
|
874
|
+
return 0
|
|
875
|
+
if args.command == "group":
|
|
876
|
+
return await group(args, state, binary)
|
|
877
|
+
if args.command == "start":
|
|
878
|
+
import service
|
|
879
|
+
|
|
880
|
+
first = resolve_label(state, args.first) if args.first else None
|
|
881
|
+
ident = service.start(
|
|
882
|
+
state,
|
|
883
|
+
name=args.name,
|
|
884
|
+
cwd=args.cwd,
|
|
885
|
+
first=first,
|
|
886
|
+
arguments=args.codex_args,
|
|
887
|
+
resume=args.resume,
|
|
888
|
+
prompt=args.prompt,
|
|
889
|
+
profile=args.profile,
|
|
890
|
+
binary=binary,
|
|
891
|
+
)
|
|
892
|
+
print("Started " + ident + "\nAttach: relay attach " + ident[:10])
|
|
893
|
+
return 0
|
|
894
|
+
if args.command == "attach":
|
|
895
|
+
import service
|
|
896
|
+
|
|
897
|
+
return await service.attach(
|
|
898
|
+
state, args.session, binary, args.codex_args or None, takeover=args.takeover
|
|
899
|
+
)
|
|
900
|
+
return None
|