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/core/repos.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
from . import paths as P
|
|
6
|
+
from . import store
|
|
7
|
+
|
|
8
|
+
_SCP = re.compile(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$")
|
|
9
|
+
_REMOTE = re.compile(r'^\s*\[remote\s+"([^"]+)"\]\s*$')
|
|
10
|
+
_URL = re.compile(r"^\s*url\s*=\s*(.+?)\s*$")
|
|
11
|
+
_SECTION = re.compile(r"^\s*\[")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_remote(url):
|
|
15
|
+
"""Normalize a git remote URL to `host/owner/name`, lowercase, without scheme, user, port or `.git`."""
|
|
16
|
+
text = url.strip().replace("\\", "/")
|
|
17
|
+
text = re.sub(r"(\.git)?/*$", "", text)
|
|
18
|
+
if "://" in text:
|
|
19
|
+
parts = urlsplit(text)
|
|
20
|
+
if parts.scheme == "file":
|
|
21
|
+
return "path:" + parts.path.rstrip("/")
|
|
22
|
+
host = (parts.hostname or "").lower()
|
|
23
|
+
return (host + "/" + parts.path.strip("/")).lower()
|
|
24
|
+
m = _SCP.match(text)
|
|
25
|
+
if m and not os.path.isabs(text):
|
|
26
|
+
return (m.group(1) + "/" + m.group(2).strip("/")).lower()
|
|
27
|
+
return "path:" + os.path.realpath(text).replace("\\", "/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def git_dir(repo):
|
|
31
|
+
dotgit = os.path.join(repo, ".git")
|
|
32
|
+
if os.path.isdir(dotgit):
|
|
33
|
+
return dotgit
|
|
34
|
+
if os.path.isfile(dotgit):
|
|
35
|
+
try:
|
|
36
|
+
with open(dotgit, encoding="utf-8") as f:
|
|
37
|
+
line = f.read().strip()
|
|
38
|
+
except OSError:
|
|
39
|
+
return None
|
|
40
|
+
if line.startswith("gitdir:"):
|
|
41
|
+
target = line[7:].strip()
|
|
42
|
+
return os.path.normpath(os.path.join(repo, target))
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def common_dir(gdir):
|
|
47
|
+
try:
|
|
48
|
+
with open(os.path.join(gdir, "commondir"), encoding="utf-8") as f:
|
|
49
|
+
return os.path.normpath(os.path.join(gdir, f.read().strip()))
|
|
50
|
+
except OSError:
|
|
51
|
+
return gdir
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def remote_url(repo):
|
|
55
|
+
gdir = git_dir(repo)
|
|
56
|
+
if not gdir:
|
|
57
|
+
return None
|
|
58
|
+
remotes = _read_remotes(os.path.join(common_dir(gdir), "config"))
|
|
59
|
+
return remotes.get("origin") or (next(iter(remotes.values())) if remotes else None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_remotes(config):
|
|
63
|
+
remotes, current = {}, None
|
|
64
|
+
try:
|
|
65
|
+
with open(config, encoding="utf-8") as f:
|
|
66
|
+
lines = f.readlines()
|
|
67
|
+
except OSError:
|
|
68
|
+
return remotes
|
|
69
|
+
for line in lines:
|
|
70
|
+
m = _REMOTE.match(line)
|
|
71
|
+
if m:
|
|
72
|
+
current = m.group(1)
|
|
73
|
+
continue
|
|
74
|
+
if _SECTION.match(line):
|
|
75
|
+
current = None
|
|
76
|
+
continue
|
|
77
|
+
u = _URL.match(line)
|
|
78
|
+
if current and u and current not in remotes:
|
|
79
|
+
remotes[current] = u.group(1)
|
|
80
|
+
return remotes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def identity(repo):
|
|
84
|
+
"""Repo identity: normalized remote URL, else `path:<absolute path>`."""
|
|
85
|
+
url = remote_url(repo)
|
|
86
|
+
if url:
|
|
87
|
+
return normalize_remote(url)
|
|
88
|
+
return "path:" + os.path.realpath(repo).replace("\\", "/")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find_root(path):
|
|
92
|
+
cur = os.path.realpath(path)
|
|
93
|
+
while True:
|
|
94
|
+
if git_dir(cur):
|
|
95
|
+
return cur
|
|
96
|
+
parent = os.path.dirname(cur)
|
|
97
|
+
if parent == cur:
|
|
98
|
+
return None
|
|
99
|
+
cur = parent
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def checkout_info(path):
|
|
103
|
+
"""Describe the checkout containing `path`: {path, kind: clone|worktree, branch}, or None."""
|
|
104
|
+
root = find_root(path)
|
|
105
|
+
if not root:
|
|
106
|
+
return None
|
|
107
|
+
gdir = git_dir(root)
|
|
108
|
+
kind = "worktree" if os.path.isfile(os.path.join(root, ".git")) and common_dir(gdir) != gdir else "clone"
|
|
109
|
+
return {"path": root, "kind": kind, "branch": _branch(gdir)}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _branch(gdir):
|
|
113
|
+
try:
|
|
114
|
+
with open(os.path.join(gdir, "HEAD"), encoding="utf-8") as f:
|
|
115
|
+
head = f.read().strip()
|
|
116
|
+
except OSError:
|
|
117
|
+
return ""
|
|
118
|
+
if head.startswith("ref:"):
|
|
119
|
+
return head.split("refs/heads/", 1)[-1]
|
|
120
|
+
return head[:12]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def case_insensitive(folder):
|
|
124
|
+
probe = os.path.join(folder, ".board")
|
|
125
|
+
if not os.path.exists(probe):
|
|
126
|
+
probe = folder
|
|
127
|
+
head, tail = os.path.split(probe.rstrip(os.sep))
|
|
128
|
+
swapped = os.path.join(head, tail.swapcase())
|
|
129
|
+
if swapped == probe or not os.path.exists(swapped):
|
|
130
|
+
return False
|
|
131
|
+
try:
|
|
132
|
+
return os.path.samefile(probe, swapped)
|
|
133
|
+
except OSError:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def discover(workspace, declared=None):
|
|
138
|
+
"""Map repo name -> absolute path: the workspace itself, git repos at depth 1-2, plus declared repos."""
|
|
139
|
+
workspace = os.path.realpath(workspace)
|
|
140
|
+
found = {}
|
|
141
|
+
if git_dir(workspace):
|
|
142
|
+
found[os.path.basename(workspace)] = workspace
|
|
143
|
+
for name in _children(workspace):
|
|
144
|
+
full = os.path.join(workspace, name)
|
|
145
|
+
if git_dir(full):
|
|
146
|
+
found[name] = full
|
|
147
|
+
continue
|
|
148
|
+
for sub in _children(full):
|
|
149
|
+
if git_dir(os.path.join(full, sub)):
|
|
150
|
+
found[name + "/" + sub] = os.path.join(full, sub)
|
|
151
|
+
for name, rel in (declared or {}).items():
|
|
152
|
+
found[P.canonical(name)] = os.path.realpath(os.path.join(workspace, os.path.expanduser(rel)))
|
|
153
|
+
return found
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _children(folder):
|
|
157
|
+
try:
|
|
158
|
+
names = sorted(os.listdir(folder))
|
|
159
|
+
except OSError:
|
|
160
|
+
return []
|
|
161
|
+
return [n for n in names if not n.startswith(".") and os.path.isdir(os.path.join(folder, n))]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def declared_repos(board_dir):
|
|
165
|
+
data = store.read_json(os.path.join(board_dir, "workspace.json"), {})
|
|
166
|
+
store.check_schema(data, "workspace.json")
|
|
167
|
+
return data.get("repos", {})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Resolver:
|
|
171
|
+
"""Resolve `<repo>/<path>` lock paths of one workspace to repo identities."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, workspace, board_dir):
|
|
174
|
+
self.workspace = os.path.realpath(workspace)
|
|
175
|
+
self.ci = case_insensitive(self.workspace)
|
|
176
|
+
self.repos = discover(self.workspace, declared_repos(board_dir))
|
|
177
|
+
self._ids = {}
|
|
178
|
+
|
|
179
|
+
def _identity(self, name):
|
|
180
|
+
if name not in self._ids:
|
|
181
|
+
self._ids[name] = identity(self.repos[name])
|
|
182
|
+
return self._ids[name]
|
|
183
|
+
|
|
184
|
+
def _match(self, parts):
|
|
185
|
+
names = sorted(self.repos, key=lambda n: -len(P.split(n)))
|
|
186
|
+
for name in names:
|
|
187
|
+
want = P.split(name)
|
|
188
|
+
have = parts[: len(want)]
|
|
189
|
+
if want == have or (self.ci and [w.casefold() for w in want] == [h.casefold() for h in have]):
|
|
190
|
+
return name, len(want)
|
|
191
|
+
root = next((n for n, p in self.repos.items() if p == self.workspace), None)
|
|
192
|
+
if root:
|
|
193
|
+
return root, 0
|
|
194
|
+
if len(self.repos) == 1:
|
|
195
|
+
return next(iter(self.repos)), 0
|
|
196
|
+
return None, 1
|
|
197
|
+
|
|
198
|
+
def resolve(self, raw):
|
|
199
|
+
"""Return {path, id, rel, ci} for a lock path."""
|
|
200
|
+
path = P.canonical(raw)
|
|
201
|
+
parts = P.split(path)
|
|
202
|
+
name, used = self._match(parts)
|
|
203
|
+
if name:
|
|
204
|
+
ident = self._identity(name)
|
|
205
|
+
else:
|
|
206
|
+
ident = "path:" + os.path.realpath(os.path.join(self.workspace, parts[0])).replace("\\", "/")
|
|
207
|
+
rel = "/".join(parts[used:])
|
|
208
|
+
if self.ci:
|
|
209
|
+
ident = ident.casefold() if ident.startswith("path:") else ident
|
|
210
|
+
rel = rel.casefold()
|
|
211
|
+
return {"path": path, "id": ident, "rel": rel, "ci": self.ci}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def locate(abs_path):
|
|
215
|
+
"""Map an absolute file path to {id, rel, ci, root} through its checkout's git remote, or None."""
|
|
216
|
+
root = find_root(os.path.dirname(abs_path) if not os.path.isdir(abs_path) else abs_path)
|
|
217
|
+
if not root:
|
|
218
|
+
return None
|
|
219
|
+
real = os.path.realpath(abs_path)
|
|
220
|
+
rel = os.path.relpath(real, root).replace("\\", "/")
|
|
221
|
+
rel = "" if rel == "." else rel
|
|
222
|
+
ci = case_insensitive(root)
|
|
223
|
+
ident = identity(root)
|
|
224
|
+
if ci:
|
|
225
|
+
rel = rel.casefold()
|
|
226
|
+
ident = ident.casefold() if ident.startswith("path:") else ident
|
|
227
|
+
return {"id": ident, "rel": rel, "ci": ci, "root": root}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def keys_overlap(a, b):
|
|
231
|
+
if a["id"] != b["id"]:
|
|
232
|
+
return False
|
|
233
|
+
fold = a.get("ci") or b.get("ci")
|
|
234
|
+
if any(P.contains(path, b["rel"], fold) for path in a.get("exclude", [])):
|
|
235
|
+
return False
|
|
236
|
+
if any(P.contains(path, a["rel"], fold) for path in b.get("exclude", [])):
|
|
237
|
+
return False
|
|
238
|
+
if not a["rel"] or not b["rel"]:
|
|
239
|
+
return True
|
|
240
|
+
return P.overlaps(a["rel"], b["rel"], fold)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def key_contains(outer, inner):
|
|
244
|
+
if outer["id"] != inner["id"]:
|
|
245
|
+
return False
|
|
246
|
+
if any(P.overlaps(path, inner["rel"], outer.get("ci") or inner.get("ci")) for path in outer.get("exclude", [])):
|
|
247
|
+
return False
|
|
248
|
+
if not outer["rel"]:
|
|
249
|
+
return True
|
|
250
|
+
return P.contains(outer["rel"], inner["rel"], outer.get("ci") or inner.get("ci"))
|
raidlock/core/safeio.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import errno
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import portable
|
|
9
|
+
from .errors import Precondition
|
|
10
|
+
|
|
11
|
+
_DIR_FD = os.open in os.supports_dir_fd and os.mkdir in os.supports_dir_fd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _system_link(component):
|
|
15
|
+
aliases = {Path("/", name): Path("/private", name) for name in ("var", "tmp", "etc")}
|
|
16
|
+
if component not in aliases:
|
|
17
|
+
return False
|
|
18
|
+
try:
|
|
19
|
+
link = component.lstat()
|
|
20
|
+
parent = component.parent.lstat()
|
|
21
|
+
except OSError:
|
|
22
|
+
return False
|
|
23
|
+
return (
|
|
24
|
+
stat.S_ISLNK(link.st_mode)
|
|
25
|
+
and os.readlink(component) in (str(aliases[component]), str(aliases[component]).lstrip("/"))
|
|
26
|
+
and link.st_uid == 0
|
|
27
|
+
and parent.st_uid == 0
|
|
28
|
+
and not parent.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
|
|
29
|
+
and component.parent == Path(component.anchor)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(path, tree=False):
|
|
34
|
+
path = Path(os.path.abspath(path))
|
|
35
|
+
if len(path.parts) > 1:
|
|
36
|
+
top = Path(path.anchor, path.parts[1])
|
|
37
|
+
if _system_link(top):
|
|
38
|
+
path = Path("/private", top.name, *path.parts[2:])
|
|
39
|
+
for component in (*reversed(path.parents), path):
|
|
40
|
+
try:
|
|
41
|
+
mode = component.lstat().st_mode
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
continue
|
|
44
|
+
if portable._redirected(component) or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
|
|
45
|
+
raise Precondition(f"unsafe filesystem entry: {component}")
|
|
46
|
+
if component != path and not stat.S_ISDIR(mode):
|
|
47
|
+
raise Precondition(f"not a directory: {component}")
|
|
48
|
+
if tree and path.is_dir():
|
|
49
|
+
for child in path.iterdir():
|
|
50
|
+
check(child, tree=True)
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _directory(path, create=False):
|
|
55
|
+
path = check(path)
|
|
56
|
+
nofollow = getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
57
|
+
flags = os.O_RDONLY | nofollow
|
|
58
|
+
search = (getattr(os, "O_SEARCH", 0) | nofollow) if hasattr(os, "O_SEARCH") else flags
|
|
59
|
+
parts = path.parts[1:]
|
|
60
|
+
parent = os.open(path.anchor, search if parts else flags)
|
|
61
|
+
try:
|
|
62
|
+
for index, part in enumerate(parts):
|
|
63
|
+
if create:
|
|
64
|
+
with contextlib.suppress(FileExistsError):
|
|
65
|
+
os.mkdir(part, mode=0o700, dir_fd=parent)
|
|
66
|
+
if index < len(parts) - 1:
|
|
67
|
+
fd = os.open(part, search, dir_fd=parent)
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
fd = os.open(part, flags, dir_fd=parent)
|
|
71
|
+
except PermissionError:
|
|
72
|
+
if search == flags:
|
|
73
|
+
raise
|
|
74
|
+
fd = os.open(part, search, dir_fd=parent)
|
|
75
|
+
os.close(parent)
|
|
76
|
+
parent = fd
|
|
77
|
+
return parent
|
|
78
|
+
except BaseException:
|
|
79
|
+
os.close(parent)
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read(path):
|
|
84
|
+
path = check(path)
|
|
85
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
86
|
+
if not _DIR_FD:
|
|
87
|
+
fd = portable.open_nofollow(path, flags)
|
|
88
|
+
with os.fdopen(fd, "rb") as stream:
|
|
89
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
90
|
+
raise Precondition(f"not a regular file: {path}")
|
|
91
|
+
check(path)
|
|
92
|
+
return stream.read()
|
|
93
|
+
parent = _directory(path.parent)
|
|
94
|
+
try:
|
|
95
|
+
fd = os.open(path.name, flags, dir_fd=parent)
|
|
96
|
+
with os.fdopen(fd, "rb") as stream:
|
|
97
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
98
|
+
raise Precondition(f"not a regular file: {path}")
|
|
99
|
+
return stream.read()
|
|
100
|
+
finally:
|
|
101
|
+
os.close(parent)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def mkdir(path):
|
|
105
|
+
path = check(path)
|
|
106
|
+
if not _DIR_FD:
|
|
107
|
+
os.mkdir(path, mode=0o700)
|
|
108
|
+
check(path)
|
|
109
|
+
return
|
|
110
|
+
parent = _directory(path.parent)
|
|
111
|
+
try:
|
|
112
|
+
os.mkdir(path.name, mode=0o700, dir_fd=parent)
|
|
113
|
+
finally:
|
|
114
|
+
os.close(parent)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def copytree(source, target):
|
|
118
|
+
source, target = check(source, tree=True), check(target)
|
|
119
|
+
mkdir(target)
|
|
120
|
+
for child in source.iterdir():
|
|
121
|
+
dest = target / child.name
|
|
122
|
+
if child.is_dir():
|
|
123
|
+
copytree(child, dest)
|
|
124
|
+
else:
|
|
125
|
+
write_text(dest, read(child))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_text(path, text, mode=0o600):
|
|
129
|
+
path = check(path)
|
|
130
|
+
if not _DIR_FD:
|
|
131
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
132
|
+
check(path)
|
|
133
|
+
temporary = path.parent / (".migration-" + secrets.token_hex(16))
|
|
134
|
+
try:
|
|
135
|
+
fd = portable.open_nofollow(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
136
|
+
with os.fdopen(fd, "wb") as stream:
|
|
137
|
+
stream.write(text.encode("utf-8") if isinstance(text, str) else text)
|
|
138
|
+
stream.flush()
|
|
139
|
+
os.fsync(stream.fileno())
|
|
140
|
+
check(path)
|
|
141
|
+
check(temporary)
|
|
142
|
+
os.replace(temporary, path)
|
|
143
|
+
finally:
|
|
144
|
+
with contextlib.suppress(FileNotFoundError):
|
|
145
|
+
unlink(temporary)
|
|
146
|
+
return
|
|
147
|
+
parent = _directory(path.parent, create=True)
|
|
148
|
+
temporary = ".migration-" + secrets.token_hex(16)
|
|
149
|
+
try:
|
|
150
|
+
try:
|
|
151
|
+
existing = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
pass
|
|
154
|
+
else:
|
|
155
|
+
if not stat.S_ISREG(existing.st_mode):
|
|
156
|
+
raise Precondition(f"not a regular file: {path}")
|
|
157
|
+
fd = os.open(
|
|
158
|
+
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), mode, dir_fd=parent
|
|
159
|
+
)
|
|
160
|
+
with os.fdopen(fd, "wb") as stream:
|
|
161
|
+
stream.write(text.encode("utf-8") if isinstance(text, str) else text)
|
|
162
|
+
stream.flush()
|
|
163
|
+
os.fsync(stream.fileno())
|
|
164
|
+
os.replace(temporary, path.name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
165
|
+
try:
|
|
166
|
+
os.fsync(parent)
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
if exc.errno not in (errno.EBADF, errno.EPERM):
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
with contextlib.suppress(FileNotFoundError):
|
|
172
|
+
os.unlink(temporary, dir_fd=parent)
|
|
173
|
+
os.close(parent)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def replace(source, target):
|
|
177
|
+
source, target = check(source), check(target)
|
|
178
|
+
if not _DIR_FD:
|
|
179
|
+
os.replace(source, target)
|
|
180
|
+
return
|
|
181
|
+
source_parent = _directory(source.parent)
|
|
182
|
+
try:
|
|
183
|
+
target_parent = _directory(target.parent)
|
|
184
|
+
try:
|
|
185
|
+
os.replace(source.name, target.name, src_dir_fd=source_parent, dst_dir_fd=target_parent)
|
|
186
|
+
finally:
|
|
187
|
+
os.close(target_parent)
|
|
188
|
+
finally:
|
|
189
|
+
os.close(source_parent)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def unlink(path):
|
|
193
|
+
path = check(path)
|
|
194
|
+
if not _DIR_FD:
|
|
195
|
+
os.unlink(path)
|
|
196
|
+
return
|
|
197
|
+
parent = _directory(path.parent)
|
|
198
|
+
try:
|
|
199
|
+
os.unlink(path.name, dir_fd=parent)
|
|
200
|
+
finally:
|
|
201
|
+
os.close(parent)
|
raidlock/core/status.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from . import clock
|
|
2
|
+
|
|
3
|
+
WORKING_SECONDS = 15 * 60
|
|
4
|
+
STALLED_SECONDS = 45 * 60
|
|
5
|
+
LABELS = {
|
|
6
|
+
"working": "Working",
|
|
7
|
+
"quiet": "Working (quiet)",
|
|
8
|
+
"review": "Waiting for review",
|
|
9
|
+
"blocked": "Blocked",
|
|
10
|
+
"stalled": "Stalled",
|
|
11
|
+
"idle": "Idle",
|
|
12
|
+
}
|
|
13
|
+
ORDER = ("working", "quiet", "blocked", "stalled", "review", "idle")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _doing_status(lease, at):
|
|
17
|
+
if not lease or not lease.get("expires") or (clock.parse(lease["expires"]) or at) <= at:
|
|
18
|
+
return "stalled"
|
|
19
|
+
age = clock.age_seconds(lease.get("renewed", ""), at)
|
|
20
|
+
if age is None or age > STALLED_SECONDS:
|
|
21
|
+
return "stalled"
|
|
22
|
+
return "quiet" if age > WORKING_SECONDS else "working"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def agent_status(name, cards, leases, at=None):
|
|
26
|
+
"""Derived status of one agent from its cards and its own lease heartbeat (never card.updated)."""
|
|
27
|
+
at = at or clock.now()
|
|
28
|
+
mine = [c for c in cards if c.get("owner") == name]
|
|
29
|
+
doing = next((c for c in mine if c["lane"] == "doing"), None)
|
|
30
|
+
if doing:
|
|
31
|
+
lease = leases.get(doing["id"])
|
|
32
|
+
return {"status": _doing_status(lease, at), "card": doing["id"], "heartbeat": (lease or {}).get("renewed", "")}
|
|
33
|
+
for lane, status in (("blocked", "blocked"), ("review", "review")):
|
|
34
|
+
card = next((c for c in mine if c["lane"] == lane), None)
|
|
35
|
+
if card:
|
|
36
|
+
return {
|
|
37
|
+
"status": status,
|
|
38
|
+
"card": card["id"],
|
|
39
|
+
"heartbeat": (leases.get(card["id"]) or {}).get("renewed", ""),
|
|
40
|
+
}
|
|
41
|
+
return {"status": "idle", "card": "", "heartbeat": ""}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def statuses(agents, cards, leases, at=None):
|
|
45
|
+
"""{agent: {status, label, card, heartbeat}} for every joined agent and card owner."""
|
|
46
|
+
names = set(agents) | {c["owner"] for c in cards if c.get("owner")}
|
|
47
|
+
out = {}
|
|
48
|
+
for name in sorted(names):
|
|
49
|
+
s = agent_status(name, cards, leases, at)
|
|
50
|
+
s["label"] = LABELS[s["status"]]
|
|
51
|
+
out[name] = s
|
|
52
|
+
return out
|
raidlock/core/store.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
from .errors import Precondition
|
|
6
|
+
|
|
7
|
+
SCHEMA = 2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def atomic_write_text(path, text):
|
|
11
|
+
folder = os.path.dirname(os.path.abspath(path))
|
|
12
|
+
os.makedirs(folder, exist_ok=True)
|
|
13
|
+
fd, tmp = tempfile.mkstemp(prefix="." + os.path.basename(path) + ".", suffix=".tmp", dir=folder)
|
|
14
|
+
try:
|
|
15
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
16
|
+
f.write(text)
|
|
17
|
+
f.flush()
|
|
18
|
+
os.fsync(f.fileno())
|
|
19
|
+
os.replace(tmp, path)
|
|
20
|
+
except BaseException:
|
|
21
|
+
if os.path.exists(tmp):
|
|
22
|
+
os.unlink(tmp)
|
|
23
|
+
raise
|
|
24
|
+
_fsync_dir(folder)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fsync_dir(folder):
|
|
28
|
+
if os.name != "posix":
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
fd = os.open(folder, os.O_RDONLY)
|
|
32
|
+
except OSError:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
os.fsync(fd)
|
|
36
|
+
except OSError:
|
|
37
|
+
pass
|
|
38
|
+
finally:
|
|
39
|
+
os.close(fd)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_json(path, data):
|
|
43
|
+
atomic_write_text(path, json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_json(path, default=None):
|
|
47
|
+
try:
|
|
48
|
+
with open(path, encoding="utf-8") as f:
|
|
49
|
+
return json.load(f)
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
return default
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
raise Precondition(f"corrupt JSON in {path}: {e}", path=path) from e
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_schema(data, path):
|
|
57
|
+
found = data.get("schema", 1) if isinstance(data, dict) else 1
|
|
58
|
+
if not isinstance(found, int) or found > SCHEMA:
|
|
59
|
+
raise Precondition(
|
|
60
|
+
f"{path} has schema {found}; this raidlock understands up to {SCHEMA}; upgrade raidlock",
|
|
61
|
+
path=path,
|
|
62
|
+
schema=found,
|
|
63
|
+
)
|
|
64
|
+
return found
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_doc(path, key, default=None):
|
|
68
|
+
data = read_json(path)
|
|
69
|
+
if data is None:
|
|
70
|
+
return {} if default is None else default
|
|
71
|
+
check_schema(data, path)
|
|
72
|
+
return data.get(key, {} if default is None else default)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_doc(path, key, value, **extra):
|
|
76
|
+
write_json(path, {"schema": SCHEMA, key: value, **extra})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def append_line(path, line, sync=True):
|
|
80
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
81
|
+
with open(path, "a", encoding="utf-8", newline="\n") as f:
|
|
82
|
+
f.write(line.rstrip("\n") + "\n")
|
|
83
|
+
f.flush()
|
|
84
|
+
if sync:
|
|
85
|
+
os.fsync(f.fileno())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_lines(path):
|
|
89
|
+
try:
|
|
90
|
+
with open(path, encoding="utf-8") as f:
|
|
91
|
+
return [line.rstrip("\n") for line in f if line.strip()]
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_jsonl(path):
|
|
97
|
+
out = []
|
|
98
|
+
for line in read_lines(path):
|
|
99
|
+
try:
|
|
100
|
+
out.append(json.loads(line))
|
|
101
|
+
except ValueError:
|
|
102
|
+
continue
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_jsonl(path, rows):
|
|
107
|
+
atomic_write_text(path, "".join(json.dumps(r, sort_keys=True, ensure_ascii=False) + "\n" for r in rows))
|
raidlock/core/tokens.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
CONTRIBUTORS = {}
|
|
5
|
+
EXTENSION_MODULES = ("raidlock.hooks", "raidlock.mcp", "raidlock.web", "raidlock.runners", "raidlock.plugin")
|
|
6
|
+
SAMPLE_OUTPUTS = (
|
|
7
|
+
"[ws] Claimed fix-login: lock app/auth until 14:05, fence 7.",
|
|
8
|
+
'export RAIDLOCK_FENCE=7 # next: raidlock note fix-login "..."',
|
|
9
|
+
"[ws] Noted on fix-login.",
|
|
10
|
+
"[ws] Renewed fix-login until 14:50.",
|
|
11
|
+
"[ws] Moved fix-login to review. Next: the lead runs raidlock done fix-login",
|
|
12
|
+
"No messages.",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def estimate(text):
|
|
17
|
+
"""Token estimate: ceil(characters / 4)."""
|
|
18
|
+
return math.ceil(len(text or "") / 4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(name, fn):
|
|
22
|
+
"""Register an overhead contributor. `fn(board)` returns {"tokens": int, "detail": str} or a text to estimate.
|
|
23
|
+
Edge components (skill, hooks, MCP schemas, UI) call this from `register_tokens(register)`."""
|
|
24
|
+
CONTRIBUTORS[name] = fn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_extensions():
|
|
28
|
+
for module in EXTENSION_MODULES:
|
|
29
|
+
try:
|
|
30
|
+
mod = importlib.import_module(module)
|
|
31
|
+
except ImportError:
|
|
32
|
+
continue
|
|
33
|
+
if hasattr(mod, "register_tokens"):
|
|
34
|
+
mod.register_tokens(register)
|
|
35
|
+
try:
|
|
36
|
+
from importlib.metadata import entry_points
|
|
37
|
+
|
|
38
|
+
eps = entry_points()
|
|
39
|
+
group = eps.select(group="raidlock.tokens") if hasattr(eps, "select") else eps.get("raidlock.tokens", [])
|
|
40
|
+
for ep in group:
|
|
41
|
+
ep.load()(register)
|
|
42
|
+
except Exception:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _core_outputs(board):
|
|
47
|
+
text = "\n".join(SAMPLE_OUTPUTS)
|
|
48
|
+
return {"tokens": estimate(text), "detail": f"{len(SAMPLE_OUTPUTS)} typical command outputs per session"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _core_brief(board):
|
|
52
|
+
if board is None:
|
|
53
|
+
return {"tokens": 0, "detail": "no board"}
|
|
54
|
+
cards = [c for c in board.cards() if c["lane"] in ("todo", "doing", "blocked")]
|
|
55
|
+
if not cards:
|
|
56
|
+
return {"tokens": 0, "detail": "no open cards"}
|
|
57
|
+
sizes = sorted(board.brief(c["id"])["tokens"] for c in cards)
|
|
58
|
+
return {"tokens": sizes[len(sizes) // 2], "detail": f"median card brief over {len(cards)} open card(s)"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
register("cli outputs", _core_outputs)
|
|
62
|
+
register("card brief", _core_brief)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def report(board=None):
|
|
66
|
+
"""Estimated per-session token overhead of raidlock, by contributor."""
|
|
67
|
+
_load_extensions()
|
|
68
|
+
items = []
|
|
69
|
+
for name, fn in sorted(CONTRIBUTORS.items()):
|
|
70
|
+
try:
|
|
71
|
+
value = fn(board)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
value = {"tokens": 0, "detail": f"error: {e}"}
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
value = {"tokens": estimate(value), "detail": f"{len(value)} chars"}
|
|
76
|
+
items.append({"name": name, "tokens": int(value.get("tokens", 0)), "detail": value.get("detail", "")})
|
|
77
|
+
return {"items": items, "total": sum(i["tokens"] for i in items), "method": "ceil(chars / 4)"}
|