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/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)
|