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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. 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