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