raidlock 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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/web/data.py
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
|
|
10
|
+
from raidlock import party
|
|
11
|
+
from raidlock.core import Board, RaidlockError, clock, db, redact, registry
|
|
12
|
+
from raidlock.core import models as core_models
|
|
13
|
+
from raidlock.core.db import Index
|
|
14
|
+
|
|
15
|
+
LANES = ("todo", "doing", "review", "blocked", "done", "cancelled")
|
|
16
|
+
CHAT_KINDS = ("msg", "relay", "handback", "note", "review", "verify")
|
|
17
|
+
SPEND_BY = ("provider", "model", "agent", "workspace", "day")
|
|
18
|
+
NOTE_TAIL = 3
|
|
19
|
+
HIDDEN = (
|
|
20
|
+
"fence",
|
|
21
|
+
"token",
|
|
22
|
+
"cap",
|
|
23
|
+
"lead_secret",
|
|
24
|
+
"lead_secret_file",
|
|
25
|
+
"lead_fence",
|
|
26
|
+
"checkout",
|
|
27
|
+
"raidlock_token",
|
|
28
|
+
"raidlock_cap",
|
|
29
|
+
"raidlock_lead_secret",
|
|
30
|
+
"raidlock_lead_secret_file",
|
|
31
|
+
)
|
|
32
|
+
CHAT_TAIL = 20
|
|
33
|
+
VIEWS = {
|
|
34
|
+
"raidmap": "raidlock.web.raidmap",
|
|
35
|
+
"replay": "raidlock.web.replay",
|
|
36
|
+
"graph": "raidlock.web.graph",
|
|
37
|
+
"frames": "raidlock.frames",
|
|
38
|
+
"achievements": "raidlock.achievements",
|
|
39
|
+
"bosses": "raidlock.party",
|
|
40
|
+
}
|
|
41
|
+
VIEW_QUERY = ("at", "marks")
|
|
42
|
+
CLOCKED = ("frames", "raidmap")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def etag_of(*parts):
|
|
46
|
+
raw = json.dumps(parts, sort_keys=True, default=str).encode()
|
|
47
|
+
return '"' + hashlib.sha256(raw).hexdigest()[:32] + '"'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scrub(value, roots):
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
for root, alias in roots:
|
|
53
|
+
if root and root in value:
|
|
54
|
+
value = value.replace(root, alias)
|
|
55
|
+
return value
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
return [_scrub(v, roots) for v in value]
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return {_scrub(k, roots): _scrub(v, roots) for k, v in value.items()}
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def private_roots(home):
|
|
64
|
+
pairs = []
|
|
65
|
+
for root, alias in ((home, "~raid"), (os.path.expanduser("~"), "~"), (tempfile.gettempdir(), "~tmp")):
|
|
66
|
+
pairs.extend(((root, alias), (os.path.realpath(root), alias)))
|
|
67
|
+
return sorted(pairs, key=lambda p: -len(p[0]))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def scrub_private(value, home):
|
|
71
|
+
return _scrub(value, private_roots(home))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _roots(board):
|
|
75
|
+
pairs = [
|
|
76
|
+
(board.dir, ".board"),
|
|
77
|
+
(os.path.realpath(board.dir), ".board"),
|
|
78
|
+
(board.workspace, board.name),
|
|
79
|
+
(os.path.realpath(board.workspace), board.name),
|
|
80
|
+
] + private_roots(board.home)
|
|
81
|
+
return sorted(pairs, key=lambda p: -len(p[0]))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _text_of(record):
|
|
85
|
+
return "\n".join(p.get("text", "") for p in record.get("parts", []) if p.get("kind") == "text")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def slim_record(record):
|
|
89
|
+
sender = record.get("from") or {}
|
|
90
|
+
data = next((p.get("data") for p in record.get("parts", []) if p.get("kind") == "data"), None)
|
|
91
|
+
return {
|
|
92
|
+
"seq": record.get("seq", 0),
|
|
93
|
+
"id": record.get("messageId", ""),
|
|
94
|
+
"ts": record.get("ts", ""),
|
|
95
|
+
"kind": record.get("kind", ""),
|
|
96
|
+
"task": record.get("taskId", ""),
|
|
97
|
+
"to": record.get("to", ""),
|
|
98
|
+
"from": {
|
|
99
|
+
"agent": sender.get("agent", ""),
|
|
100
|
+
"provider": sender.get("provider", ""),
|
|
101
|
+
"runtime": sender.get("runtime", ""),
|
|
102
|
+
"model": sender.get("model", ""),
|
|
103
|
+
},
|
|
104
|
+
"text": _text_of(record),
|
|
105
|
+
"data": {k: v for k, v in data.items() if k not in HIDDEN} if isinstance(data, dict) else None,
|
|
106
|
+
"artifacts": [
|
|
107
|
+
{"name": a.get("name", ""), "kind": (a.get("metadata") or {}).get("kind", ""), "text": _text_of(a)}
|
|
108
|
+
for a in record.get("artifacts", [])
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _slim_lease(lease):
|
|
114
|
+
if not lease:
|
|
115
|
+
return None
|
|
116
|
+
return {
|
|
117
|
+
"agent": lease.get("agent", ""),
|
|
118
|
+
"paths": lease.get("paths", []),
|
|
119
|
+
"expires": lease.get("expires"),
|
|
120
|
+
"renewed": lease.get("renewed", ""),
|
|
121
|
+
"live": lease.get("live", True),
|
|
122
|
+
"branch": lease.get("branch", ""),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def repos_of(locks):
|
|
127
|
+
out = []
|
|
128
|
+
for lock in locks or []:
|
|
129
|
+
head = str(lock).strip().strip("/").split("/")[0]
|
|
130
|
+
if head and head not in out:
|
|
131
|
+
out.append(head)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def repo_counts(cards):
|
|
136
|
+
counts = {}
|
|
137
|
+
for c in cards:
|
|
138
|
+
for r in c.get("repos", []):
|
|
139
|
+
counts[r] = counts.get(r, 0) + 1
|
|
140
|
+
return [{"name": k, "cards": counts[k]} for k in sorted(counts)]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def spend_today(spend):
|
|
144
|
+
today = clock.iso()[:10]
|
|
145
|
+
row = next((r for r in (spend or {}).get("by_day", []) if str(r.get("key", ""))[:10] == today), None) or {}
|
|
146
|
+
return {
|
|
147
|
+
"day": today,
|
|
148
|
+
"usd": row.get("usd", 0),
|
|
149
|
+
"usd_equiv": row.get("usd_equiv", 0),
|
|
150
|
+
"tokens": row.get("tokens", 0),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def slim_pr(pr):
|
|
155
|
+
if not isinstance(pr, dict) or not pr.get("number"):
|
|
156
|
+
return None
|
|
157
|
+
checks = pr.get("checks") or {}
|
|
158
|
+
return {
|
|
159
|
+
"number": pr.get("number"),
|
|
160
|
+
"url": pr.get("url", ""),
|
|
161
|
+
"state": pr.get("state", ""),
|
|
162
|
+
"checks": {k: checks.get(k, 0) for k in ("passed", "failed", "pending", "total")}
|
|
163
|
+
| {"state": checks.get("state", "none")},
|
|
164
|
+
"held": pr.get("held", ""),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def slim_card(card, known=None):
|
|
169
|
+
notes = card.get("notes", [])
|
|
170
|
+
heads = repos_of(card.get("locks", [])) + ([card["repo"]] if card.get("repo") else [])
|
|
171
|
+
return {
|
|
172
|
+
"id": card["id"],
|
|
173
|
+
"repos": sorted({r for r in heads if not known or r in known}),
|
|
174
|
+
"title": card.get("title", ""),
|
|
175
|
+
"lane": card.get("lane", ""),
|
|
176
|
+
"owner": card.get("owner", ""),
|
|
177
|
+
"team": card.get("team", ""),
|
|
178
|
+
"ticket": card.get("ticket", ""),
|
|
179
|
+
"scope": card.get("scope", ""),
|
|
180
|
+
"allow_protected": card.get("allow_protected", []),
|
|
181
|
+
"lock_exclusions": card.get("lock_exclusions", []),
|
|
182
|
+
"cancel_reason": card.get("cancel_reason", ""),
|
|
183
|
+
"evidence": card.get("evidence", ""),
|
|
184
|
+
"verify": card.get("verify", ""),
|
|
185
|
+
"locks": card.get("locks", []),
|
|
186
|
+
"depends": card.get("depends", []),
|
|
187
|
+
"reviewers": card.get("reviewers", []),
|
|
188
|
+
"updated": card.get("updated", ""),
|
|
189
|
+
"created": card.get("created", ""),
|
|
190
|
+
"a2a_state": card.get("a2a_state", ""),
|
|
191
|
+
"approvals": card.get("approvals", []),
|
|
192
|
+
"cost": card.get("cost", {}),
|
|
193
|
+
"pr": slim_pr(card.get("pr")),
|
|
194
|
+
"lease": _slim_lease(card.get("lease")),
|
|
195
|
+
"notes_count": len(notes),
|
|
196
|
+
"notes": notes[-NOTE_TAIL:],
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def agent_reputation(board):
|
|
201
|
+
"""Use the optional public scorer without making it a UI startup dependency."""
|
|
202
|
+
try:
|
|
203
|
+
from raidlock.reputation import reputation
|
|
204
|
+
except ImportError:
|
|
205
|
+
return {}
|
|
206
|
+
try:
|
|
207
|
+
result = reputation(board, by="agent")
|
|
208
|
+
return {row["key"]: row for row in result.get("rows", [])}
|
|
209
|
+
except (RaidlockError, KeyError, TypeError, ValueError):
|
|
210
|
+
return {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def model_reputation(board, workspace, names):
|
|
214
|
+
try:
|
|
215
|
+
from raidlock.reputation import reputation
|
|
216
|
+
except ImportError:
|
|
217
|
+
return []
|
|
218
|
+
try:
|
|
219
|
+
result = reputation(board, by="model")
|
|
220
|
+
return [
|
|
221
|
+
{**row, "workspace": workspace, "label": names.get(row["key"], row["key"])}
|
|
222
|
+
for row in result.get("rows", [])
|
|
223
|
+
]
|
|
224
|
+
except (RaidlockError, KeyError, TypeError, ValueError):
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def score_rows(cards, agents):
|
|
229
|
+
rows = []
|
|
230
|
+
for card in cards:
|
|
231
|
+
if card.get("lane") == "cancelled":
|
|
232
|
+
continue
|
|
233
|
+
owner = card.get("owner", "")
|
|
234
|
+
if not owner:
|
|
235
|
+
continue
|
|
236
|
+
agent = agents.get(owner, {})
|
|
237
|
+
verdicts = [r.get("verdict") for r in card.get("reviews", []) if r.get("verdict") in ("approve", "changes")]
|
|
238
|
+
costs = card.get("costs", [])
|
|
239
|
+
provider = agent.get("provider", "")
|
|
240
|
+
if provider in ("", "local"):
|
|
241
|
+
provider = next((r.get("provider") for r in reversed(costs) if r.get("provider")), provider)
|
|
242
|
+
rows.append(
|
|
243
|
+
{
|
|
244
|
+
"provider": provider or "unknown",
|
|
245
|
+
"model": agent.get("model", "") or "unknown",
|
|
246
|
+
"done": card.get("lane") == "done" and not card.get("evidence"),
|
|
247
|
+
"approve": verdicts.count("approve"),
|
|
248
|
+
"changes": verdicts.count("changes"),
|
|
249
|
+
"usd": round(sum(r.get("usd") or 0 for r in costs), 6),
|
|
250
|
+
"usd_equiv": round(sum(r.get("usd_equiv") or 0 for r in costs), 6),
|
|
251
|
+
"tokens": sum(r.get("tokens", 0) for r in costs),
|
|
252
|
+
"duration_ms": sum(r.get("duration_ms", 0) for r in costs),
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return rows
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def scorecard(rows, names):
|
|
259
|
+
out = {}
|
|
260
|
+
for r in rows:
|
|
261
|
+
k = (r["provider"], r["model"])
|
|
262
|
+
g = out.setdefault(
|
|
263
|
+
k,
|
|
264
|
+
{
|
|
265
|
+
"provider": r["provider"],
|
|
266
|
+
"model": r["model"],
|
|
267
|
+
"label": names.get(r["model"], r["model"]),
|
|
268
|
+
"quests": 0,
|
|
269
|
+
"done": 0,
|
|
270
|
+
"approve": 0,
|
|
271
|
+
"changes": 0,
|
|
272
|
+
"usd": 0.0,
|
|
273
|
+
"usd_equiv": 0.0,
|
|
274
|
+
"tokens": 0,
|
|
275
|
+
"duration_ms": 0,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
g["quests"] += 1
|
|
279
|
+
g["done"] += 1 if r["done"] else 0
|
|
280
|
+
for f in ("approve", "changes", "tokens", "duration_ms"):
|
|
281
|
+
g[f] += r[f]
|
|
282
|
+
for f in ("usd", "usd_equiv"):
|
|
283
|
+
g[f] = round(g[f] + r[f], 6)
|
|
284
|
+
for g in out.values():
|
|
285
|
+
reviewed = g["approve"] + g["changes"]
|
|
286
|
+
g["pass_rate"] = round(g["approve"] / reviewed, 4) if reviewed else None
|
|
287
|
+
return sorted(out.values(), key=lambda g: (-g["done"], -g["quests"], g["provider"], g["model"]))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def treasury(board, names, agents, everywhere=False):
|
|
291
|
+
out = {}
|
|
292
|
+
billing = board.spend(by="billing", everywhere=everywhere)
|
|
293
|
+
out["total"] = billing["total"]
|
|
294
|
+
modes = {r["key"]: r for r in billing["rows"]}
|
|
295
|
+
zero = {"usd": 0.0, "usd_equiv": 0.0, "tokens": 0, "runs": 0}
|
|
296
|
+
out["billing"] = {m: {k: (modes.get(m) or zero).get(k, 0) for k in zero} for m in ("api", "plan", "unknown")}
|
|
297
|
+
out["api_equivalent_usd"] = round(out["billing"]["plan"]["usd_equiv"] + out["billing"]["unknown"]["usd_equiv"], 6)
|
|
298
|
+
for by in SPEND_BY:
|
|
299
|
+
rows = board.spend(by=by, everywhere=everywhere)["rows"]
|
|
300
|
+
for r in rows:
|
|
301
|
+
r["key"] = r["key"] if r["key"] not in (None, "") else "unknown"
|
|
302
|
+
r["label"] = names.get(r["key"], r["key"]) if by == "model" else r["key"]
|
|
303
|
+
if by == "agent":
|
|
304
|
+
r["billing"] = (agents.get(r["key"]) or {}).get("billing", "unknown")
|
|
305
|
+
out["by_" + by] = sorted(rows, key=lambda r: r["key"]) if by == "day" else rows
|
|
306
|
+
return out
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def needs_count(payload):
|
|
310
|
+
items = [
|
|
311
|
+
x for x in (payload.get("approvals") or {}).get("items", []) if x.get("kind") in ("close", "apply", "duel")
|
|
312
|
+
]
|
|
313
|
+
seen = {x.get("card") for x in items if x.get("card")}
|
|
314
|
+
extra = {c["id"] for c in payload["cards"] if c["lane"] in ("blocked", "review")} - seen
|
|
315
|
+
return len(items) + len(extra)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def summary_of(name, payload, chat):
|
|
319
|
+
titles = {c["id"]: c["title"] for c in payload["cards"]}
|
|
320
|
+
rows = []
|
|
321
|
+
for agent, a in payload["agents"].items():
|
|
322
|
+
rows.append(
|
|
323
|
+
{
|
|
324
|
+
"name": agent,
|
|
325
|
+
"status": a.get("status", "idle"),
|
|
326
|
+
"label": a.get("label", ""),
|
|
327
|
+
"card": a.get("card", ""),
|
|
328
|
+
"title": titles.get(a.get("card", ""), ""),
|
|
329
|
+
"display": a.get("display", agent),
|
|
330
|
+
"provider": a.get("provider", ""),
|
|
331
|
+
"runtime": a.get("runtime", ""),
|
|
332
|
+
"model": a.get("model", ""),
|
|
333
|
+
"billing": a.get("billing", "unknown"),
|
|
334
|
+
"heartbeat": a.get("heartbeat", ""),
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
claim = payload["claimable"]
|
|
338
|
+
return {
|
|
339
|
+
"name": name,
|
|
340
|
+
"missing": False,
|
|
341
|
+
"counts": payload["counts"],
|
|
342
|
+
"claimable": sum(1 for v in claim.values() if v["ok"]),
|
|
343
|
+
"open": len(claim),
|
|
344
|
+
"cost": payload["costs"]["workspace"],
|
|
345
|
+
"agents": rows,
|
|
346
|
+
"teams": len(payload["teams"]),
|
|
347
|
+
"last": payload["last"],
|
|
348
|
+
"repos": payload["repos"],
|
|
349
|
+
"today": payload["today"],
|
|
350
|
+
"review": needs_count(payload),
|
|
351
|
+
"chat": [dict(r, workspace=name) for r in chat],
|
|
352
|
+
"warnings": payload["warnings"],
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class Workspace:
|
|
357
|
+
def __init__(self, name, board_dir, home):
|
|
358
|
+
self.name = name
|
|
359
|
+
self.dir = board_dir
|
|
360
|
+
self.home = home
|
|
361
|
+
self.lock = threading.Lock()
|
|
362
|
+
self.key = None
|
|
363
|
+
self.payload = None
|
|
364
|
+
self._board = None
|
|
365
|
+
self._stamp = None
|
|
366
|
+
|
|
367
|
+
def board(self, fresh=False):
|
|
368
|
+
meta = os.path.join(self.dir, "board.json")
|
|
369
|
+
try:
|
|
370
|
+
stamp = os.stat(meta).st_mtime_ns
|
|
371
|
+
except OSError:
|
|
372
|
+
stamp = None
|
|
373
|
+
if fresh or self._board is None or stamp != self._stamp:
|
|
374
|
+
self._board = Board.open(self.dir, home=self.home)
|
|
375
|
+
self._stamp = stamp
|
|
376
|
+
return self._board
|
|
377
|
+
|
|
378
|
+
def key_now(self):
|
|
379
|
+
with self.lock:
|
|
380
|
+
return self._key()
|
|
381
|
+
|
|
382
|
+
def _key(self):
|
|
383
|
+
return etag_of(self.name, self.board().version())
|
|
384
|
+
|
|
385
|
+
def refresh(self):
|
|
386
|
+
with self.lock:
|
|
387
|
+
key = self._key()
|
|
388
|
+
if key != self.key:
|
|
389
|
+
self.payload = self._build(self.board())
|
|
390
|
+
self.key = key
|
|
391
|
+
return self.key, self.payload
|
|
392
|
+
|
|
393
|
+
def roots(self):
|
|
394
|
+
return _roots(self.board())
|
|
395
|
+
|
|
396
|
+
def _build(self, board):
|
|
397
|
+
snap = board.snapshot(transcript_limit=1, messages_limit=50)
|
|
398
|
+
names = core_models.display_map(self.home)
|
|
399
|
+
raw_cards = board.cards()
|
|
400
|
+
agents = {}
|
|
401
|
+
trust = agent_reputation(board)
|
|
402
|
+
for name, a in snap["agents"].items():
|
|
403
|
+
agents[name] = {k: v for k, v in a.items() if k not in ("session", "token")}
|
|
404
|
+
agents[name]["billing"] = a.get("billing") or "unknown"
|
|
405
|
+
agents[name]["reputation"] = trust.get(name, {}).get("score")
|
|
406
|
+
agents[name]["reputation_inputs"] = trust.get(name, {}).get("inputs", {})
|
|
407
|
+
try:
|
|
408
|
+
known = set(board._resolver().repos)
|
|
409
|
+
except RaidlockError:
|
|
410
|
+
known = set()
|
|
411
|
+
cards = [slim_card(c, known) for c in snap["cards"]]
|
|
412
|
+
spend = treasury(board, names, agents)
|
|
413
|
+
tail = [slim_record(r) for r in board.transcript.read(limit=60)]
|
|
414
|
+
chat = [r for r in tail if r["kind"] in CHAT_KINDS][-CHAT_TAIL:]
|
|
415
|
+
cursor = tail[-1]["seq"] if tail else 0
|
|
416
|
+
last = max([c.get("updated", "") for c in cards] + [r["ts"] for r in tail[-1:]] + [""])
|
|
417
|
+
payload = {
|
|
418
|
+
"workspace": self.name,
|
|
419
|
+
"lead": snap["lead"],
|
|
420
|
+
"now": snap["now"],
|
|
421
|
+
"warnings": snap["warnings"],
|
|
422
|
+
"version": snap.get("version"),
|
|
423
|
+
"settings": {
|
|
424
|
+
"require_review": snap["settings"].get("require_review"),
|
|
425
|
+
"ttl_minutes": snap["settings"].get("ttl_minutes"),
|
|
426
|
+
},
|
|
427
|
+
"cards": cards,
|
|
428
|
+
"teams": snap["teams"],
|
|
429
|
+
"agents": agents,
|
|
430
|
+
"claimable": snap["claimable"],
|
|
431
|
+
"review_queue": snap["review_queue"],
|
|
432
|
+
"inbox": snap["inbox"],
|
|
433
|
+
"messages": snap["messages"],
|
|
434
|
+
"costs": snap["costs"],
|
|
435
|
+
"model_names": names,
|
|
436
|
+
"scorecard": scorecard(score_rows(raw_cards, agents), names),
|
|
437
|
+
"model_reputation": model_reputation(board, self.name, names),
|
|
438
|
+
"treasury": spend,
|
|
439
|
+
"forecast": forecast_of(board),
|
|
440
|
+
"today": spend_today(spend),
|
|
441
|
+
"repos": repo_counts(cards),
|
|
442
|
+
"bosses_slain": party.slain_bosses(raw_cards),
|
|
443
|
+
"streak": party.streak(raw_cards),
|
|
444
|
+
"cursor": cursor,
|
|
445
|
+
"last": last,
|
|
446
|
+
"counts": {lane: sum(1 for c in cards if c["lane"] == lane) for lane in LANES},
|
|
447
|
+
"approvals": _approvals(board),
|
|
448
|
+
"lead_src": _lead_src(board),
|
|
449
|
+
"_score_rows": score_rows(raw_cards, agents),
|
|
450
|
+
"_chat": chat,
|
|
451
|
+
}
|
|
452
|
+
return _scrub(payload, _roots(board))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _approvals(board):
|
|
456
|
+
try:
|
|
457
|
+
from raidlock.approvals import collect, web_lead_src
|
|
458
|
+
except ImportError:
|
|
459
|
+
return {"items": [], "counts": {}, "total": 0}
|
|
460
|
+
try:
|
|
461
|
+
return collect(board, secret=web_lead_src(board))
|
|
462
|
+
except (RaidlockError, OSError, ValueError):
|
|
463
|
+
return {"items": [], "counts": {}, "total": 0, "error": "approvals unavailable"}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _lead_src(board):
|
|
467
|
+
try:
|
|
468
|
+
from raidlock.approvals import web_lead_src
|
|
469
|
+
|
|
470
|
+
return web_lead_src(board)
|
|
471
|
+
except (ImportError, RaidlockError, OSError, ValueError):
|
|
472
|
+
return '"$LEAD_SECRET_FILE"'
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def public(payload):
|
|
476
|
+
return redacted({k: v for k, v in payload.items() if not k.startswith("_")})
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class Catalog:
|
|
480
|
+
def __init__(self, single=None, home=None, extra=None):
|
|
481
|
+
self.single = os.path.realpath(single) if single else None
|
|
482
|
+
self.extra = os.path.realpath(extra) if extra else None
|
|
483
|
+
self.home = home or registry.home()
|
|
484
|
+
self.lock = threading.Lock()
|
|
485
|
+
self.cache = {}
|
|
486
|
+
self._limits_lock = threading.Lock()
|
|
487
|
+
self._limits_at = float("-inf")
|
|
488
|
+
self._limits = {"providers": []}
|
|
489
|
+
|
|
490
|
+
def provider_limits(self):
|
|
491
|
+
"""Sample quotas at most twice a minute, independently of board writes."""
|
|
492
|
+
with self._limits_lock:
|
|
493
|
+
now = time.monotonic()
|
|
494
|
+
if now - self._limits_at >= 30:
|
|
495
|
+
try:
|
|
496
|
+
from raidlock.limits import collect
|
|
497
|
+
except ImportError:
|
|
498
|
+
value = {"providers": []}
|
|
499
|
+
else:
|
|
500
|
+
try:
|
|
501
|
+
value = collect()
|
|
502
|
+
json.dumps(value, allow_nan=False)
|
|
503
|
+
except (OSError, ValueError, TypeError, OverflowError, AttributeError):
|
|
504
|
+
value = {"providers": []}
|
|
505
|
+
self._limits = redacted(value)
|
|
506
|
+
self._limits_at = now
|
|
507
|
+
return self._limits
|
|
508
|
+
|
|
509
|
+
def mode(self):
|
|
510
|
+
return "single" if self.single else "all"
|
|
511
|
+
|
|
512
|
+
def _registered(self):
|
|
513
|
+
try:
|
|
514
|
+
return [(w["name"], w["board"]) for w in registry.workspaces(Index(self.home))]
|
|
515
|
+
except Exception:
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
def entries(self):
|
|
519
|
+
if self.single:
|
|
520
|
+
return [(self._name(self.single), self.single)]
|
|
521
|
+
found = [(n, d) for n, d in self._registered() if os.path.isdir(d)]
|
|
522
|
+
if self.extra and os.path.isdir(self.extra) and all(os.path.realpath(d) != self.extra for _, d in found):
|
|
523
|
+
taken = {n for n, _ in found}
|
|
524
|
+
stem = self._name(self.extra)
|
|
525
|
+
name, i = stem, 2
|
|
526
|
+
while name in taken:
|
|
527
|
+
name, i = f"{stem}-{i}", i + 1
|
|
528
|
+
found = sorted(found + [(name, self.extra)])
|
|
529
|
+
return found
|
|
530
|
+
|
|
531
|
+
def _name(self, board_dir):
|
|
532
|
+
try:
|
|
533
|
+
return Board.open(board_dir, home=self.home).name
|
|
534
|
+
except RaidlockError:
|
|
535
|
+
return os.path.basename(os.path.dirname(board_dir)) or "board"
|
|
536
|
+
|
|
537
|
+
def workspaces(self):
|
|
538
|
+
out = []
|
|
539
|
+
with self.lock:
|
|
540
|
+
for name, board_dir in self.entries():
|
|
541
|
+
ws = self.cache.get(name)
|
|
542
|
+
if ws is None or ws.dir != board_dir:
|
|
543
|
+
ws = self.cache[name] = Workspace(name, board_dir, self.home)
|
|
544
|
+
out.append(ws)
|
|
545
|
+
return out
|
|
546
|
+
|
|
547
|
+
def get(self, name):
|
|
548
|
+
return next((w for w in self.workspaces() if w.name == name), None)
|
|
549
|
+
|
|
550
|
+
def overview_key(self):
|
|
551
|
+
parts = []
|
|
552
|
+
for ws in self.workspaces():
|
|
553
|
+
try:
|
|
554
|
+
parts.append((ws.name, ws.key_now()))
|
|
555
|
+
except (RaidlockError, OSError, ValueError):
|
|
556
|
+
parts.append((ws.name, None))
|
|
557
|
+
return etag_of("overview", self.mode(), parts, self.provider_limits())
|
|
558
|
+
|
|
559
|
+
def overview(self):
|
|
560
|
+
projects, srows, names, agents, first = [], [], {}, {}, None
|
|
561
|
+
reputations, forecasts = [], []
|
|
562
|
+
spaces = self.workspaces()
|
|
563
|
+
|
|
564
|
+
def load(ws):
|
|
565
|
+
try:
|
|
566
|
+
with db.released():
|
|
567
|
+
return ws.refresh()[1]
|
|
568
|
+
except (RaidlockError, OSError, ValueError) as e:
|
|
569
|
+
return e
|
|
570
|
+
|
|
571
|
+
with ThreadPoolExecutor(max_workers=min(8, max(1, len(spaces)))) as pool:
|
|
572
|
+
results = list(pool.map(load, spaces))
|
|
573
|
+
for ws, payload in zip(spaces, results, strict=True):
|
|
574
|
+
if isinstance(payload, Exception):
|
|
575
|
+
e = payload
|
|
576
|
+
projects.append(
|
|
577
|
+
{
|
|
578
|
+
"name": ws.name,
|
|
579
|
+
"missing": True,
|
|
580
|
+
"error": str(e).split(":")[0][:80],
|
|
581
|
+
"counts": {},
|
|
582
|
+
"agents": [],
|
|
583
|
+
"chat": [],
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
continue
|
|
587
|
+
first = first or ws
|
|
588
|
+
projects.append(summary_of(ws.name, payload, payload["_chat"]))
|
|
589
|
+
srows += payload["_score_rows"]
|
|
590
|
+
names.update(payload["model_names"])
|
|
591
|
+
agents.update(payload["agents"])
|
|
592
|
+
reputations.extend(payload.get("model_reputation", []))
|
|
593
|
+
if payload.get("forecast"):
|
|
594
|
+
forecasts.append(payload["forecast"])
|
|
595
|
+
spend = None
|
|
596
|
+
if first is not None:
|
|
597
|
+
with first.lock:
|
|
598
|
+
board = first.board()
|
|
599
|
+
if self.single:
|
|
600
|
+
spend = treasury(board, names, agents)
|
|
601
|
+
else:
|
|
602
|
+
spend = _scrub(treasury(board, names, agents, everywhere=True), [(os.path.expanduser("~"), "~")])
|
|
603
|
+
return {
|
|
604
|
+
"mode": self.mode(),
|
|
605
|
+
"projects": projects,
|
|
606
|
+
"treasury": spend,
|
|
607
|
+
"forecast": _combined(forecasts),
|
|
608
|
+
"scorecard": scorecard(srows, names),
|
|
609
|
+
"model_names": names,
|
|
610
|
+
"limits": self.provider_limits(),
|
|
611
|
+
"model_reputation": reputations,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def view_clock(kind):
|
|
616
|
+
return int(time.time() // 30) if kind in CLOCKED else 0
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def view(ws, kind, query):
|
|
620
|
+
import importlib
|
|
621
|
+
|
|
622
|
+
module = importlib.import_module(VIEWS[kind])
|
|
623
|
+
with ws.lock:
|
|
624
|
+
board = ws.board()
|
|
625
|
+
return _scrub(module.payload(board, query), _roots(board))
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def forecast_of(board):
|
|
629
|
+
try:
|
|
630
|
+
from raidlock.treasury import forecast
|
|
631
|
+
except ImportError:
|
|
632
|
+
return None
|
|
633
|
+
try:
|
|
634
|
+
return forecast(board)
|
|
635
|
+
except (RaidlockError, KeyError, TypeError, ValueError):
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _combined(forecasts):
|
|
640
|
+
if not forecasts:
|
|
641
|
+
return None
|
|
642
|
+
from raidlock.treasury import combine
|
|
643
|
+
|
|
644
|
+
return combine(forecasts)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def transcript_page(ws, since, limit, task):
|
|
648
|
+
with ws.lock:
|
|
649
|
+
return _transcript_page(ws.board(), since, limit, task)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _transcript_page(board, since, limit, task):
|
|
653
|
+
if since:
|
|
654
|
+
rows = board.transcript.read(task=task or None, since=since, limit=limit)
|
|
655
|
+
else:
|
|
656
|
+
rows = board.transcript.read(task=task or None, limit=limit)
|
|
657
|
+
recs = [slim_record(r) for r in rows]
|
|
658
|
+
cursor = recs[-1]["seq"] if recs else since
|
|
659
|
+
if not since and not recs:
|
|
660
|
+
cursor = 0
|
|
661
|
+
return _scrub({"records": recs, "cursor": cursor, "more": bool(since) and len(recs) >= limit}, _roots(board))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def card_detail(ws, card_id):
|
|
665
|
+
with ws.lock:
|
|
666
|
+
return _card_detail(ws.board(), card_id)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _card_detail(board, card_id):
|
|
670
|
+
card = board.card_view(board.card(card_id))
|
|
671
|
+
for k in ("fence", "verify_cmd"):
|
|
672
|
+
card.pop(k, None)
|
|
673
|
+
card["lease"] = _slim_lease(card.get("lease"))
|
|
674
|
+
card["claim"] = board.claimable().get(card_id)
|
|
675
|
+
card["log"] = [slim_record(r) for r in board.transcript.read(task=card_id, limit=200)]
|
|
676
|
+
return _scrub(card, _roots(board))
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def redacted(value):
|
|
680
|
+
if isinstance(value, str):
|
|
681
|
+
value = re.sub(r"(?i)\bRAIDLOCK_CAP\s*[:=]\s*[^\s'\",;]+", "RAIDLOCK_CAP=[REDACTED]", value)
|
|
682
|
+
return redact(value)
|
|
683
|
+
if isinstance(value, list):
|
|
684
|
+
return [redacted(v) for v in value]
|
|
685
|
+
if isinstance(value, dict):
|
|
686
|
+
return {k: redacted(v) for k, v in value.items() if k.lower() not in HIDDEN}
|
|
687
|
+
return value
|