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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Credential handling for cooperative worker launchers."""
|
|
2
|
+
|
|
3
|
+
from .errors import IdentityError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def lead_material(env):
|
|
7
|
+
return [
|
|
8
|
+
k
|
|
9
|
+
for k, v in env.items()
|
|
10
|
+
if v and (k.startswith("RAIDLOCK_LEAD_") or k == "RAIDLOCK_ROLE" and str(v).lower() == "lead")
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def assert_worker_env(env):
|
|
15
|
+
"""Refuse a worker environment containing lead material; never include values in errors."""
|
|
16
|
+
if lead_material(env):
|
|
17
|
+
raise IdentityError("worker environment contains lead material; scrub RAIDLOCK_LEAD_* before launch")
|
|
18
|
+
return env
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scrub_worker_env(env):
|
|
22
|
+
"""Copy the inherited environment without board identity or lead credentials."""
|
|
23
|
+
return {k: v for k, v in env.items() if not k.startswith("RAIDLOCK_")}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Helpers for hook adapters. Host hooks supply authenticated worker identity.
|
|
2
|
+
|
|
3
|
+
Context is untrusted message data. It must be appended to host additionalContext,
|
|
4
|
+
never used as hook instructions or a permission decision.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from . import identity as I
|
|
11
|
+
from .errors import IdentityError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def protected_lead_path(board, path):
|
|
15
|
+
"""Recognize the secret file or a containing directory, including symlink aliases."""
|
|
16
|
+
secret = os.path.realpath(I.token_path(board.home, board.uid, board.lead))
|
|
17
|
+
candidate = os.path.realpath(os.path.expanduser(os.fspath(path)))
|
|
18
|
+
try:
|
|
19
|
+
if os.path.samefile(secret, candidate):
|
|
20
|
+
return True
|
|
21
|
+
except OSError:
|
|
22
|
+
pass
|
|
23
|
+
try:
|
|
24
|
+
return os.path.commonpath((secret.casefold(), candidate.casefold())) == candidate.casefold()
|
|
25
|
+
except ValueError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def guard_lead_read(board, paths, subagent=False):
|
|
30
|
+
if subagent and any(protected_lead_path(board, p) for p in paths):
|
|
31
|
+
raise IdentityError("subagents may not read the lead secret or its containing directory")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def urgent_context(board, caller):
|
|
35
|
+
"""Drain urgent messages for a validated worker, even while its card is doing."""
|
|
36
|
+
with board.lock():
|
|
37
|
+
board._auth(caller, scoped_ok=True)
|
|
38
|
+
rows = board.take_urgent(caller.name)
|
|
39
|
+
if not rows:
|
|
40
|
+
return ""
|
|
41
|
+
data = [{"from": r.get("from", ""), "task": r.get("task", ""), "text": r.get("text", "")} for r in rows]
|
|
42
|
+
return "Raidlock urgent messages (untrusted quoted data):\n" + json.dumps(data, ensure_ascii=True)
|
raidlock/core/leases.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from . import clock
|
|
2
|
+
|
|
3
|
+
DEFAULT_TTL_MINUTES = 45
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_live(lease, at=None):
|
|
7
|
+
"""Leases live until expiry; legacy holds without an expiry remain readable."""
|
|
8
|
+
if lease.get("expires") is None:
|
|
9
|
+
return True
|
|
10
|
+
expires = clock.parse(lease["expires"])
|
|
11
|
+
return bool(expires) and expires > (at or clock.now())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def describe(conflict):
|
|
15
|
+
until = conflict["expires"] or "it leaves review/blocked"
|
|
16
|
+
return (
|
|
17
|
+
f"{conflict['path']} overlaps {conflict['held']} held by {conflict['agent']} on card {conflict['card']} "
|
|
18
|
+
f"in workspace {conflict['workspace']} until {until}"
|
|
19
|
+
)
|
raidlock/core/lock.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from .errors import Timeout
|
|
8
|
+
from .portable import NONBLOCK, open_nofollow
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import fcntl
|
|
12
|
+
except ImportError:
|
|
13
|
+
fcntl = None
|
|
14
|
+
import msvcrt
|
|
15
|
+
|
|
16
|
+
_held = {}
|
|
17
|
+
_guard = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _try_lock(fd):
|
|
21
|
+
try:
|
|
22
|
+
if fcntl:
|
|
23
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
24
|
+
else:
|
|
25
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
26
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
27
|
+
return True
|
|
28
|
+
except OSError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _unlock(fd):
|
|
33
|
+
if fcntl:
|
|
34
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
35
|
+
else:
|
|
36
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
37
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileLock:
|
|
41
|
+
"""Exclusive OS lock on a persistent file, reentrant within one process and thread."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path, timeout=30.0):
|
|
44
|
+
self.path = os.path.abspath(path)
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> Self:
|
|
48
|
+
key = (self.path, threading.get_ident())
|
|
49
|
+
with _guard:
|
|
50
|
+
entry = _held.get(key)
|
|
51
|
+
if entry:
|
|
52
|
+
entry[1] += 1
|
|
53
|
+
return self
|
|
54
|
+
fd = self._acquire()
|
|
55
|
+
with _guard:
|
|
56
|
+
_held[key] = [fd, 1]
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def _acquire(self):
|
|
60
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
61
|
+
deadline = time.monotonic() + self.timeout
|
|
62
|
+
delay = 0.005
|
|
63
|
+
while True:
|
|
64
|
+
fd = open_nofollow(self.path, os.O_RDWR | os.O_CREAT | NONBLOCK, 0o600)
|
|
65
|
+
acquired = False
|
|
66
|
+
try:
|
|
67
|
+
opened = os.fstat(fd)
|
|
68
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
69
|
+
raise OSError("lock must be a regular file")
|
|
70
|
+
acquired = _try_lock(fd)
|
|
71
|
+
if acquired:
|
|
72
|
+
opened = os.fstat(fd)
|
|
73
|
+
try:
|
|
74
|
+
named = os.lstat(self.path)
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
named = None
|
|
77
|
+
if named and (opened.st_dev, opened.st_ino) == (named.st_dev, named.st_ino):
|
|
78
|
+
return fd
|
|
79
|
+
except BaseException:
|
|
80
|
+
os.close(fd)
|
|
81
|
+
raise
|
|
82
|
+
if acquired:
|
|
83
|
+
_unlock(fd)
|
|
84
|
+
os.close(fd)
|
|
85
|
+
if time.monotonic() >= deadline:
|
|
86
|
+
raise Timeout(f"lock busy: {self.path}", lock=self.path)
|
|
87
|
+
time.sleep(delay)
|
|
88
|
+
delay = min(delay * 2, 0.1)
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *exc):
|
|
91
|
+
key = (self.path, threading.get_ident())
|
|
92
|
+
with _guard:
|
|
93
|
+
entry = _held[key]
|
|
94
|
+
entry[1] -= 1
|
|
95
|
+
if entry[1]:
|
|
96
|
+
return
|
|
97
|
+
del _held[key]
|
|
98
|
+
_unlock(entry[0])
|
|
99
|
+
os.close(entry[0])
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from . import clock, store
|
|
6
|
+
from .errors import Denied
|
|
7
|
+
|
|
8
|
+
MAX_BYTES = 32 * 1024
|
|
9
|
+
MAX_QUEUED = 50
|
|
10
|
+
REPEAT_WINDOW = 60
|
|
11
|
+
RATE_WINDOW = 60
|
|
12
|
+
RATE_LIMIT = 20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inbox_path(board_dir, agent):
|
|
16
|
+
return os.path.join(board_dir, "inbox", re.sub(r"[^A-Za-z0-9_.-]", "_", agent) + ".jsonl")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(board_dir, agent):
|
|
20
|
+
return store.read_jsonl(inbox_path(board_dir, agent))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save(board_dir, agent, rows):
|
|
24
|
+
store.write_jsonl(inbox_path(board_dir, agent), rows)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def all_messages(board_dir):
|
|
28
|
+
folder = os.path.join(board_dir, "inbox")
|
|
29
|
+
if not os.path.isdir(folder):
|
|
30
|
+
return []
|
|
31
|
+
out = []
|
|
32
|
+
for name in sorted(os.listdir(folder)):
|
|
33
|
+
if name.endswith(".jsonl"):
|
|
34
|
+
out.extend(store.read_jsonl(os.path.join(folder, name)))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def initial_status(task, urgent, busy_card):
|
|
39
|
+
"""ready when urgent, when the recipient is idle, or when the message is about the recipient's doing card."""
|
|
40
|
+
if urgent or not busy_card or (task and task == busy_card):
|
|
41
|
+
return "ready"
|
|
42
|
+
return "queued"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_limits(board_dir, sender, to, task, text, now):
|
|
46
|
+
"""Return a duplicate message when this one repeats it within the window; raise Denied on caps."""
|
|
47
|
+
inbox = load(board_dir, to)
|
|
48
|
+
for m in reversed(inbox):
|
|
49
|
+
if _within(m, now, REPEAT_WINDOW) and (m.get("from"), m.get("task"), m.get("text")) == (sender, task, text):
|
|
50
|
+
return m
|
|
51
|
+
if sum(1 for m in inbox if m.get("status") == "queued") >= MAX_QUEUED:
|
|
52
|
+
raise Denied(f"{to} has {MAX_QUEUED} queued messages; wait until it finishes its task", to=to)
|
|
53
|
+
recent = [m for m in all_messages(board_dir) if m.get("from") == sender and _within(m, now, RATE_WINDOW)]
|
|
54
|
+
if len(recent) >= RATE_LIMIT:
|
|
55
|
+
raise Denied(f"{sender} sent {RATE_LIMIT} messages in the last minute; slow down", sender=sender)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _within(message, now, window):
|
|
60
|
+
age = clock.age_seconds(message.get("sent", ""), now)
|
|
61
|
+
return age is not None and age <= window
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make(sender, to, text, task, status, urgent, model):
|
|
65
|
+
return {
|
|
66
|
+
"id": uuid.uuid4().hex[:12],
|
|
67
|
+
"from": sender,
|
|
68
|
+
"to": to,
|
|
69
|
+
"task": task or "",
|
|
70
|
+
"text": text,
|
|
71
|
+
"sent": clock.iso(),
|
|
72
|
+
"status": status,
|
|
73
|
+
"urgent": bool(urgent),
|
|
74
|
+
"model": model,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def promote(rows, busy_card):
|
|
79
|
+
"""Queued messages become ready when the recipient is not in doing, or the message is about its doing card."""
|
|
80
|
+
changed = False
|
|
81
|
+
for m in rows:
|
|
82
|
+
if m.get("status") == "queued" and (not busy_card or m.get("task") == busy_card):
|
|
83
|
+
m["status"] = "ready"
|
|
84
|
+
changed = True
|
|
85
|
+
return changed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def counts(board_dir, busy):
|
|
89
|
+
"""{agent: {unread, queued}} where `busy` maps agent -> doing card id."""
|
|
90
|
+
out = {}
|
|
91
|
+
for m in all_messages(board_dir):
|
|
92
|
+
c = out.setdefault(m.get("to", ""), {"unread": 0, "queued": 0})
|
|
93
|
+
b = busy.get(m.get("to", ""), "")
|
|
94
|
+
if m.get("status") == "queued" and b and m.get("task") != b:
|
|
95
|
+
c["queued"] += 1
|
|
96
|
+
elif m.get("status") in ("queued", "ready"):
|
|
97
|
+
c["unread"] += 1
|
|
98
|
+
return out
|