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/bench.py ADDED
@@ -0,0 +1,286 @@
1
+ """Offline overhead measurements using disposable board state and fresh hook processes."""
2
+
3
+ import argparse
4
+ import importlib
5
+ import json
6
+ import math
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+
15
+ CHARS_PER_TOKEN = 4
16
+
17
+
18
+ def size(text):
19
+ return {
20
+ "bytes": len(text.encode("utf-8")),
21
+ "chars": len(text),
22
+ "estimated_tokens": math.ceil(len(text) / CHARS_PER_TOKEN),
23
+ }
24
+
25
+
26
+ def percentile(values, fraction):
27
+ return sorted(values)[max(0, math.ceil(len(values) * fraction) - 1)]
28
+
29
+
30
+ def listing(path):
31
+ text = path.read_text(encoding="utf-8").split("\n---", 1)[0]
32
+ fields = dict(line.split(": ", 1) for line in text.splitlines() if ": " in line)
33
+ return fields["name"] + ": " + fields["description"]
34
+
35
+
36
+ def check_budget(metrics, budget):
37
+ if not isinstance(budget, dict) or not budget:
38
+ raise ValueError("budget must be a nonempty object of metric names and numeric upper limits")
39
+ failures = []
40
+ for name, limit in budget.items():
41
+ if isinstance(limit, bool) or not isinstance(limit, (int, float)) or not math.isfinite(limit) or limit < 0:
42
+ raise ValueError(f"invalid budget for {name}")
43
+ if name not in metrics:
44
+ raise ValueError(f"unknown budget metric: {name}")
45
+ if metrics[name] > limit:
46
+ failures.append({"metric": name, "actual": metrics[name], "budget": limit})
47
+ return failures
48
+
49
+
50
+ def summary(report):
51
+ m = report["metrics"]
52
+ return (
53
+ f"Idle overhead: {m['idle.estimated_tokens']} estimated tokens per session "
54
+ f"(skill and agent listings {m['idle.estimated_tokens'] - m['idle.hooks.estimated_tokens']}, "
55
+ f"idle hook output {m['idle.hooks.estimated_tokens']}); with MCP tool schemas: "
56
+ f"{m['idle.with_mcp.estimated_tokens']}. Tokenizer: {report['tokenizer']}, "
57
+ f"{report['chars_per_token']} characters per estimated token."
58
+ )
59
+
60
+
61
+ def markdown(report):
62
+ lines = [summary(report), "", "| Metric | Value |", "| --- | ---: |"]
63
+ lines.extend(f"| {name} | {value} |" for name, value in sorted(report["metrics"].items()))
64
+ lines.append(f"\nBudget: {'FAIL' if report['failures'] else 'PASS'}")
65
+ return "\n".join(lines)
66
+
67
+
68
+ def _invoke(args, env, cwd, payload=None):
69
+ start = time.perf_counter_ns()
70
+ result = subprocess.run(
71
+ [sys.executable, "-m", "raidlock", *args],
72
+ input=payload,
73
+ text=True,
74
+ encoding="utf-8",
75
+ capture_output=True,
76
+ cwd=cwd,
77
+ env=env,
78
+ timeout=30,
79
+ check=True,
80
+ )
81
+ elapsed = (time.perf_counter_ns() - start) / 1_000_000
82
+ if result.stderr:
83
+ raise RuntimeError(result.stderr)
84
+ return result.stdout, elapsed
85
+
86
+
87
+ def _fixture(root, runs):
88
+ from . import hooks
89
+ from .core import Board, db
90
+ from .core.identity import token_path
91
+ from .mcp.server import Server, compact
92
+
93
+ root = Path(root)
94
+ workspace = root / "workspace"
95
+ workspace.mkdir()
96
+ board, secret = Board.init(workspace, lead="bench-lead")
97
+ lead = board.caller(agent=board.lead, lead_secret=secret)
98
+ board.new_card(lead, "Benchmark card", card_id="bench-card")
99
+ texts, metrics = {}, {}
100
+ env = dict(os.environ)
101
+ base = {"cwd": str(workspace), "session_id": "bench-session", "tool_input": {}}
102
+ cases = {f"{event}.allow": dict(base) for event in hooks.HANDLERS}
103
+ cases["pre-write.allow"] = {**base, "tool_name": "Write", "tool_input": {"file_path": str(workspace / "free.txt")}}
104
+ cases["pre-bash.allow"] = {**base, "tool_name": "Bash", "tool_input": {"command": "printf hello"}}
105
+ cases["session-start.unbound"] = {**base, "session_id": "bench-other-session", "source": "startup"}
106
+ cases["pre-write.deny"] = {
107
+ **base,
108
+ "tool_name": "Write",
109
+ "tool_input": {"file_path": str(workspace / ".board/board.json")},
110
+ }
111
+ cases["pre-bash.deny"] = {**base, "tool_name": "Bash", "tool_input": {"command": "echo x > .board/board.json"}}
112
+ cases["pre-read.deny"] = {
113
+ **base,
114
+ "agent_id": "bench-worker",
115
+ "tool_name": "Read",
116
+ "tool_input": {"file_path": token_path(board.home, board.uid, board.lead)},
117
+ }
118
+ for name, payload in cases.items():
119
+ samples = []
120
+ outputs = []
121
+ _invoke(["hook", name.split(".")[0]], env, workspace, json.dumps(payload))
122
+ for _ in range(runs):
123
+ out, elapsed = _invoke(["hook", name.split(".")[0]], env, workspace, json.dumps(payload))
124
+ if name.endswith(".deny"):
125
+ decision = json.loads(out)["hookSpecificOutput"]["permissionDecision"]
126
+ if decision != "deny" or "hook error" in out:
127
+ raise RuntimeError(f"invalid deny fixture: {name}: {out}")
128
+ elif out:
129
+ raise RuntimeError(f"allow fixture was not silent: {name}: {out}")
130
+ samples.append(elapsed)
131
+ outputs.append(out)
132
+ texts[f"hook.{name}"] = max(outputs, key=len)
133
+ metrics[f"hook.{name}.p50_ms"] = round(percentile(samples, 0.5), 3)
134
+ metrics[f"hook.{name}.p95_ms"] = round(percentile(samples, 0.95), 3)
135
+ commands = {
136
+ "show": ["show"],
137
+ "show_card": ["show", "bench-card"],
138
+ "who": ["who"],
139
+ "claimable": ["claimable"],
140
+ "brief": ["brief", "bench-card"],
141
+ "tokens": ["tokens"],
142
+ }
143
+ for name, args in commands.items():
144
+ out, _ = _invoke([*args, "--json", "--board", str(workspace)], env, workspace)
145
+ data = json.loads(out)
146
+ if not data.get("ok"):
147
+ raise RuntimeError(f"CLI fixture failed: {name}")
148
+ texts[f"cli.{name}"] = out
149
+ joined = board.join(lead, agent="bench-worker", runtime="claude-code")
150
+ worker_args = ["--agent", "bench-worker", "--token", joined["token"]]
151
+ for name, args in (
152
+ ("claim", ["claim", "bench-card"]),
153
+ ("note", ["note", "bench-card", "Benchmark evidence"]),
154
+ ("move", ["move", "bench-card", "review", "--note", "Benchmark complete"]),
155
+ ):
156
+ out, _ = _invoke([*args, *worker_args, "--json", "--board", str(workspace)], env, workspace)
157
+ data = json.loads(out)
158
+ if not data.get("ok"):
159
+ raise RuntimeError(f"CLI fixture failed: {name}")
160
+ texts[f"cli.{name}"] = out
161
+ if name == "claim":
162
+ worker_args.extend(["--fence", str(data["fence"])])
163
+ brief = board.brief("bench-card")
164
+ texts["brief"] = brief["text"]
165
+ metrics["brief.cap_tokens"] = brief["max_tokens"]
166
+ metrics["brief.cap_ratio"] = brief["tokens"] / brief["max_tokens"]
167
+ server = Server()
168
+ server.handle({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": "2025-11-25"}})
169
+ texts["mcp.tools_list"] = compact(server.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})) + "\n"
170
+ db.close_all()
171
+ return {"texts": texts, "metrics": metrics}
172
+
173
+
174
+ def measure(runs=20, plugin_root=None, tokenizer="auto"):
175
+ if runs < 1:
176
+ raise ValueError("runs must be positive")
177
+ root = Path(plugin_root) if plugin_root else Path(__file__).resolve().parents[2]
178
+ texts = {}
179
+ for kind, pattern in (("skill", "skills/*/SKILL.md"), ("agent", "agents/*.md")):
180
+ paths = sorted(root.glob(pattern))
181
+ if not paths:
182
+ raise ValueError(f"no {kind} listings under {root}; pass --plugin-root with a raidlock checkout")
183
+ texts[f"listing.{kind}"] = "\n".join(listing(path) for path in paths)
184
+ with tempfile.TemporaryDirectory(prefix="raid-bench-") as temporary:
185
+ scratch = os.path.realpath(temporary)
186
+ env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "WINDIR", "TMPDIR") if key in os.environ}
187
+ env.update(
188
+ {
189
+ "PYTHONPATH": str(Path(__file__).resolve().parents[1]),
190
+ "PYTHONUTF8": "1",
191
+ "RAIDLOCK_HOME": str(Path(scratch) / "state"),
192
+ "CLAUDE_PLUGIN_DATA": str(Path(scratch) / "plugin"),
193
+ "CLAUDE_CODE_SESSION_ID": "bench-session",
194
+ }
195
+ )
196
+ code = (
197
+ "import json,sys; from raidlock.bench import _fixture; "
198
+ "print(json.dumps(_fixture(sys.argv[1], int(sys.argv[2]))))"
199
+ )
200
+ result = subprocess.run(
201
+ [sys.executable, "-c", code, scratch, str(runs)],
202
+ env=env,
203
+ cwd=scratch,
204
+ capture_output=True,
205
+ text=True,
206
+ encoding="utf-8",
207
+ timeout=60 + runs * 30,
208
+ check=True,
209
+ )
210
+ fixture = json.loads(result.stdout)
211
+ texts.update(fixture["texts"])
212
+ metrics = fixture["metrics"]
213
+ encoding = None
214
+ if tokenizer == "auto":
215
+ from unittest.mock import patch
216
+
217
+ try:
218
+ tiktoken = importlib.import_module("tiktoken")
219
+
220
+ with patch("tiktoken.load.read_file", side_effect=OSError("benchmark is offline")):
221
+ encoding = tiktoken.get_encoding("cl100k_base")
222
+ except (ImportError, OSError):
223
+ pass
224
+ for name, text in texts.items():
225
+ metrics.update({f"{name}.{unit}": value for unit, value in size(text).items()})
226
+ if encoding:
227
+ metrics[f"{name}.tiktoken_tokens"] = len(encoding.encode(text, disallowed_special=()))
228
+ listings = sum(metrics[f"listing.{kind}.estimated_tokens"] for kind in ("skill", "agent"))
229
+ idle_hooks = sum(
230
+ metrics[f"{name}.estimated_tokens"] for name in texts if name.startswith("hook.") and not name.endswith(".deny")
231
+ )
232
+ metrics["idle.hooks.estimated_tokens"] = idle_hooks
233
+ metrics["idle.estimated_tokens"] = listings + idle_hooks
234
+ metrics["idle.with_mcp.estimated_tokens"] = listings + idle_hooks + metrics["mcp.tools_list.estimated_tokens"]
235
+ return {
236
+ "schema_version": 1,
237
+ "runs": runs,
238
+ "chars_per_token": CHARS_PER_TOKEN,
239
+ "tokenizer": "cl100k_base" if encoding else "estimate",
240
+ "python": platform.python_version(),
241
+ "platform": platform.system(),
242
+ "metrics": metrics,
243
+ "failures": [],
244
+ }
245
+
246
+
247
+ def setup(parser):
248
+ parser.add_argument("--runs", type=int, default=20)
249
+ parser.add_argument("--budget", type=Path)
250
+ parser.add_argument("--plugin-root", type=Path)
251
+ parser.add_argument("--tokenizer", choices=("estimate", "auto"), default="auto")
252
+
253
+
254
+ def execute(args):
255
+ report = measure(args.runs, args.plugin_root, args.tokenizer)
256
+ if args.budget:
257
+ report["failures"] = check_budget(report["metrics"], json.loads(args.budget.read_text(encoding="utf-8")))
258
+ return report
259
+
260
+
261
+ def register_cli(add_command):
262
+ def handler(ctx, args):
263
+ from .core.errors import Precondition
264
+
265
+ try:
266
+ report = execute(args)
267
+ except (ValueError, OSError) as exc:
268
+ raise Precondition(str(exc)) from exc
269
+ return {**report, "_exit": 1 if report["failures"] else 0}, json.dumps(report, indent=2) + "\n\n" + markdown(
270
+ report
271
+ )
272
+
273
+ add_command("bench", "measure idle and per-action overhead offline", setup, handler)
274
+
275
+
276
+ def main(argv=None):
277
+ parser = argparse.ArgumentParser(description=__doc__)
278
+ setup(parser)
279
+ args = parser.parse_args(argv)
280
+ try:
281
+ report = execute(args)
282
+ except (ValueError, OSError) as exc:
283
+ parser.error(str(exc))
284
+ print(json.dumps(report, indent=2))
285
+ print(markdown(report), file=sys.stderr)
286
+ return 1 if report["failures"] else 0
@@ -0,0 +1,186 @@
1
+ """Native host bridges: Agent Teams teammates and Codex sessions under raidlock leases."""
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+
9
+ from ..core import identity, leases, store
10
+ from ..core.errors import Precondition
11
+ from ..core.lock import FileLock
12
+
13
+ CONFIG = "bridge.json"
14
+ NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
15
+ STALE_SECONDS = 12 * 3600
16
+ PATCH_LINE = re.compile(r"^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+?)\s*$", re.M)
17
+
18
+
19
+ def config_path(board):
20
+ return os.path.join(board.dir, CONFIG)
21
+
22
+
23
+ def load(board):
24
+ data = store.read_json(config_path(board)) or {}
25
+ pool = [n for n in data.get("pool", []) if isinstance(n, str) and NAME.match(n)]
26
+ return {"pool": pool}
27
+
28
+
29
+ def validate_pool(board, names):
30
+ agents = board.agents()
31
+ out = []
32
+ for name in names:
33
+ name = name.strip()
34
+ if not name:
35
+ continue
36
+ if not NAME.match(name):
37
+ raise Precondition(f"invalid agent name {name!r}")
38
+ if name == board.lead:
39
+ raise Precondition("the lead cannot be in the auto-join pool")
40
+ if name not in agents:
41
+ raise Precondition(f"{name} has not joined this board", hint=f"raidlock join --agent {name} --team <team>")
42
+ if name not in out:
43
+ out.append(name)
44
+ return out
45
+
46
+
47
+ def set_pool(board, caller, names):
48
+ """Lead-only. Record which joined workers new unbound host sessions may take on start."""
49
+ pool = validate_pool(board, names)
50
+ with board.lead_write(caller, "configure the auto-join pool"):
51
+ store.write_json(config_path(board), {"pool": pool})
52
+ return load(board)
53
+
54
+
55
+ def patch_paths(text):
56
+ """Every file an apply_patch body adds, updates, deletes or moves to."""
57
+ if not isinstance(text, str):
58
+ return []
59
+ return [m.strip() for m in PATCH_LINE.findall(text) if m.strip()]
60
+
61
+
62
+ def _pool_dir(state, uid):
63
+ return os.path.join(state, "pool", re.sub(r"[^A-Za-z0-9_.-]", "_", uid))
64
+
65
+
66
+ def _read(path):
67
+ try:
68
+ with open(path, encoding="utf-8") as f:
69
+ data = json.load(f)
70
+ except (OSError, ValueError):
71
+ return {}
72
+ return data if isinstance(data, dict) else {}
73
+
74
+
75
+ def pool_lock(state, uid):
76
+ return FileLock(os.path.join(_pool_dir(state, uid), ".lock"))
77
+
78
+
79
+ def allocated(state, uid, session):
80
+ with pool_lock(state, uid):
81
+ return _allocated(state, uid, session)
82
+
83
+
84
+ def _allocated(state, uid, session):
85
+ folder = _pool_dir(state, uid)
86
+ try:
87
+ names = sorted(os.listdir(folder))
88
+ except OSError:
89
+ return ""
90
+ for entry in names:
91
+ if entry.endswith(".json") and _read(os.path.join(folder, entry)).get("session") == session:
92
+ return entry[:-5]
93
+ return ""
94
+
95
+
96
+ def _stale(board, path, name, now):
97
+ try:
98
+ age = now - os.path.getmtime(path)
99
+ except OSError:
100
+ return True
101
+ if age < STALE_SECONDS:
102
+ return False
103
+ return not live_worker(board, name)
104
+
105
+
106
+ def live_worker(board, name):
107
+ return any(v.get("agent") == name and leases.is_live(v) for v in board.leases().values())
108
+
109
+
110
+ def allocate(board, state, session, now=None):
111
+ with pool_lock(state, board.uid), board.lock():
112
+ return _allocate(board, state, session, now)
113
+
114
+
115
+ def _allocate(board, state, session, now=None):
116
+ """Give `session` a pool identity it alone holds. Returns the name or ""."""
117
+ if not session:
118
+ return ""
119
+ now = time.time() if now is None else now
120
+ pool = load(board)["pool"]
121
+ held = allocated(state, board.uid, session)
122
+ if held in pool:
123
+ return held
124
+ if held:
125
+ free(state, board.uid, session)
126
+ folder = _pool_dir(state, board.uid)
127
+ os.makedirs(folder, mode=0o700, exist_ok=True)
128
+ os.chmod(folder, 0o700)
129
+ for name in pool:
130
+ if live_worker(board, name):
131
+ continue
132
+ path = os.path.join(folder, name + ".json")
133
+ if os.path.exists(path):
134
+ if not _stale(board, path, name, now):
135
+ continue
136
+ try:
137
+ os.unlink(path)
138
+ except OSError:
139
+ continue
140
+ try:
141
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
142
+ except OSError:
143
+ continue
144
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
145
+ identity.issue_token(board.home, board.uid, name)
146
+ json.dump({"session": session, "at": now}, f, sort_keys=True)
147
+ return name
148
+ return ""
149
+
150
+
151
+ def free(state, uid, session):
152
+ with pool_lock(state, uid):
153
+ return _free(state, uid, session)
154
+
155
+
156
+ def _free(state, uid, session):
157
+ name = allocated(state, uid, session)
158
+ if name:
159
+ with contextlib.suppress(OSError):
160
+ os.unlink(os.path.join(_pool_dir(state, uid), name + ".json"))
161
+ return name
162
+
163
+
164
+ def _setup(parser):
165
+ parser.add_argument(
166
+ "action",
167
+ nargs="?",
168
+ default="show",
169
+ choices=("show", "pool"),
170
+ help="show the bridge config, or set the auto-join pool",
171
+ )
172
+ parser.add_argument("names", nargs="?", default=None, help="comma-separated joined worker names; empty clears")
173
+
174
+
175
+ def _handler(ctx, args):
176
+ board = ctx.board
177
+ if args.action == "show":
178
+ cfg = load(board)
179
+ return cfg, "auto-join pool: " + (", ".join(cfg["pool"]) or "(empty)")
180
+ names = (args.names or "").split(",")
181
+ cfg = set_pool(board, ctx.caller(), names)
182
+ return cfg, "auto-join pool: " + (", ".join(cfg["pool"]) or "(empty)")
183
+
184
+
185
+ def register_cli(add_command):
186
+ add_command("bridge", "native host bridge: show, or set the auto-join pool (lead)", _setup, _handler, mutating=True)
raidlock/budgets.py ADDED
@@ -0,0 +1,178 @@
1
+ """Delegation budget checks, using UTC days and calendar months.
2
+
3
+ The core config API must accept budget.* keys before set_budget can persist
4
+ limits. Register the CLI with register_cli(raidlock.cli.add_command).
5
+ Checks inspect recorded usage; they do not reserve funds or stop active runs.
6
+ """
7
+
8
+ import datetime
9
+ import json
10
+ from decimal import Decimal, InvalidOperation
11
+
12
+ from .core import clock
13
+ from .core.errors import Precondition
14
+ from .core.model import card_id
15
+
16
+ DAILY = "budget.workspace.daily_usd"
17
+ MONTHLY = "budget.workspace.monthly_usd"
18
+ TOKENS = "budget.tokens_per_card"
19
+ COUNT_PLAN = "budget.count_plan_usage"
20
+
21
+
22
+ def _number(value, key, integer=False):
23
+ try:
24
+ if isinstance(value, bool):
25
+ raise ValueError
26
+ number = Decimal(str(value))
27
+ if not number.is_finite() or number < 0 or (integer and number != number.to_integral_value()):
28
+ raise ValueError
29
+ result = int(number) if integer else float(number)
30
+ if not integer and not Decimal(str(result)).is_finite():
31
+ raise ValueError
32
+ return result
33
+ except (InvalidOperation, ValueError, TypeError, OverflowError):
34
+ raise Precondition(f"{key} must be a finite nonnegative {'integer' if integer else 'number'}") from None
35
+
36
+
37
+ def _boolean(value):
38
+ if isinstance(value, bool):
39
+ return value
40
+ if isinstance(value, str) and value.lower() in ("true", "false"):
41
+ return value.lower() == "true"
42
+ raise Precondition(f"{COUNT_PLAN} must be true or false")
43
+
44
+
45
+ def _value(key, value):
46
+ if value is None:
47
+ return None
48
+ if key in (DAILY, MONTHLY):
49
+ return _number(value, key)
50
+ if key == TOKENS:
51
+ return _number(value, key, integer=True)
52
+ if key == COUNT_PLAN:
53
+ return _boolean(value)
54
+ if key.startswith("budget.card."):
55
+ card_id(key[len("budget.card.") :])
56
+ if not isinstance(value, dict) or not value or set(value) - {"usd", "tokens"}:
57
+ raise Precondition(f"{key} must be an object with usd and/or tokens")
58
+ return {
59
+ unit: None if cap is None else _number(cap, key + "." + unit, integer=unit == "tokens")
60
+ for unit, cap in value.items()
61
+ }
62
+ raise Precondition(f"unknown budget key {key}")
63
+
64
+
65
+ def set_budget(board, caller, key, value):
66
+ """Persist a validated cap through core's lead-only config API; null clears it."""
67
+ if key not in (DAILY, MONTHLY, TOKENS, COUNT_PLAN) and not key.startswith("budget.card."):
68
+ raise Precondition(f"unknown budget key {key}")
69
+ if key.startswith("budget.card."):
70
+ board.card(card_id(key[len("budget.card.") :]))
71
+ value = _value(key, value)
72
+ board.set_setting(caller, key, value)
73
+ return {"key": key, "value": value}
74
+
75
+
76
+ def _settings(board):
77
+ settings = board.get_setting()
78
+ return {key: _value(key, value) for key, value in settings.items() if key.startswith("budget.")}
79
+
80
+
81
+ def _remaining(cap, used):
82
+ if cap is None:
83
+ return None
84
+ return max(0, float(Decimal(str(cap)) - Decimal(str(used))))
85
+
86
+
87
+ def _workspace_usd(board, since, until, count_plan):
88
+ rows = board.spend(by="billing", since=clock.iso(since), until=clock.iso(until))["rows"]
89
+ return float(
90
+ sum(
91
+ (Decimal(str(row.get("usd_equiv" if row["key"] == "plan" and count_plan else "usd") or 0)) for row in rows),
92
+ Decimal(0),
93
+ )
94
+ )
95
+
96
+
97
+ def check_before_delegate(board, card):
98
+ """Return allowed, reasons, limits, used and remaining; None means uncapped.
99
+
100
+ Accept a card id or a card dict. All token usage counts, even when plan USD
101
+ equivalents are disabled. A card token cap overrides tokens_per_card.
102
+ Call before runner preparation, joining, claiming or cloning, for either role.
103
+ """
104
+ with board.lock():
105
+ card = board.card(card["id"] if isinstance(card, dict) else card)
106
+ settings = _settings(board)
107
+ count_plan = settings.get(COUNT_PLAN)
108
+ count_plan = True if count_plan is None else count_plan
109
+ caps = settings.get("budget.card." + card["id"]) or {}
110
+ limits = {
111
+ "card_usd": caps.get("usd"),
112
+ "card_tokens": caps.get("tokens", settings.get(TOKENS)),
113
+ "daily_usd": settings.get(DAILY),
114
+ "monthly_usd": settings.get(MONTHLY),
115
+ }
116
+ row = next((r for r in board.spend(by="card")["rows"] if r["key"] == card["id"]), {})
117
+ usd = Decimal(str(row.get("usd") or 0))
118
+ if count_plan:
119
+ usd += sum(
120
+ (Decimal(str(r.get("usd_equiv") or 0)) for r in card.get("costs", []) if r.get("billing") == "plan"),
121
+ Decimal(0),
122
+ )
123
+ now = clock.now()
124
+ day = now.replace(hour=0, minute=0, second=0, microsecond=0)
125
+ month = day.replace(day=1)
126
+ next_month = (month.replace(day=28) + datetime.timedelta(days=4)).replace(day=1)
127
+ used = {
128
+ "card_usd": float(usd),
129
+ "card_tokens": row.get("tokens") or 0,
130
+ "daily_usd": _workspace_usd(board, day, day + datetime.timedelta(days=1), count_plan),
131
+ "monthly_usd": _workspace_usd(board, month, next_month, count_plan),
132
+ }
133
+ remaining = {key: _remaining(cap, used[key]) for key, cap in limits.items()}
134
+ if remaining["card_tokens"] is not None:
135
+ remaining["card_tokens"] = max(0, limits["card_tokens"] - used["card_tokens"])
136
+ reasons = [f"{key} budget exhausted" for key, cap in limits.items() if cap is not None and used[key] >= cap]
137
+ return {
138
+ "card": card["id"],
139
+ "allowed": not reasons,
140
+ "reasons": reasons,
141
+ "limits": limits,
142
+ "used": used,
143
+ "remaining": remaining,
144
+ "count_plan_usage": count_plan,
145
+ }
146
+
147
+
148
+ def _setup(parser):
149
+ parser.add_argument("budget_action", nargs="?", default="show", choices=("set", "show"))
150
+ parser.add_argument("key", nargs="?", help="budget config key for set, optional card id for show")
151
+ parser.add_argument(
152
+ "value", nargs="?", help='JSON value, e.g. 10, true, or {"usd":10,"tokens":50000}; null clears a cap'
153
+ )
154
+
155
+
156
+ def _handler(ctx, args):
157
+ if args.budget_action == "set":
158
+ if args.key is None or args.value is None:
159
+ raise Precondition("budget set requires a config key and JSON value")
160
+ try:
161
+ value = json.loads(args.value)
162
+ except ValueError:
163
+ raise Precondition("budget value must be valid JSON") from None
164
+ result = set_budget(ctx.board, ctx.caller(), args.key, value)
165
+ elif args.value is not None:
166
+ raise Precondition("budget show accepts at most one card id")
167
+ elif args.key:
168
+ result = check_before_delegate(ctx.board, args.key)
169
+ else:
170
+ settings = _settings(ctx.board)
171
+ if settings.get(COUNT_PLAN) is None:
172
+ settings[COUNT_PLAN] = True
173
+ result = {"settings": settings}
174
+ return result, json.dumps(result, indent=2, sort_keys=True)
175
+
176
+
177
+ def register_cli(add_command):
178
+ add_command("budget", "set or show delegation budgets", _setup, _handler, mutating=True)