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/board.py
ADDED
|
@@ -0,0 +1,2251 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from . import advisories, clock, db, migrate, model, models, registry, repos, store
|
|
14
|
+
from . import billing as billing_mod
|
|
15
|
+
from . import brief as B
|
|
16
|
+
from . import cost as C
|
|
17
|
+
from . import identity as I
|
|
18
|
+
from . import lead as LD
|
|
19
|
+
from . import leases as L
|
|
20
|
+
from . import messages as M
|
|
21
|
+
from . import status as S
|
|
22
|
+
from . import transcript as T
|
|
23
|
+
from .db import Index
|
|
24
|
+
from .errors import Denied, IdentityError, Precondition, StaleLead, Timeout
|
|
25
|
+
from .hints import lead_cmd
|
|
26
|
+
from .lock import FileLock
|
|
27
|
+
from .paths import parse_list
|
|
28
|
+
|
|
29
|
+
DEFAULT_SETTINGS = {
|
|
30
|
+
"auth": "token",
|
|
31
|
+
"require_review": {"count": 0, "other_provider": False},
|
|
32
|
+
"ttl_minutes": 45,
|
|
33
|
+
"hold_minutes": 60,
|
|
34
|
+
"brief_max_tokens": 1200,
|
|
35
|
+
"lead_store": "file",
|
|
36
|
+
"require_verify": False,
|
|
37
|
+
"require_card_for_lead_edits": True,
|
|
38
|
+
"board_first_subagents": True,
|
|
39
|
+
}
|
|
40
|
+
SETTING_KEYS = (
|
|
41
|
+
"auth",
|
|
42
|
+
"require_review",
|
|
43
|
+
"ttl_minutes",
|
|
44
|
+
"brief_max_tokens",
|
|
45
|
+
"lead_store",
|
|
46
|
+
"require_verify",
|
|
47
|
+
"hold_minutes",
|
|
48
|
+
"require_card_for_lead_edits",
|
|
49
|
+
"board_first_subagents",
|
|
50
|
+
)
|
|
51
|
+
LEAD_CARD_RULE = (
|
|
52
|
+
"the lead holds no card covering this path (require_card_for_lead_edits is on). "
|
|
53
|
+
"Add a card that locks it (raid --lead-secret FILE new TITLE --locks REPO/PATH), claim it "
|
|
54
|
+
"(raid --lead-secret FILE claim CARD), or: "
|
|
55
|
+
"raid --lead-secret FILE config set require_card_for_lead_edits off"
|
|
56
|
+
)
|
|
57
|
+
ENFORCE_KEYS = ("require_card_for_lead_edits", "board_first_subagents")
|
|
58
|
+
PR_SETTING_KEYS = ("pr.redact", "pr.terms", "pr.base", "pr.remote")
|
|
59
|
+
TAIL_LINES = 40
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_board_dir(path=None, env=None):
|
|
63
|
+
"""Resolve a `.board` directory from an explicit path, RAIDLOCK_BOARD, BOARD_DIR, or by walking up from cwd."""
|
|
64
|
+
env = os.environ if env is None else env
|
|
65
|
+
explicit = path or env.get("RAIDLOCK_BOARD") or env.get("BOARD_DIR")
|
|
66
|
+
if explicit:
|
|
67
|
+
explicit = os.path.abspath(os.path.expanduser(explicit))
|
|
68
|
+
if os.path.basename(explicit.rstrip(os.sep)) == ".board" and os.path.isdir(explicit):
|
|
69
|
+
return explicit
|
|
70
|
+
if os.path.isdir(os.path.join(explicit, ".board")):
|
|
71
|
+
return os.path.join(explicit, ".board")
|
|
72
|
+
raise Precondition(f"no board at {explicit}", board=explicit, hint=f"raidlock init {explicit}")
|
|
73
|
+
cur = os.getcwd()
|
|
74
|
+
while True:
|
|
75
|
+
if os.path.isdir(os.path.join(cur, ".board")):
|
|
76
|
+
return os.path.join(cur, ".board")
|
|
77
|
+
parent = os.path.dirname(cur)
|
|
78
|
+
if parent == cur:
|
|
79
|
+
raise Precondition(
|
|
80
|
+
"no board found here or in any parent folder", hint="raidlock up (or pass --board PATH)"
|
|
81
|
+
)
|
|
82
|
+
cur = parent
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_review_setting(value):
|
|
86
|
+
if isinstance(value, int):
|
|
87
|
+
return {"count": value, "other_provider": False}
|
|
88
|
+
value = dict(value or {})
|
|
89
|
+
return {"count": int(value.get("count", 0)), "other_provider": bool(value.get("other_provider", False))}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def serialized_write(fn):
|
|
93
|
+
"""Keep credential validation and its mutation in the same board-lock interval."""
|
|
94
|
+
signature = inspect.signature(fn)
|
|
95
|
+
|
|
96
|
+
@wraps(fn)
|
|
97
|
+
def wrapped(self, *args, **kwargs):
|
|
98
|
+
caller = signature.bind(self, *args, **kwargs).arguments.get("caller")
|
|
99
|
+
with self.lock():
|
|
100
|
+
previous = getattr(self, "_validated_lead", None)
|
|
101
|
+
try:
|
|
102
|
+
if caller and caller.is_lead:
|
|
103
|
+
self._lead_write(caller)
|
|
104
|
+
self._validated_lead = caller
|
|
105
|
+
result = fn(self, *args, **kwargs)
|
|
106
|
+
if caller and caller.is_lead:
|
|
107
|
+
self._record(
|
|
108
|
+
"status",
|
|
109
|
+
caller.name,
|
|
110
|
+
f"lead action: {fn.__name__}",
|
|
111
|
+
data={
|
|
112
|
+
"event": "lead_action",
|
|
113
|
+
"action": fn.__name__,
|
|
114
|
+
"session": caller.session,
|
|
115
|
+
"lead_fence": caller.lead_fence,
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
return result
|
|
119
|
+
finally:
|
|
120
|
+
self._validated_lead = previous
|
|
121
|
+
|
|
122
|
+
return wrapped
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Board:
|
|
126
|
+
"""One workspace board (`<workspace>/.board`). Every mutation takes the board lock; lease writes also take the
|
|
127
|
+
global lock."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, board_dir, env=None, home=None):
|
|
130
|
+
migrate.reject_legacy(board_dir)
|
|
131
|
+
self.dir = os.path.abspath(board_dir)
|
|
132
|
+
self.workspace = os.path.dirname(self.dir)
|
|
133
|
+
self.env = dict(os.environ if env is None else env)
|
|
134
|
+
self.home = home or registry.home()
|
|
135
|
+
self.index = Index(self.home)
|
|
136
|
+
self._resolver_cache = None
|
|
137
|
+
self._cache = None
|
|
138
|
+
self._lead_session = None
|
|
139
|
+
self.proc_table = None
|
|
140
|
+
self.keychain = None
|
|
141
|
+
self.meta = store.read_json(self._p("board.json"))
|
|
142
|
+
if self.meta is None:
|
|
143
|
+
raise Precondition(f"{self.dir} is not a raidlock board; run `raidlock init`", board=self.dir)
|
|
144
|
+
store.check_schema(self.meta, self._p("board.json"))
|
|
145
|
+
self.transcript = T.Transcript(self.dir, self.index, self.uid)
|
|
146
|
+
self._sync_mirror()
|
|
147
|
+
with self.lock():
|
|
148
|
+
for lease in self.index.leases(self.uid).values():
|
|
149
|
+
if lease["expires"] is None:
|
|
150
|
+
lease["keys"] = [{**key, "advisory": True} for key in lease["keys"]]
|
|
151
|
+
lease["expires"] = clock.later(
|
|
152
|
+
float(self.settings["hold_minutes"]) * 60,
|
|
153
|
+
start=clock.parse(lease["renewed"]),
|
|
154
|
+
)
|
|
155
|
+
self.index.put_lease(self.uid, lease)
|
|
156
|
+
|
|
157
|
+
def _sync_mirror(self):
|
|
158
|
+
folder = self._p("cards")
|
|
159
|
+
n = sum(1 for f in os.listdir(folder) if f.endswith(".json")) if os.path.isdir(folder) else 0
|
|
160
|
+
if n != self.index.card_count(self.uid):
|
|
161
|
+
with self.lock():
|
|
162
|
+
cards = self.cards()
|
|
163
|
+
self.index.put_cards(self.uid, cards)
|
|
164
|
+
if any(c.get("costs") for c in cards) and not self.index.cost_count(self.uid):
|
|
165
|
+
self.index.rebuild_costs(self.uid, cards)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def open(cls, path=None, env=None, home=None):
|
|
169
|
+
"""Open the board at `path` (workspace or .board dir), or find it from RAIDLOCK_BOARD / cwd."""
|
|
170
|
+
return cls(find_board_dir(path, env), env=env, home=home)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def init(
|
|
174
|
+
cls, workspace=".", lead=None, runtime="human", provider=None, model_id=None, auth=True, env=None, home=None
|
|
175
|
+
):
|
|
176
|
+
"""Create a board in `workspace` (idempotent). Returns (board, lead_secret or None when the board existed)."""
|
|
177
|
+
workspace = os.path.abspath(workspace)
|
|
178
|
+
board_dir = os.path.join(workspace, ".board")
|
|
179
|
+
migrate.reject_legacy(board_dir)
|
|
180
|
+
os.makedirs(os.path.join(board_dir, "cards"), exist_ok=True)
|
|
181
|
+
if not os.path.exists(os.path.join(board_dir, ".gitignore")):
|
|
182
|
+
store.atomic_write_text(os.path.join(board_dir, ".gitignore"), "*\n")
|
|
183
|
+
created = False
|
|
184
|
+
initial_session = None
|
|
185
|
+
with FileLock(os.path.join(board_dir, ".lock")):
|
|
186
|
+
if not os.path.exists(os.path.join(board_dir, "board.json")) and not migrate.needs_migration(board_dir):
|
|
187
|
+
name = model.agent_name(lead or I.default_name(runtime, "lead", "", set()))
|
|
188
|
+
initial_session = LD.session_of(os.environ if env is None else env)
|
|
189
|
+
if not all(initial_session):
|
|
190
|
+
raise IdentityError("cannot establish lead lease without a session process anchor")
|
|
191
|
+
settings = dict(DEFAULT_SETTINGS, auth="token" if auth else "off")
|
|
192
|
+
store.write_json(
|
|
193
|
+
os.path.join(board_dir, "board.json"),
|
|
194
|
+
{
|
|
195
|
+
"schema": store.SCHEMA,
|
|
196
|
+
"uid": uuid.uuid4().hex,
|
|
197
|
+
"workspace": os.path.basename(workspace),
|
|
198
|
+
"created": clock.iso(),
|
|
199
|
+
"lead": name,
|
|
200
|
+
"settings": settings,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
created = True
|
|
204
|
+
board = cls(board_dir, env=env, home=home)
|
|
205
|
+
token = None
|
|
206
|
+
if created:
|
|
207
|
+
sid, pid, start = initial_session
|
|
208
|
+
board._enroll(board.lead, "lead", "", runtime, provider, model_id, None, None, None, sid, issue=False)
|
|
209
|
+
with board.lock():
|
|
210
|
+
token = board.leadlease().establish(sid, pid, start)
|
|
211
|
+
board._lead_session = (sid, pid, start)
|
|
212
|
+
board._record(
|
|
213
|
+
"status",
|
|
214
|
+
board.lead,
|
|
215
|
+
f"lead lease established for session {sid} (fence 1)",
|
|
216
|
+
data={"event": "lead_established", "session": sid, "lead_fence": 1},
|
|
217
|
+
)
|
|
218
|
+
board.register()
|
|
219
|
+
return board, token
|
|
220
|
+
|
|
221
|
+
def _p(self, *parts):
|
|
222
|
+
return os.path.join(self.dir, *parts)
|
|
223
|
+
|
|
224
|
+
@contextmanager
|
|
225
|
+
def lock(self):
|
|
226
|
+
"""The board lock. Reads of agents.json and teams.json are cached while it is held."""
|
|
227
|
+
with FileLock(self._p(".lock")):
|
|
228
|
+
outer = self._cache is None
|
|
229
|
+
if outer:
|
|
230
|
+
self._cache = {}
|
|
231
|
+
try:
|
|
232
|
+
yield self
|
|
233
|
+
finally:
|
|
234
|
+
if outer:
|
|
235
|
+
self._cache = None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def lead(self):
|
|
239
|
+
return self.meta.get("lead", "lead")
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def uid(self):
|
|
243
|
+
return self.meta["uid"]
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def name(self):
|
|
247
|
+
return self.meta.get("workspace") or os.path.basename(self.workspace)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def settings(self):
|
|
251
|
+
self.meta = store.read_json(self._p("board.json"))
|
|
252
|
+
s = dict(DEFAULT_SETTINGS)
|
|
253
|
+
s.update(self.meta.get("settings", {}))
|
|
254
|
+
s["require_review"] = normalize_review_setting(s.get("require_review"))
|
|
255
|
+
return s
|
|
256
|
+
|
|
257
|
+
def ttl_seconds(self, ttl_minutes=None):
|
|
258
|
+
return float(ttl_minutes if ttl_minutes is not None else self.settings["ttl_minutes"]) * 60
|
|
259
|
+
|
|
260
|
+
def register(self, refresh=True):
|
|
261
|
+
"""Register this board in the global registry; returns the workspace name. With refresh=False it only
|
|
262
|
+
writes when the board is missing from the registry."""
|
|
263
|
+
if not refresh:
|
|
264
|
+
name = self.index.registered_name(self.uid, self.dir)
|
|
265
|
+
if name:
|
|
266
|
+
return name
|
|
267
|
+
return registry.register(self.index, self.uid, self.dir)
|
|
268
|
+
|
|
269
|
+
def warnings(self):
|
|
270
|
+
provider = registry.synced_folder(self.workspace)
|
|
271
|
+
return [f"workspace is inside a {provider} synced folder; sync can replay old board files"] if provider else []
|
|
272
|
+
|
|
273
|
+
def caller(self, agent=None, token=None, fence=None, env=None, lead_secret=None, cap=None, session=None):
|
|
274
|
+
"""Resolve the calling agent from flags and the environment (RAIDLOCK_AGENT_ID, RAIDLOCK_AGENT, RAIDLOCK_TOKEN,
|
|
275
|
+
RAIDLOCK_CAP, RAIDLOCK_FENCE, RAIDLOCK_LEAD_SECRET, RAIDLOCK_LEAD_SECRET_FILE). Lead rights need the presented
|
|
276
|
+
lead secret, from the session that holds the lead lease."""
|
|
277
|
+
env = self.env if env is None else env
|
|
278
|
+
|
|
279
|
+
def check(secret):
|
|
280
|
+
sid, pid, start = LD.session_of(env, session, self._table())
|
|
281
|
+
with self.lock():
|
|
282
|
+
fence_value = self._lead_check(secret, sid, pid=pid, start=start)
|
|
283
|
+
if self.leadlease().lease():
|
|
284
|
+
self.leadlease().touch(secret, sid, pid, start)
|
|
285
|
+
self._lead_session = (sid, pid, start)
|
|
286
|
+
return fence_value, sid
|
|
287
|
+
|
|
288
|
+
return I.resolve(
|
|
289
|
+
self.home,
|
|
290
|
+
self.uid,
|
|
291
|
+
self.settings,
|
|
292
|
+
self.agents(),
|
|
293
|
+
self.lead,
|
|
294
|
+
agent=agent,
|
|
295
|
+
token=token,
|
|
296
|
+
fence=fence,
|
|
297
|
+
env=env,
|
|
298
|
+
lead_secret=lead_secret,
|
|
299
|
+
cap=cap,
|
|
300
|
+
lead_check=check,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _table(self):
|
|
304
|
+
return (self.proc_table or LD.process_table)()
|
|
305
|
+
|
|
306
|
+
def leadlease(self):
|
|
307
|
+
"""The board's lead lease (`.board/lead.json`) with its secret store."""
|
|
308
|
+
return LD.Lead(
|
|
309
|
+
self.dir,
|
|
310
|
+
I.token_path(self.home, self.uid, self.lead),
|
|
311
|
+
f"{self.uid}/{self.lead}",
|
|
312
|
+
self.ttl_seconds(),
|
|
313
|
+
self.settings.get("lead_store", "file"),
|
|
314
|
+
keychain=self.keychain,
|
|
315
|
+
table=self._table() if self.proc_table else None,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _lead_check(self, secret, sid, any_session=False, pid=None, start=None):
|
|
319
|
+
try:
|
|
320
|
+
return self.leadlease().check(secret, sid, any_session=any_session, pid=pid, start=start)
|
|
321
|
+
except (LD.ForeignLead, StaleLead) as e:
|
|
322
|
+
event = "lead_stale_fence" if isinstance(e, StaleLead) else "lead_foreign_session"
|
|
323
|
+
with self.lock():
|
|
324
|
+
self._record(
|
|
325
|
+
"status",
|
|
326
|
+
self.lead,
|
|
327
|
+
f"ALERT {event}: {e.message}",
|
|
328
|
+
data={
|
|
329
|
+
"event": event,
|
|
330
|
+
"alert": True,
|
|
331
|
+
"session": sid,
|
|
332
|
+
"lead_session": e.details.get("lead_session", ""),
|
|
333
|
+
"lead_fence": e.details.get("fence", 0),
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
raise
|
|
337
|
+
|
|
338
|
+
def _lead_write(self, caller):
|
|
339
|
+
"""Every lead write: re-check the presented secret and its fence against the lease under the board lock,
|
|
340
|
+
renew the lease, and remember the session for the transcript."""
|
|
341
|
+
with self.lock():
|
|
342
|
+
if getattr(self, "_validated_lead", None) is caller:
|
|
343
|
+
return
|
|
344
|
+
sid, pid, start = LD.session_of(self.env, caller.session, self._table())
|
|
345
|
+
checked = self._lead_check(caller.lead_secret, caller.session, pid=pid, start=start)
|
|
346
|
+
if checked != caller.lead_fence:
|
|
347
|
+
raise StaleLead("caller lead fence does not match its secret", fence=caller.lead_fence)
|
|
348
|
+
fence_value, replaced = self.leadlease().touch(caller.lead_secret, sid, pid, start)
|
|
349
|
+
if replaced:
|
|
350
|
+
caller.lead_secret = replaced
|
|
351
|
+
self._record(
|
|
352
|
+
"status",
|
|
353
|
+
self.lead,
|
|
354
|
+
f"lead lease adopted by session {sid} (fence {fence_value})",
|
|
355
|
+
data={"event": "lead_adopted", "session": sid, "lead_fence": fence_value},
|
|
356
|
+
)
|
|
357
|
+
caller.lead_fence = fence_value
|
|
358
|
+
self._lead_session = (sid, pid, start)
|
|
359
|
+
|
|
360
|
+
def _require_lead(self, caller, action):
|
|
361
|
+
I.require_lead(caller, action)
|
|
362
|
+
self._lead_write(caller)
|
|
363
|
+
|
|
364
|
+
@contextmanager
|
|
365
|
+
def lead_write(self, caller, action):
|
|
366
|
+
"""Authorize extension writes against the current lease and hold the board lock through the mutation."""
|
|
367
|
+
with self.lock():
|
|
368
|
+
self._require_lead(caller, action)
|
|
369
|
+
yield self
|
|
370
|
+
self._record(
|
|
371
|
+
"status",
|
|
372
|
+
caller.name,
|
|
373
|
+
f"lead action: {action}",
|
|
374
|
+
data={
|
|
375
|
+
"event": "lead_action",
|
|
376
|
+
"action": action,
|
|
377
|
+
"session": caller.session,
|
|
378
|
+
"lead_fence": caller.lead_fence,
|
|
379
|
+
},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def lead_status(self):
|
|
383
|
+
"""The lead lease without its secret: session, fence, expiry, liveness and store."""
|
|
384
|
+
lease = self.leadlease()
|
|
385
|
+
return {"lead": self.lead, **lease.summary(), "secret_file": I.token_path(self.home, self.uid, self.lead)}
|
|
386
|
+
|
|
387
|
+
@serialized_write
|
|
388
|
+
def lead_heartbeat(self, caller):
|
|
389
|
+
self._require_lead(caller, "renew the lead lease")
|
|
390
|
+
return self.lead_status()
|
|
391
|
+
|
|
392
|
+
@serialized_write
|
|
393
|
+
def lead_handover(self, caller, to_session):
|
|
394
|
+
"""Lead-only: rotate the lead secret, bump the lead fence and bind the lease to `to_session`."""
|
|
395
|
+
self._require_lead(caller, "hand the lead over")
|
|
396
|
+
with self.lock():
|
|
397
|
+
r = self.leadlease().handover(to_session)
|
|
398
|
+
self._lead_session = None
|
|
399
|
+
self._record(
|
|
400
|
+
"status",
|
|
401
|
+
self.lead,
|
|
402
|
+
f"lead handed over from session {r['from']} to session {r['to']} (fence {r['fence']})",
|
|
403
|
+
data={"event": "lead_handover", "from": r["from"], "to": r["to"], "lead_fence": r["fence"]},
|
|
404
|
+
)
|
|
405
|
+
return {**r, "secret_file": I.token_path(self.home, self.uid, self.lead)}
|
|
406
|
+
|
|
407
|
+
def lead_takeover(self, lead_secret=None, env=None, session=None):
|
|
408
|
+
"""Take the lead lease after it expired. Needs the current lead secret; refused for worker credentials.
|
|
409
|
+
Logged loudly as an alert."""
|
|
410
|
+
env = self.env if env is None else env
|
|
411
|
+
secret = I.presented_secret(lead_secret, env)
|
|
412
|
+
if any(env.get(k) for k in ("RAIDLOCK_AGENT_ID", "RAIDLOCK_TOKEN", "RAIDLOCK_CAP")):
|
|
413
|
+
raise IdentityError("workers cannot take over the lead", hint="unset worker credentials first")
|
|
414
|
+
if not secret:
|
|
415
|
+
raise IdentityError("takeover needs the current lead secret", hint=I.LEAD_HINT)
|
|
416
|
+
sid, pid, start = LD.session_of(env, session, self._table())
|
|
417
|
+
with self.lock():
|
|
418
|
+
self._lead_check(secret, sid, any_session=True)
|
|
419
|
+
lease = self.leadlease()
|
|
420
|
+
r = lease.takeover(sid, pid, start)
|
|
421
|
+
prev = r["previous"]
|
|
422
|
+
self._lead_session = (sid, pid, start)
|
|
423
|
+
self._record(
|
|
424
|
+
"status",
|
|
425
|
+
self.lead,
|
|
426
|
+
f"ALERT LEAD TAKEOVER: session {sid} took the lead from session "
|
|
427
|
+
f"{prev['session'] or '(none)'} whose lease expired at "
|
|
428
|
+
f"{prev['expires'] or '-'}; lead fence now {r['fence']}",
|
|
429
|
+
data={
|
|
430
|
+
"event": "lead_takeover",
|
|
431
|
+
"alert": True,
|
|
432
|
+
"session": sid,
|
|
433
|
+
"previous": prev,
|
|
434
|
+
"lead_fence": r["fence"],
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
return {**r, "secret_file": I.token_path(self.home, self.uid, self.lead)}
|
|
438
|
+
|
|
439
|
+
def agents(self):
|
|
440
|
+
return self._cached("agents.json", "agents")
|
|
441
|
+
|
|
442
|
+
def teams(self):
|
|
443
|
+
return self._cached("teams.json", "teams")
|
|
444
|
+
|
|
445
|
+
def _cached(self, name, key):
|
|
446
|
+
if self._cache is None:
|
|
447
|
+
return store.read_doc(self._p(name), key)
|
|
448
|
+
if name not in self._cache:
|
|
449
|
+
self._cache[name] = store.read_doc(self._p(name), key)
|
|
450
|
+
return copy.deepcopy(self._cache[name])
|
|
451
|
+
|
|
452
|
+
def leases(self):
|
|
453
|
+
return self.index.leases(self.uid)
|
|
454
|
+
|
|
455
|
+
def version(self):
|
|
456
|
+
"""Cheap change counters: {workspace, global}. Use them as ETags for polling."""
|
|
457
|
+
return self.index.versions(self.uid)
|
|
458
|
+
|
|
459
|
+
def cards(self):
|
|
460
|
+
"""All cards, sorted by id. Unchanged files (same mtime and size) are served from the DB mirror."""
|
|
461
|
+
folder = self._p("cards")
|
|
462
|
+
names = sorted(f for f in os.listdir(folder) if f.endswith(".json")) if os.path.isdir(folder) else []
|
|
463
|
+
cached = self.index.card_docs(self.uid)
|
|
464
|
+
out, fresh, stats = [], [], {}
|
|
465
|
+
for name in names:
|
|
466
|
+
cid = name[:-5]
|
|
467
|
+
try:
|
|
468
|
+
st = os.stat(os.path.join(folder, name))
|
|
469
|
+
except FileNotFoundError:
|
|
470
|
+
continue
|
|
471
|
+
key = (st.st_mtime_ns, st.st_size)
|
|
472
|
+
doc, mtime, size = cached.get(cid, (None, None, None))
|
|
473
|
+
if doc and (mtime, size) == key:
|
|
474
|
+
out.append(json.loads(doc))
|
|
475
|
+
continue
|
|
476
|
+
card = store.read_doc(os.path.join(folder, name), "card")
|
|
477
|
+
out.append(card)
|
|
478
|
+
fresh.append(card)
|
|
479
|
+
stats[cid] = key
|
|
480
|
+
if fresh:
|
|
481
|
+
self.index.put_cards(self.uid, fresh, stats, replace=False, bump=False)
|
|
482
|
+
return out
|
|
483
|
+
|
|
484
|
+
def card(self, card_id):
|
|
485
|
+
path = self._p("cards", model.card_id(card_id) + ".json")
|
|
486
|
+
try:
|
|
487
|
+
st = os.stat(path)
|
|
488
|
+
except FileNotFoundError:
|
|
489
|
+
raise Precondition(f"no card {card_id}", card=card_id, hint="raidlock show") from None
|
|
490
|
+
doc, mtime, size = self.index.card_doc(self.uid, card_id)
|
|
491
|
+
if doc and (mtime, size) == (st.st_mtime_ns, st.st_size):
|
|
492
|
+
return json.loads(doc)
|
|
493
|
+
data = store.read_json(path)
|
|
494
|
+
if data is None:
|
|
495
|
+
raise Precondition(f"no card {card_id}", card=card_id, hint="raidlock show")
|
|
496
|
+
store.check_schema(data, card_id)
|
|
497
|
+
return data["card"]
|
|
498
|
+
|
|
499
|
+
def brief(self, card_id, max_tokens=None):
|
|
500
|
+
"""Deterministic agent brief for a card with its token estimate (ceil(chars/4)), capped at brief_max_tokens."""
|
|
501
|
+
card = self.card(card_id)
|
|
502
|
+
cap = int(max_tokens or self.settings["brief_max_tokens"])
|
|
503
|
+
lanes = self.index.lanes(self.uid, card.get("depends", []))
|
|
504
|
+
minimal = B.render(dict(card, notes=[], output="", ticket="", reviewers=[]), lanes, cap)
|
|
505
|
+
try:
|
|
506
|
+
from ..lore import lessons_for
|
|
507
|
+
except ImportError:
|
|
508
|
+
return B.render(card, lanes, cap)
|
|
509
|
+
lore = lessons_for(self, card, min(200, max(0, cap - minimal["tokens"])))
|
|
510
|
+
result = B.render(card, lanes, cap, context=lore["text"])
|
|
511
|
+
result.update(lore_tokens=lore["tokens"], lore_ids=[item["id"] for item in lore["lessons"]])
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
def _save_card(self, card):
|
|
515
|
+
card["updated"] = clock.iso()
|
|
516
|
+
path = self._p("cards", card["id"] + ".json")
|
|
517
|
+
store.write_doc(path, "card", card)
|
|
518
|
+
st = os.stat(path)
|
|
519
|
+
self.index.put_card(self.uid, card, (st.st_mtime_ns, st.st_size))
|
|
520
|
+
|
|
521
|
+
def _save_agents(self, agents):
|
|
522
|
+
store.write_doc(self._p("agents.json"), "agents", agents)
|
|
523
|
+
if self._cache is not None:
|
|
524
|
+
self._cache["agents.json"] = copy.deepcopy(agents)
|
|
525
|
+
self.index.bump(self.uid)
|
|
526
|
+
|
|
527
|
+
def _from(self, name):
|
|
528
|
+
a = self.agents().get(name, {})
|
|
529
|
+
out = {
|
|
530
|
+
"agent": name,
|
|
531
|
+
"provider": a.get("provider", ""),
|
|
532
|
+
"runtime": a.get("runtime", ""),
|
|
533
|
+
"model": a.get("model", ""),
|
|
534
|
+
}
|
|
535
|
+
if name == self.lead:
|
|
536
|
+
out["session"] = (
|
|
537
|
+
self._lead_session[0] if self._lead_session else self.leadlease().lease().get("session", "")
|
|
538
|
+
)
|
|
539
|
+
return out
|
|
540
|
+
|
|
541
|
+
def _record(self, kind, by, text="", task="", **kw):
|
|
542
|
+
rec = T.make(
|
|
543
|
+
kind, self._from(by), text=text, task=task, context=f"{self.name}/{task}" if task else self.name, **kw
|
|
544
|
+
)
|
|
545
|
+
return self.transcript.append(rec)
|
|
546
|
+
|
|
547
|
+
def _note(self, card, by, text):
|
|
548
|
+
card["notes"].append(
|
|
549
|
+
{"ts": clock.iso(), "by": by, "model": self.agents().get(by, {}).get("model", ""), "text": text}
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def _auth(self, caller, card=None, scoped_ok=False):
|
|
553
|
+
I.require_auth(caller, self.settings, self.env, self.lead, self.agents())
|
|
554
|
+
if caller.scope_card and not scoped_ok and card != caller.scope_card:
|
|
555
|
+
raise Denied(
|
|
556
|
+
f"{caller.name}'s capability is scoped to card {caller.scope_card}",
|
|
557
|
+
agent=caller.name,
|
|
558
|
+
card=card or "",
|
|
559
|
+
hint="use the agent token for other work",
|
|
560
|
+
)
|
|
561
|
+
if caller.scope_card:
|
|
562
|
+
current = self.card(caller.scope_card)
|
|
563
|
+
if current.get("owner") != caller.name or current.get("fence") != caller.fence or current.get("released"):
|
|
564
|
+
raise Denied("card capability is stale or released", card=caller.scope_card)
|
|
565
|
+
if caller.is_lead:
|
|
566
|
+
self._lead_write(caller)
|
|
567
|
+
|
|
568
|
+
def _touch(self, name):
|
|
569
|
+
self.index.touch_agent(self.uid, name)
|
|
570
|
+
|
|
571
|
+
def _resolver(self):
|
|
572
|
+
if self._resolver_cache is None:
|
|
573
|
+
self._resolver_cache = repos.Resolver(self.workspace, self.dir)
|
|
574
|
+
return self._resolver_cache
|
|
575
|
+
|
|
576
|
+
def _enroll(
|
|
577
|
+
self, name, role, team, runtime, provider, declared, flag, reported, effort, session, issue=True, billing=None
|
|
578
|
+
):
|
|
579
|
+
agents = self.agents()
|
|
580
|
+
runtime = runtime or "shell"
|
|
581
|
+
if runtime not in I.RUNTIMES:
|
|
582
|
+
raise Precondition(
|
|
583
|
+
f"runtime must be one of {', '.join(I.RUNTIMES)}",
|
|
584
|
+
runtime=runtime,
|
|
585
|
+
hint="the short names claude, codex and gemini work too",
|
|
586
|
+
)
|
|
587
|
+
provider = provider or I.RUNTIME_PROVIDER[runtime]
|
|
588
|
+
if provider not in I.PROVIDERS:
|
|
589
|
+
raise Precondition(f"provider must be one of {', '.join(I.PROVIDERS)}", provider=provider)
|
|
590
|
+
found = models.detect(runtime, declared=declared, flag=flag, reported=reported, effort=effort, env=self.env)
|
|
591
|
+
found.update(billing_mod.detect(runtime, declared=billing, env=self.env))
|
|
592
|
+
old = agents.get(name, {})
|
|
593
|
+
record = {
|
|
594
|
+
**old,
|
|
595
|
+
"team": team if team is not None else old.get("team", ""),
|
|
596
|
+
"role": role,
|
|
597
|
+
"provider": provider,
|
|
598
|
+
"runtime": runtime,
|
|
599
|
+
"session": session or old.get("session", ""),
|
|
600
|
+
"joined": old.get("joined") or clock.iso(),
|
|
601
|
+
"last_seen": clock.iso(),
|
|
602
|
+
"card": old.get("card", ""),
|
|
603
|
+
**found,
|
|
604
|
+
}
|
|
605
|
+
if not found["model"] and old.get("model"):
|
|
606
|
+
record.update({k: old[k] for k in ("model", "model_version", "effort", "model_source") if k in old})
|
|
607
|
+
record["display"] = f"{name} · {models.display(record['model'], self.home)} · {runtime}"
|
|
608
|
+
agents[name] = record
|
|
609
|
+
self._save_agents(agents)
|
|
610
|
+
providers = {a.get("provider") for a in agents.values()} - {None, "", "human"}
|
|
611
|
+
if len(providers) >= 2:
|
|
612
|
+
meta = store.read_json(self._p("board.json"))
|
|
613
|
+
settings = copy.deepcopy(meta.get("settings", {}))
|
|
614
|
+
explicit = meta.get("explicit_settings", [])
|
|
615
|
+
review = normalize_review_setting(settings.get("require_review"))
|
|
616
|
+
if "require_review.count" not in explicit:
|
|
617
|
+
review["count"] = max(1, review["count"])
|
|
618
|
+
if "require_review.other_provider" not in explicit:
|
|
619
|
+
review["other_provider"] = True
|
|
620
|
+
settings["require_review"] = review
|
|
621
|
+
if "require_verify" not in explicit:
|
|
622
|
+
settings["require_verify"] = True
|
|
623
|
+
if settings != meta.get("settings"):
|
|
624
|
+
meta["settings"] = settings
|
|
625
|
+
store.write_json(self._p("board.json"), meta)
|
|
626
|
+
self._record(
|
|
627
|
+
"status",
|
|
628
|
+
name,
|
|
629
|
+
"multi-provider review and verify defaults enabled",
|
|
630
|
+
data={"event": "gate_defaults", "settings": settings},
|
|
631
|
+
)
|
|
632
|
+
self.meta = meta
|
|
633
|
+
self.index.put_agent(self.uid, name, record)
|
|
634
|
+
token = I.issue_token(self.home, self.uid, name) if issue else None
|
|
635
|
+
return {"agent": name, "token": token, **record}
|
|
636
|
+
|
|
637
|
+
@serialized_write
|
|
638
|
+
def join(
|
|
639
|
+
self,
|
|
640
|
+
caller,
|
|
641
|
+
agent=None,
|
|
642
|
+
team=None,
|
|
643
|
+
role="worker",
|
|
644
|
+
runtime="shell",
|
|
645
|
+
provider=None,
|
|
646
|
+
model_id=None,
|
|
647
|
+
flag_model=None,
|
|
648
|
+
reported_model=None,
|
|
649
|
+
effort=None,
|
|
650
|
+
session="",
|
|
651
|
+
card="",
|
|
652
|
+
billing=None,
|
|
653
|
+
):
|
|
654
|
+
"""Join (or update) an agent. Returns the record plus `token` when one was issued or rotated."""
|
|
655
|
+
if role not in model.ROLES:
|
|
656
|
+
raise Precondition(f"role must be one of {', '.join(model.ROLES)}", role=role)
|
|
657
|
+
with self.lock():
|
|
658
|
+
agents = self.agents()
|
|
659
|
+
own = caller.name if caller.name and not caller.is_lead else ""
|
|
660
|
+
name = agent or own or I.default_name(runtime, role, card, set(agents))
|
|
661
|
+
if caller.subagent and name != caller.name:
|
|
662
|
+
raise IdentityError(f"RAIDLOCK_AGENT_ID={caller.name} cannot join as {name}", agent=name)
|
|
663
|
+
model.agent_name(name)
|
|
664
|
+
if role == "lead" or name == self.lead:
|
|
665
|
+
self._require_lead(caller, "join as lead")
|
|
666
|
+
if name != self.lead:
|
|
667
|
+
raise Precondition(f"the lead of this board is {self.lead}", agent=name)
|
|
668
|
+
if team:
|
|
669
|
+
model.team_name(team)
|
|
670
|
+
if team not in self.teams():
|
|
671
|
+
raise Precondition(
|
|
672
|
+
f"no team {team}; the lead creates it with `raidlock team add {team}`", team=team
|
|
673
|
+
)
|
|
674
|
+
if caller.scope_card:
|
|
675
|
+
self._auth(caller)
|
|
676
|
+
keep_token = self._may_rejoin(caller, name, agents)
|
|
677
|
+
result = self._enroll(
|
|
678
|
+
name,
|
|
679
|
+
role,
|
|
680
|
+
team,
|
|
681
|
+
runtime,
|
|
682
|
+
provider,
|
|
683
|
+
model_id,
|
|
684
|
+
flag_model,
|
|
685
|
+
reported_model,
|
|
686
|
+
effort,
|
|
687
|
+
session or LD.session_of(self.env)[0],
|
|
688
|
+
issue=not keep_token and name != self.lead,
|
|
689
|
+
billing=billing,
|
|
690
|
+
)
|
|
691
|
+
self._record("status", name, f"{name} joined as {role}" + (f" on team {team}" if team else ""))
|
|
692
|
+
self.register()
|
|
693
|
+
return result
|
|
694
|
+
|
|
695
|
+
def _may_rejoin(self, caller, name, agents):
|
|
696
|
+
if name not in agents:
|
|
697
|
+
return False
|
|
698
|
+
if caller.name == name and caller.authenticated:
|
|
699
|
+
return True
|
|
700
|
+
if caller.is_lead or not I.has_token(self.home, self.uid, name):
|
|
701
|
+
return False
|
|
702
|
+
raise IdentityError(f"{name} already joined; present its token or ask the lead to re-issue it", agent=name)
|
|
703
|
+
|
|
704
|
+
@serialized_write
|
|
705
|
+
def add_team(self, caller, name, max_active=2, mission="", repos_list=None):
|
|
706
|
+
self._require_lead(caller, "manage teams")
|
|
707
|
+
model.team_name(name)
|
|
708
|
+
mission = model.line_text(mission, "mission", 500)
|
|
709
|
+
with self.lock():
|
|
710
|
+
teams = self.teams()
|
|
711
|
+
old = teams.get(name, {})
|
|
712
|
+
teams[name] = {
|
|
713
|
+
"max": int(max_active if max_active is not None else old.get("max", 2)),
|
|
714
|
+
"mission": mission or old.get("mission", ""),
|
|
715
|
+
"repos": parse_list(repos_list) or old.get("repos", []),
|
|
716
|
+
}
|
|
717
|
+
store.write_doc(self._p("teams.json"), "teams", teams)
|
|
718
|
+
self._cache.pop("teams.json", None)
|
|
719
|
+
self.index.bump(self.uid)
|
|
720
|
+
return {"team": name, **teams[name]}
|
|
721
|
+
|
|
722
|
+
@serialized_write
|
|
723
|
+
def remove_team(self, caller, name):
|
|
724
|
+
self._require_lead(caller, "manage teams")
|
|
725
|
+
with self.lock():
|
|
726
|
+
teams = self.teams()
|
|
727
|
+
if teams.pop(name, None) is None:
|
|
728
|
+
raise Precondition(f"no team {name}", team=name)
|
|
729
|
+
store.write_doc(self._p("teams.json"), "teams", teams)
|
|
730
|
+
self._cache.pop("teams.json", None)
|
|
731
|
+
self.index.bump(self.uid)
|
|
732
|
+
return {"team": name, "removed": True}
|
|
733
|
+
|
|
734
|
+
def team_list(self):
|
|
735
|
+
agents = self.agents()
|
|
736
|
+
return {
|
|
737
|
+
n: {**t, "members": sorted(a for a, x in agents.items() if x.get("team") == n)}
|
|
738
|
+
for n, t in sorted(self.teams().items())
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
def _move_lead_secret(self, old, new):
|
|
742
|
+
path = I.token_path(self.home, self.uid, self.lead)
|
|
743
|
+
src = LD.SecretStore(path, f"{self.uid}/{self.lead}", old, self.keychain)
|
|
744
|
+
secret = src.get()
|
|
745
|
+
if not secret:
|
|
746
|
+
return
|
|
747
|
+
dst = LD.SecretStore(path, f"{self.uid}/{self.lead}", new, self.keychain)
|
|
748
|
+
where = dst.put(secret)
|
|
749
|
+
if old == "keychain" and where == "file":
|
|
750
|
+
src.keychain.delete()
|
|
751
|
+
|
|
752
|
+
def get_setting(self, key=None):
|
|
753
|
+
s = self.settings
|
|
754
|
+
if key is None:
|
|
755
|
+
return s
|
|
756
|
+
if key in PR_SETTING_KEYS:
|
|
757
|
+
return s.get(key, "")
|
|
758
|
+
if key not in SETTING_KEYS and not key.startswith("require_review."):
|
|
759
|
+
raise Precondition(f"unknown setting {key}; known: {', '.join(SETTING_KEYS)}", key=key)
|
|
760
|
+
if key.startswith("require_review."):
|
|
761
|
+
return s["require_review"].get(key.split(".", 1)[1])
|
|
762
|
+
return s[key]
|
|
763
|
+
|
|
764
|
+
@serialized_write
|
|
765
|
+
def set_setting(self, caller, key, value):
|
|
766
|
+
"""Lead-only. Keys: auth (token|off), ttl_minutes (number), require_review (N), require_review.count,
|
|
767
|
+
require_review.other_provider."""
|
|
768
|
+
self._require_lead(caller, "change settings")
|
|
769
|
+
with self.lock():
|
|
770
|
+
meta = store.read_json(self._p("board.json"))
|
|
771
|
+
settings = dict(DEFAULT_SETTINGS, **meta.get("settings", {}))
|
|
772
|
+
settings["require_review"] = normalize_review_setting(settings.get("require_review"))
|
|
773
|
+
old_store = settings.get("lead_store", "file")
|
|
774
|
+
previous = copy.deepcopy(settings)
|
|
775
|
+
_apply_setting(settings, key, value)
|
|
776
|
+
explicit = set(meta.get("explicit_settings", []))
|
|
777
|
+
explicit.add("require_review.count" if key == "require_review" else key)
|
|
778
|
+
meta["explicit_settings"] = sorted(explicit)
|
|
779
|
+
if settings.get("lead_store", "file") != old_store:
|
|
780
|
+
self._move_lead_secret(old_store, settings["lead_store"])
|
|
781
|
+
meta["settings"] = settings
|
|
782
|
+
store.write_json(self._p("board.json"), meta)
|
|
783
|
+
self.meta = meta
|
|
784
|
+
self.index.bump(self.uid)
|
|
785
|
+
self._record(
|
|
786
|
+
"status",
|
|
787
|
+
caller.name,
|
|
788
|
+
f"setting changed: {key}",
|
|
789
|
+
data={"event": "setting_changed", "key": key, "before": previous, "after": settings},
|
|
790
|
+
)
|
|
791
|
+
return self.settings
|
|
792
|
+
|
|
793
|
+
@serialized_write
|
|
794
|
+
def new_card(
|
|
795
|
+
self,
|
|
796
|
+
caller,
|
|
797
|
+
title,
|
|
798
|
+
card_id=None,
|
|
799
|
+
team="",
|
|
800
|
+
locks=None,
|
|
801
|
+
depends=None,
|
|
802
|
+
scope="",
|
|
803
|
+
verify="",
|
|
804
|
+
verify_cmd="",
|
|
805
|
+
output="",
|
|
806
|
+
ticket="",
|
|
807
|
+
reviewers=None,
|
|
808
|
+
repo=None,
|
|
809
|
+
allow_protected=None,
|
|
810
|
+
):
|
|
811
|
+
"""Create a card in todo. `verify_cmd` (a runnable check) may only be set by the lead."""
|
|
812
|
+
self._auth(caller)
|
|
813
|
+
if verify_cmd:
|
|
814
|
+
self._require_lead(caller, "set a verify command")
|
|
815
|
+
allowed = self._protected_paths(caller, allow_protected) if allow_protected else []
|
|
816
|
+
if repo is not None and repo not in self._resolver().repos:
|
|
817
|
+
raise Precondition(f"unknown workspace repo {repo!r}")
|
|
818
|
+
cid = model.card_id(card_id or model.slug(title))
|
|
819
|
+
lock_list = parse_list(locks)
|
|
820
|
+
resolver = self._resolver()
|
|
821
|
+
for p in lock_list:
|
|
822
|
+
resolver.resolve(p)
|
|
823
|
+
with self.lock():
|
|
824
|
+
if os.path.exists(self._p("cards", cid + ".json")):
|
|
825
|
+
raise Precondition(f"card {cid} exists", card=cid)
|
|
826
|
+
if team and team not in self.teams():
|
|
827
|
+
raise Precondition(f"no team {team}", team=team)
|
|
828
|
+
card = model.new_card(
|
|
829
|
+
cid,
|
|
830
|
+
title,
|
|
831
|
+
clock.iso(),
|
|
832
|
+
team=team,
|
|
833
|
+
locks=lock_list,
|
|
834
|
+
repo=repo or "",
|
|
835
|
+
allow_protected=allowed,
|
|
836
|
+
ticket=ticket,
|
|
837
|
+
scope=scope,
|
|
838
|
+
output=output,
|
|
839
|
+
verify=verify,
|
|
840
|
+
verify_cmd=verify_cmd,
|
|
841
|
+
depends=[model.card_id(d) for d in parse_words(depends)],
|
|
842
|
+
reviewers=parse_words(reviewers),
|
|
843
|
+
)
|
|
844
|
+
if repo:
|
|
845
|
+
self._card_repo(card)
|
|
846
|
+
self._note(card, caller.name, "created")
|
|
847
|
+
self._save_card(card)
|
|
848
|
+
self._record(
|
|
849
|
+
"status",
|
|
850
|
+
caller.name,
|
|
851
|
+
f"created {cid}: {card['title']}",
|
|
852
|
+
task=cid,
|
|
853
|
+
data={"locks": card["locks"], "repo": card["repo"], "allow_protected": allowed},
|
|
854
|
+
)
|
|
855
|
+
self.register(refresh=False)
|
|
856
|
+
return card
|
|
857
|
+
|
|
858
|
+
def import_plan(self, caller, items):
|
|
859
|
+
"""Create several cards from dicts (id, title, team, locks, depends, scope, verify, output, ticket).
|
|
860
|
+
Validates every item before creating any. Returns the created cards."""
|
|
861
|
+
self._auth(caller)
|
|
862
|
+
existing = {c["id"] for c in self.cards()}
|
|
863
|
+
prepared = []
|
|
864
|
+
for item in items:
|
|
865
|
+
if not isinstance(item, dict) or not item.get("title"):
|
|
866
|
+
raise Precondition("every plan item needs a title", item=str(item)[:200])
|
|
867
|
+
cid = model.card_id(item.get("id") or model.slug(item["title"]))
|
|
868
|
+
if cid in existing:
|
|
869
|
+
raise Precondition(f"card {cid} exists", card=cid, hint="raidlock show " + cid)
|
|
870
|
+
existing.add(cid)
|
|
871
|
+
prepared.append({**item, "id": cid})
|
|
872
|
+
fields = ("team", "locks", "depends", "scope", "verify", "verify_cmd", "output", "ticket", "reviewers")
|
|
873
|
+
return [
|
|
874
|
+
self.new_card(caller, p["title"], card_id=p["id"], **{k: p.get(k) or "" for k in fields}) for p in prepared
|
|
875
|
+
]
|
|
876
|
+
|
|
877
|
+
def _verify_hint(self, card):
|
|
878
|
+
if card.get("verify_cmd"):
|
|
879
|
+
return lead_cmd(self, f"verify {card['id']}")
|
|
880
|
+
return (
|
|
881
|
+
lead_cmd(self, f'edit {card["id"]} --verify-cmd "<command>"')
|
|
882
|
+
+ f", then verify {card['id']}; or turn the gate off: "
|
|
883
|
+
+ lead_cmd(self, "config set require_verify false")
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
def _protected_paths(self, caller, paths):
|
|
887
|
+
self._require_lead(caller, "allow protected paths")
|
|
888
|
+
allowed = parse_list(paths)
|
|
889
|
+
if any(path not in (".github/workflows", ".claude") for path in allowed):
|
|
890
|
+
raise Precondition("only .github/workflows and .claude can be allowed; .board is always denied")
|
|
891
|
+
return allowed
|
|
892
|
+
|
|
893
|
+
@serialized_write
|
|
894
|
+
def edit_card(
|
|
895
|
+
self,
|
|
896
|
+
caller,
|
|
897
|
+
card_id,
|
|
898
|
+
verify_cmd=None,
|
|
899
|
+
verify=None,
|
|
900
|
+
repo=None,
|
|
901
|
+
locks=None,
|
|
902
|
+
depends=None,
|
|
903
|
+
scope=None,
|
|
904
|
+
allow_protected=None,
|
|
905
|
+
exclude_locks=None,
|
|
906
|
+
boss=None,
|
|
907
|
+
):
|
|
908
|
+
"""Lead-only: set or change a card's verify command or verify description. A changed command invalidates
|
|
909
|
+
earlier receipts, because each receipt carries the hash of the command it ran."""
|
|
910
|
+
self._auth(caller)
|
|
911
|
+
self._require_lead(caller, "edit a card")
|
|
912
|
+
changes = {}
|
|
913
|
+
if exclude_locks is not None:
|
|
914
|
+
changes["lock_exclusions"] = parse_list(exclude_locks)
|
|
915
|
+
self._resolver_check(changes["lock_exclusions"])
|
|
916
|
+
if allow_protected is not None:
|
|
917
|
+
changes["allow_protected"] = self._protected_paths(caller, allow_protected)
|
|
918
|
+
if repo is not None:
|
|
919
|
+
if repo not in self._resolver().repos:
|
|
920
|
+
raise Precondition(f"unknown workspace repo {repo!r}")
|
|
921
|
+
changes["repo"] = repo
|
|
922
|
+
if locks is not None:
|
|
923
|
+
changes["locks"] = parse_list(locks)
|
|
924
|
+
self._resolver_check(changes["locks"])
|
|
925
|
+
if depends is not None:
|
|
926
|
+
changes["depends"] = [model.card_id(d) for d in parse_words(depends)]
|
|
927
|
+
if card_id in changes["depends"]:
|
|
928
|
+
raise Precondition("a card cannot depend on itself")
|
|
929
|
+
for dependency in changes["depends"]:
|
|
930
|
+
self.card(dependency)
|
|
931
|
+
if scope is not None:
|
|
932
|
+
changes["scope"] = model.line_text(scope, "scope")
|
|
933
|
+
if boss is not None:
|
|
934
|
+
changes["boss"] = bool(boss)
|
|
935
|
+
if verify_cmd is not None:
|
|
936
|
+
changes["verify_cmd"] = model.line_text(verify_cmd, "verify_cmd")
|
|
937
|
+
if verify is not None:
|
|
938
|
+
changes["verify"] = model.line_text(verify, "verify")
|
|
939
|
+
if not changes:
|
|
940
|
+
raise Precondition(
|
|
941
|
+
"nothing to change", card=card_id, hint=f'raidlock edit {card_id} --verify-cmd "<command>"'
|
|
942
|
+
)
|
|
943
|
+
with self.lock():
|
|
944
|
+
card = self.card(card_id)
|
|
945
|
+
if card["lane"] in ("done", "cancelled"):
|
|
946
|
+
raise Precondition(f"{card_id} is {card['lane']}", card=card_id, hint=f"raidlock reopen {card_id}")
|
|
947
|
+
candidate = {**card, **changes}
|
|
948
|
+
if "repo" in changes or "locks" in changes:
|
|
949
|
+
self._card_repo(candidate)
|
|
950
|
+
self._card_keys(candidate)
|
|
951
|
+
before = {k: card.get(k, "") for k in changes}
|
|
952
|
+
lease = self.index.lease(self.uid, card_id)
|
|
953
|
+
if ("locks" in changes or "lock_exclusions" in changes) and lease:
|
|
954
|
+
self._grant(
|
|
955
|
+
candidate,
|
|
956
|
+
card["owner"],
|
|
957
|
+
candidate["locks"],
|
|
958
|
+
card["fence"],
|
|
959
|
+
self.ttl_seconds(),
|
|
960
|
+
checkout=lease.get("checkout"),
|
|
961
|
+
held=card["lane"] in ("review", "blocked"),
|
|
962
|
+
since=lease["since"],
|
|
963
|
+
)
|
|
964
|
+
self._update_lease(card_id, expires=lease["expires"])
|
|
965
|
+
card.update(changes)
|
|
966
|
+
if set(changes) != {"boss"}:
|
|
967
|
+
card["rev"] = card.get("rev", 0) + 1
|
|
968
|
+
self._note(card, caller.name, "edited " + ", ".join(sorted(changes)))
|
|
969
|
+
self._save_card(card)
|
|
970
|
+
self._record(
|
|
971
|
+
"status",
|
|
972
|
+
caller.name,
|
|
973
|
+
f"edited {card_id}: " + ", ".join(sorted(changes)),
|
|
974
|
+
task=card_id,
|
|
975
|
+
data={"event": "card_edited", "before": before, "after": changes},
|
|
976
|
+
)
|
|
977
|
+
return {"card": card_id, **changes}
|
|
978
|
+
|
|
979
|
+
def _owner_or_lead(self, card, caller):
|
|
980
|
+
self._auth(caller, card["id"])
|
|
981
|
+
if card["owner"] and caller.name == card["owner"]:
|
|
982
|
+
if not caller.fence:
|
|
983
|
+
raise Precondition(
|
|
984
|
+
f"no fence given for {card['id']}",
|
|
985
|
+
card=card["id"],
|
|
986
|
+
hint="export RAIDLOCK_FENCE=<fence printed by raidlock claim>",
|
|
987
|
+
)
|
|
988
|
+
if caller.fence != card["fence"]:
|
|
989
|
+
self._record(
|
|
990
|
+
"status",
|
|
991
|
+
caller.name,
|
|
992
|
+
f"stale fence {caller.fence} rejected for {card['id']}",
|
|
993
|
+
task=card["id"],
|
|
994
|
+
data={"event": "stale_fence", "fence": caller.fence, "current_fence": card["fence"]},
|
|
995
|
+
)
|
|
996
|
+
raise Denied(
|
|
997
|
+
f"fence {caller.fence} for {card['id']} is stale; someone claimed it after you",
|
|
998
|
+
card=card["id"],
|
|
999
|
+
fence=caller.fence,
|
|
1000
|
+
hint=f"raidlock claim {card['id']}",
|
|
1001
|
+
)
|
|
1002
|
+
return "owner"
|
|
1003
|
+
if caller.is_lead:
|
|
1004
|
+
return "lead"
|
|
1005
|
+
raise Denied(f"{card['id']} is owned by {card['owner'] or 'nobody'}", card=card["id"], owner=card["owner"])
|
|
1006
|
+
|
|
1007
|
+
def _keys(self, paths):
|
|
1008
|
+
resolver = self._resolver()
|
|
1009
|
+
return [resolver.resolve(p) for p in paths]
|
|
1010
|
+
|
|
1011
|
+
def _card_keys(self, card, paths=None):
|
|
1012
|
+
keys = self._keys(card.get("locks", []) if paths is None else paths)
|
|
1013
|
+
excluded = self._keys(card.get("lock_exclusions", []))
|
|
1014
|
+
for exclusion in excluded:
|
|
1015
|
+
if not any(repos.key_contains(key, exclusion) and key["rel"] != exclusion["rel"] for key in keys):
|
|
1016
|
+
raise Precondition("excluded paths must be inside a held directory lock")
|
|
1017
|
+
return [
|
|
1018
|
+
{**key, "exclude": [item["rel"] for item in excluded if repos.key_contains(key, item)]} if excluded else key
|
|
1019
|
+
for key in keys
|
|
1020
|
+
]
|
|
1021
|
+
|
|
1022
|
+
def _grant(self, card, agent, paths, fence, ttl_s, checkout=None, held=False, since=None):
|
|
1023
|
+
return self.index.grant(
|
|
1024
|
+
self.uid,
|
|
1025
|
+
card["id"],
|
|
1026
|
+
agent,
|
|
1027
|
+
paths,
|
|
1028
|
+
[{**key, **({"advisory": True} if held else {})} for key in self._card_keys(card, paths)],
|
|
1029
|
+
fence,
|
|
1030
|
+
float(self.settings["hold_minutes"]) * 60 if held else ttl_s,
|
|
1031
|
+
held=False,
|
|
1032
|
+
since=since,
|
|
1033
|
+
checkout=checkout,
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def _update_lease(self, card_id, **fields):
|
|
1037
|
+
return self.index.update_lease(self.uid, card_id, **fields)
|
|
1038
|
+
|
|
1039
|
+
def _drop_lease(self, card_id):
|
|
1040
|
+
return self.index.drop_lease(self.uid, card_id)
|
|
1041
|
+
|
|
1042
|
+
def _claim_reasons(self, card, agent, agents, teams, lease=None):
|
|
1043
|
+
resolver = self._resolver()
|
|
1044
|
+
for path in card.get("locks", []):
|
|
1045
|
+
if resolver.repos and resolver._match(path.split("/"))[0] is None:
|
|
1046
|
+
raise Precondition(
|
|
1047
|
+
f"lock {path} matches no repo in workspace {self.workspace}",
|
|
1048
|
+
hint="prefix the lock with a workspace repo: "
|
|
1049
|
+
+ (", ".join(sorted(resolver.repos)) or "initialize or declare a git repo in this workspace"),
|
|
1050
|
+
)
|
|
1051
|
+
out = []
|
|
1052
|
+
takeover = ""
|
|
1053
|
+
lane, owner = card["lane"], card["owner"]
|
|
1054
|
+
if lane in ("done", "cancelled"):
|
|
1055
|
+
out.append((3, f"{lane} is terminal; the lead can reopen it"))
|
|
1056
|
+
elif lane == "review":
|
|
1057
|
+
out.append((3, "in review; a `changes` review or the lead sends it back"))
|
|
1058
|
+
if owner and agent and owner != agent:
|
|
1059
|
+
takeover = owner
|
|
1060
|
+
lease = lease if lease is not None else self.index.lease(self.uid, card["id"])
|
|
1061
|
+
if lane == "doing" and lease and L.is_live(lease):
|
|
1062
|
+
out.append((2, f"owned by {owner} with a live lease until {lease['expires']}"))
|
|
1063
|
+
if agent:
|
|
1064
|
+
out.extend(self._member_reasons(card, agent, agents))
|
|
1065
|
+
lanes = self.index.lanes(self.uid, card.get("depends", []))
|
|
1066
|
+
for dep in card.get("depends", []):
|
|
1067
|
+
state = lanes.get(dep, "missing")
|
|
1068
|
+
if state != "done":
|
|
1069
|
+
out.append((3, f"waits on {dep} ({state})"))
|
|
1070
|
+
out.extend(self._cap_reasons(card, teams))
|
|
1071
|
+
clash = self.index.conflicts(self._card_keys(card), self.uid, card["id"])
|
|
1072
|
+
out.extend((2, L.describe(c)) for c in clash)
|
|
1073
|
+
return out, takeover, clash
|
|
1074
|
+
|
|
1075
|
+
def _member_reasons(self, card, agent, agents):
|
|
1076
|
+
rec = agents.get(agent)
|
|
1077
|
+
if rec is None:
|
|
1078
|
+
return [(5, f"{agent} has not joined")]
|
|
1079
|
+
if rec.get("role") == "reviewer":
|
|
1080
|
+
return [(5, f"{agent} is a reviewer; reviewers cannot claim")]
|
|
1081
|
+
if card.get("team") and rec.get("team") != card["team"] and agent != self.lead:
|
|
1082
|
+
return [(5, f"{agent} is on team {rec.get('team') or '(none)'}; {card['id']} is for {card['team']}")]
|
|
1083
|
+
return []
|
|
1084
|
+
|
|
1085
|
+
def _cap_reasons(self, card, teams):
|
|
1086
|
+
team = card.get("team")
|
|
1087
|
+
cap = teams.get(team, {}).get("max") if team else None
|
|
1088
|
+
if cap is None or card["lane"] == "doing":
|
|
1089
|
+
return []
|
|
1090
|
+
n = self.index.doing_in_team(self.uid, team, card["id"])
|
|
1091
|
+
return [(2, f"team {team} at its limit ({n}/{cap} in doing)")] if n >= cap else []
|
|
1092
|
+
|
|
1093
|
+
@serialized_write
|
|
1094
|
+
def claim(
|
|
1095
|
+
self, card_id, caller, take_over=False, ttl_minutes=None, for_agent=None, checkout=None, runner_base=None
|
|
1096
|
+
):
|
|
1097
|
+
"""Grant every lock of the card atomically (across all registered workspaces) and move it to doing.
|
|
1098
|
+
|
|
1099
|
+
Mints a new global fence; returns {card, agent, fence, expires, paths, checkout, taken_over_from}."""
|
|
1100
|
+
self._auth(caller)
|
|
1101
|
+
owner = for_agent or caller.name
|
|
1102
|
+
if owner != caller.name or runner_base:
|
|
1103
|
+
self._require_lead(caller, "claim for another agent")
|
|
1104
|
+
if for_agent and owner not in self.agents():
|
|
1105
|
+
self.join(caller, agent=owner, team=self.card(card_id).get("team") or None)
|
|
1106
|
+
warnings = advisories.pre_claim_warnings(self, card_id, checkout=checkout)
|
|
1107
|
+
with self.lock():
|
|
1108
|
+
card = self.card(card_id)
|
|
1109
|
+
warnings.extend(
|
|
1110
|
+
"Advisory hold: " + L.describe(c)
|
|
1111
|
+
for c in self.index.conflicts(self._keys(card["locks"]), self.uid, card_id, advisory=True)
|
|
1112
|
+
)
|
|
1113
|
+
reasons, prev, clash = self._claim_reasons(card, owner, self.agents(), self.teams())
|
|
1114
|
+
if prev and not take_over:
|
|
1115
|
+
reasons.append((2, f"owned by {prev}; --take-over needed"))
|
|
1116
|
+
if reasons and {c for c, _ in reasons} <= {2}:
|
|
1117
|
+
self._record(
|
|
1118
|
+
"status",
|
|
1119
|
+
owner,
|
|
1120
|
+
f"lease denied on {card_id}: " + "; ".join(r for _, r in reasons),
|
|
1121
|
+
task=card_id,
|
|
1122
|
+
data={
|
|
1123
|
+
"event": "lock_denied",
|
|
1124
|
+
"agent": owner,
|
|
1125
|
+
"reasons": [r for _, r in reasons],
|
|
1126
|
+
"paths": [c.get("path", "") for c in clash or []],
|
|
1127
|
+
},
|
|
1128
|
+
)
|
|
1129
|
+
_raise_reasons(card_id, reasons, clash)
|
|
1130
|
+
if checkout:
|
|
1131
|
+
self._verification_repo(card, checkout)
|
|
1132
|
+
info = repos.checkout_info(checkout) if checkout else None
|
|
1133
|
+
if info is not None and runner_base:
|
|
1134
|
+
info["runner_base"] = runner_base
|
|
1135
|
+
lease = self._grant(card, owner, card["locks"], None, self.ttl_seconds(ttl_minutes), checkout=info)
|
|
1136
|
+
card.update(owner=owner, lane="doing", fence=lease["fence"], released=False, rev=card.get("rev", 0) + 1)
|
|
1137
|
+
text = f"claimed by {owner} (fence {lease['fence']}); lease until {lease['expires']}"
|
|
1138
|
+
text += f"; taken over from {prev}" if prev else ""
|
|
1139
|
+
self._note(card, caller.name, text)
|
|
1140
|
+
self._save_card(card)
|
|
1141
|
+
self._touch(owner)
|
|
1142
|
+
self._record(
|
|
1143
|
+
"status",
|
|
1144
|
+
owner,
|
|
1145
|
+
text,
|
|
1146
|
+
task=card_id,
|
|
1147
|
+
data={"a2a_state": "working", "fence": lease["fence"], "locks": card["locks"]},
|
|
1148
|
+
)
|
|
1149
|
+
self.register(refresh=False)
|
|
1150
|
+
token = I.read_token(self.home, self.uid, owner) if owner != self.lead else ""
|
|
1151
|
+
cap = I.make_cap(token, owner, card_id, lease["fence"]) if token else ""
|
|
1152
|
+
return {
|
|
1153
|
+
"card": card_id,
|
|
1154
|
+
"agent": owner,
|
|
1155
|
+
"fence": lease["fence"],
|
|
1156
|
+
"expires": lease["expires"],
|
|
1157
|
+
"paths": lease["paths"],
|
|
1158
|
+
"checkout": lease["checkout"],
|
|
1159
|
+
"branch": lease["branch"],
|
|
1160
|
+
"taken_over_from": prev,
|
|
1161
|
+
"cap": cap,
|
|
1162
|
+
**({"warnings": warnings} if warnings else {}),
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
@serialized_write
|
|
1166
|
+
def heartbeat(self, card_id, caller, ttl_minutes=None):
|
|
1167
|
+
"""Renew the card's lease (owner with fence, or lead). A doing lease renewed after expiry is re-granted and
|
|
1168
|
+
logged as resumed."""
|
|
1169
|
+
with self.lock():
|
|
1170
|
+
card = self.card(card_id)
|
|
1171
|
+
self._owner_or_lead(card, caller)
|
|
1172
|
+
if card["lane"] not in model.HOLD_LANES:
|
|
1173
|
+
raise Precondition(f"{card_id} is in {card['lane']}; nothing to renew", card=card_id)
|
|
1174
|
+
lease, resumed = self._renew(card, ttl_minutes)
|
|
1175
|
+
self._touch(caller.name)
|
|
1176
|
+
return {"card": card_id, "fence": card["fence"], "expires": lease["expires"], "resumed": resumed}
|
|
1177
|
+
|
|
1178
|
+
def _renew(self, card, ttl_minutes=None):
|
|
1179
|
+
lease = self.index.lease(self.uid, card["id"])
|
|
1180
|
+
if card["lane"] != "doing":
|
|
1181
|
+
if not lease:
|
|
1182
|
+
raise Precondition(f"{card['id']} holds no lease; claim it again", card=card["id"])
|
|
1183
|
+
return self._update_lease(card["id"], renewed=clock.iso()), False
|
|
1184
|
+
if lease and L.is_live(lease):
|
|
1185
|
+
return self._update_lease(
|
|
1186
|
+
card["id"], renewed=clock.iso(), expires=clock.later(self.ttl_seconds(ttl_minutes))
|
|
1187
|
+
), False
|
|
1188
|
+
lease = self._grant(
|
|
1189
|
+
card,
|
|
1190
|
+
card["owner"],
|
|
1191
|
+
card["locks"],
|
|
1192
|
+
card["fence"],
|
|
1193
|
+
self.ttl_seconds(ttl_minutes),
|
|
1194
|
+
since=(lease or {}).get("since"),
|
|
1195
|
+
)
|
|
1196
|
+
self._note(card, card["owner"], "lease resumed after expiry")
|
|
1197
|
+
self._save_card(card)
|
|
1198
|
+
self._record("status", card["owner"], "lease resumed after expiry", task=card["id"])
|
|
1199
|
+
return lease, True
|
|
1200
|
+
|
|
1201
|
+
@serialized_write
|
|
1202
|
+
def note(self, card_id, caller, text):
|
|
1203
|
+
"""Append a note. The owner must pass its fence; an owner note renews the lease. The lead secret alone
|
|
1204
|
+
notes on any card, its own included, without renewing a lease."""
|
|
1205
|
+
text = model.line_text(T.redact(text), "note")
|
|
1206
|
+
if not text:
|
|
1207
|
+
raise Precondition("note text is empty")
|
|
1208
|
+
with self.lock():
|
|
1209
|
+
card = self.card(card_id)
|
|
1210
|
+
self._auth(caller, card_id)
|
|
1211
|
+
is_owner = bool(card["owner"]) and caller.name == card["owner"]
|
|
1212
|
+
if is_owner and caller.is_lead and not caller.fence:
|
|
1213
|
+
is_owner = False
|
|
1214
|
+
if is_owner:
|
|
1215
|
+
self._owner_or_lead(card, caller)
|
|
1216
|
+
card["rev"] = card.get("rev", 0) + 1
|
|
1217
|
+
self._note(card, caller.name, text)
|
|
1218
|
+
self._save_card(card)
|
|
1219
|
+
if is_owner and card["lane"] in model.HOLD_LANES:
|
|
1220
|
+
self._renew(card)
|
|
1221
|
+
self._touch(caller.name)
|
|
1222
|
+
self._record("note", caller.name, text, task=card_id)
|
|
1223
|
+
delivered = self._lead_note_to_owner(card, caller, text)
|
|
1224
|
+
return {"card": card_id, "note": text, "delivered_to": delivered}
|
|
1225
|
+
|
|
1226
|
+
def _lead_note_to_owner(self, card, caller, text):
|
|
1227
|
+
owner = card.get("owner") or ""
|
|
1228
|
+
if not caller.is_lead or not owner or owner == caller.name or owner not in self.agents():
|
|
1229
|
+
return ""
|
|
1230
|
+
msg = M.make(
|
|
1231
|
+
caller.name,
|
|
1232
|
+
owner,
|
|
1233
|
+
f"lead note on {card['id']}: {text}",
|
|
1234
|
+
card["id"],
|
|
1235
|
+
"ready",
|
|
1236
|
+
True,
|
|
1237
|
+
self.agents().get(caller.name, {}).get("model", ""),
|
|
1238
|
+
)
|
|
1239
|
+
msg["source"] = "note"
|
|
1240
|
+
store.append_line(M.inbox_path(self.dir, owner), _dumps(msg))
|
|
1241
|
+
self.index.bump(self.uid)
|
|
1242
|
+
self._record(
|
|
1243
|
+
"msg",
|
|
1244
|
+
caller.name,
|
|
1245
|
+
msg["text"],
|
|
1246
|
+
task=card["id"],
|
|
1247
|
+
to=owner,
|
|
1248
|
+
message_id=msg["id"],
|
|
1249
|
+
data={"status": "ready", "urgent": True, "source": "note"},
|
|
1250
|
+
)
|
|
1251
|
+
return owner
|
|
1252
|
+
|
|
1253
|
+
@serialized_write
|
|
1254
|
+
def lock_paths(self, card_id, caller, paths, replace=False, remove=False):
|
|
1255
|
+
"""Add lock paths to a card; re-grants the lease atomically when the card holds one."""
|
|
1256
|
+
add = parse_list(paths)
|
|
1257
|
+
if not add:
|
|
1258
|
+
raise Precondition("no paths given")
|
|
1259
|
+
with self.lock():
|
|
1260
|
+
card = self.card(card_id)
|
|
1261
|
+
if replace or remove:
|
|
1262
|
+
self._auth(caller)
|
|
1263
|
+
self._require_lead(caller, "replace or remove locks")
|
|
1264
|
+
elif card["lane"] == "todo" and not card["owner"]:
|
|
1265
|
+
self._auth(caller)
|
|
1266
|
+
self._require_lead(caller, "change locks of an unclaimed card")
|
|
1267
|
+
else:
|
|
1268
|
+
self._owner_or_lead(card, caller)
|
|
1269
|
+
merged = sorted(
|
|
1270
|
+
set(add) if replace else set(card["locks"]) - set(add) if remove else set(card["locks"]) | set(add)
|
|
1271
|
+
)
|
|
1272
|
+
lease = self.index.lease(self.uid, card_id)
|
|
1273
|
+
if lease and card["lane"] in model.HOLD_LANES:
|
|
1274
|
+
self._grant(
|
|
1275
|
+
card,
|
|
1276
|
+
card["owner"],
|
|
1277
|
+
merged,
|
|
1278
|
+
card["fence"],
|
|
1279
|
+
self.ttl_seconds(),
|
|
1280
|
+
held=card["lane"] in ("review", "blocked"),
|
|
1281
|
+
since=lease["since"],
|
|
1282
|
+
)
|
|
1283
|
+
self._update_lease(card_id, expires=lease["expires"])
|
|
1284
|
+
else:
|
|
1285
|
+
self._resolver_check(merged)
|
|
1286
|
+
card["locks"] = merged
|
|
1287
|
+
card["rev"] = card.get("rev", 0) + 1
|
|
1288
|
+
self._note(card, caller.name, "locks now " + ", ".join(merged))
|
|
1289
|
+
self._record(
|
|
1290
|
+
"status",
|
|
1291
|
+
caller.name,
|
|
1292
|
+
"locks updated",
|
|
1293
|
+
task=card_id,
|
|
1294
|
+
data={"locks": merged, "replace": replace, "remove": remove, "fence": card["fence"]},
|
|
1295
|
+
)
|
|
1296
|
+
self._save_card(card)
|
|
1297
|
+
return {"card": card_id, "locks": merged}
|
|
1298
|
+
|
|
1299
|
+
def _resolver_check(self, paths):
|
|
1300
|
+
resolver = self._resolver()
|
|
1301
|
+
for p in paths:
|
|
1302
|
+
resolver.resolve(p)
|
|
1303
|
+
|
|
1304
|
+
@serialized_write
|
|
1305
|
+
def release(self, card_id, caller, note=None):
|
|
1306
|
+
"""Drop the card's lease. A doing card goes back to todo with no owner (A2A canceled)."""
|
|
1307
|
+
with self.lock():
|
|
1308
|
+
card = self.card(card_id)
|
|
1309
|
+
if caller.is_lead:
|
|
1310
|
+
self._auth(caller)
|
|
1311
|
+
else:
|
|
1312
|
+
self._owner_or_lead(card, caller)
|
|
1313
|
+
dropped = self._drop_lease(card_id)
|
|
1314
|
+
if card["lane"] == "doing":
|
|
1315
|
+
card.update(lane="todo", owner="", released=True)
|
|
1316
|
+
self._note(card, caller.name, "lease released" + (f" ({_line(note)})" if note else ""))
|
|
1317
|
+
self._save_card(card)
|
|
1318
|
+
self._record(
|
|
1319
|
+
"status", caller.name, "lease released", task=card_id, data={"a2a_state": model.a2a_state(card)}
|
|
1320
|
+
)
|
|
1321
|
+
return {"card": card_id, "released": dropped, "lane": card["lane"]}
|
|
1322
|
+
|
|
1323
|
+
@serialized_write
|
|
1324
|
+
def close_with_evidence(self, card_id, caller, evidence, note):
|
|
1325
|
+
self._auth(caller)
|
|
1326
|
+
self._require_lead(caller, "close a card with evidence")
|
|
1327
|
+
evidence = model.line_text(evidence, "evidence")
|
|
1328
|
+
sha = re.fullmatch(r"[0-9a-f]{7,40}", evidence)
|
|
1329
|
+
try:
|
|
1330
|
+
url = urlsplit(evidence)
|
|
1331
|
+
valid = sha or (url.scheme == "https" and bool(url.hostname) and not any(c.isspace() for c in evidence))
|
|
1332
|
+
except ValueError:
|
|
1333
|
+
valid = False
|
|
1334
|
+
if not valid:
|
|
1335
|
+
raise Precondition(
|
|
1336
|
+
"evidence must be an https URL or a commit in the card's repo; use --evidence https://host/path or SHA"
|
|
1337
|
+
)
|
|
1338
|
+
note = model.line_text(note, "note")
|
|
1339
|
+
if not note:
|
|
1340
|
+
raise Precondition("closing with evidence requires a note; use --note <text>")
|
|
1341
|
+
with self.lock():
|
|
1342
|
+
card = self.card(card_id)
|
|
1343
|
+
if card["lane"] not in ("todo", "doing", "review", "blocked"):
|
|
1344
|
+
raise Precondition(f"{card_id} is already {card['lane']}")
|
|
1345
|
+
if sha:
|
|
1346
|
+
evidence = "commit:" + self._evidence_commit(card, evidence)
|
|
1347
|
+
self._drop_lease(card_id)
|
|
1348
|
+
card.update(lane="done", evidence=evidence, rev=card.get("rev", 0) + 1)
|
|
1349
|
+
text = f"closed with evidence {evidence}: {note}"
|
|
1350
|
+
self._note(card, caller.name, text)
|
|
1351
|
+
self._save_card(card)
|
|
1352
|
+
self._record(
|
|
1353
|
+
"status",
|
|
1354
|
+
caller.name,
|
|
1355
|
+
text,
|
|
1356
|
+
task=card_id,
|
|
1357
|
+
data={"lane": "done", "a2a_state": "completed", "evidence": evidence, "note": note},
|
|
1358
|
+
)
|
|
1359
|
+
return {"card": card_id, "lane": "done", "a2a_state": "completed", "evidence": evidence}
|
|
1360
|
+
|
|
1361
|
+
def _evidence_commit(self, card, ref):
|
|
1362
|
+
repo = self._card_repo(card)
|
|
1363
|
+
if not repo:
|
|
1364
|
+
raise Precondition(f"{card['id']} has no repo to find commit {ref} in; set one with raid edit --repo")
|
|
1365
|
+
from ..runners import workspace
|
|
1366
|
+
|
|
1367
|
+
found = workspace.git_run(str(repo), "rev-parse", "--verify", "--quiet", ref + "^{commit}", timeout=30)
|
|
1368
|
+
if found.returncode:
|
|
1369
|
+
raise Precondition(f"commit {ref} is not in {repo}; use a commit from the card's repo or an https URL")
|
|
1370
|
+
return found.stdout.strip()
|
|
1371
|
+
|
|
1372
|
+
@serialized_write
|
|
1373
|
+
def cancel(self, card_id, caller, reason):
|
|
1374
|
+
self._auth(caller)
|
|
1375
|
+
self._require_lead(caller, "cancel a card")
|
|
1376
|
+
reason = model.line_text(reason, "reason")
|
|
1377
|
+
if not reason:
|
|
1378
|
+
raise Precondition("cancellation requires a reason")
|
|
1379
|
+
with self.lock():
|
|
1380
|
+
card = self.card(card_id)
|
|
1381
|
+
if card["lane"] in ("done", "cancelled"):
|
|
1382
|
+
raise Precondition(f"{card_id} is already {card['lane']}")
|
|
1383
|
+
self._drop_lease(card_id)
|
|
1384
|
+
card.update(lane="cancelled", cancel_reason=reason, rev=card.get("rev", 0) + 1)
|
|
1385
|
+
self._note(card, caller.name, "cancelled: " + reason)
|
|
1386
|
+
self._save_card(card)
|
|
1387
|
+
self._record(
|
|
1388
|
+
"status",
|
|
1389
|
+
caller.name,
|
|
1390
|
+
"cancelled: " + reason,
|
|
1391
|
+
task=card_id,
|
|
1392
|
+
data={"lane": "cancelled", "a2a_state": "canceled", "reason": reason},
|
|
1393
|
+
)
|
|
1394
|
+
return {"card": card_id, "lane": "cancelled", "reason": reason}
|
|
1395
|
+
|
|
1396
|
+
@serialized_write
|
|
1397
|
+
def move(self, card_id, lane, caller, note=None):
|
|
1398
|
+
"""Move a card. doing is reachable only through claim; done is lead-only and terminal."""
|
|
1399
|
+
model.lane(lane)
|
|
1400
|
+
note = _line(note) if note else ""
|
|
1401
|
+
if lane == "cancelled":
|
|
1402
|
+
raise Precondition(f"use raidlock cancel {card_id} --reason <reason>")
|
|
1403
|
+
if lane == "doing":
|
|
1404
|
+
raise Denied("only claim moves a card into doing", card=card_id, hint=f"raidlock claim {card_id}")
|
|
1405
|
+
with self.lock():
|
|
1406
|
+
card = self.card(card_id)
|
|
1407
|
+
if card["lane"] in ("done", "cancelled"):
|
|
1408
|
+
raise Precondition(
|
|
1409
|
+
f"{card_id} is {card['lane']}", card=card_id, hint=f"raidlock reopen {card_id} (lead)"
|
|
1410
|
+
)
|
|
1411
|
+
if card["lane"] == lane:
|
|
1412
|
+
raise Precondition(f"{card_id} is already in {lane}", card=card_id)
|
|
1413
|
+
role = self._move_checks(card, lane, caller)
|
|
1414
|
+
self._apply_move(card, lane, role)
|
|
1415
|
+
self._note(card, caller.name, f"-> {lane}" + (f" ({note})" if note else ""))
|
|
1416
|
+
self._save_card(card)
|
|
1417
|
+
self._record(
|
|
1418
|
+
"status",
|
|
1419
|
+
caller.name,
|
|
1420
|
+
f"{card_id} -> {lane}" + (f": {note}" if note else ""),
|
|
1421
|
+
task=card_id,
|
|
1422
|
+
data={"a2a_state": model.a2a_state(card), "lane": lane},
|
|
1423
|
+
)
|
|
1424
|
+
return {"card": card_id, "lane": lane, "a2a_state": model.a2a_state(card), "owner": card["owner"]}
|
|
1425
|
+
|
|
1426
|
+
def _move_checks(self, card, lane, caller):
|
|
1427
|
+
if lane == "done":
|
|
1428
|
+
self._auth(caller)
|
|
1429
|
+
self._require_lead(caller, "move a card to done")
|
|
1430
|
+
self._done_gate(card)
|
|
1431
|
+
return "lead"
|
|
1432
|
+
if lane in ("review", "blocked") and card["lane"] == "todo":
|
|
1433
|
+
raise Precondition(
|
|
1434
|
+
f"{card['id']} is in todo; run raidlock claim {card['id']} before moving to {lane}",
|
|
1435
|
+
card=card["id"],
|
|
1436
|
+
hint=f"raidlock claim {card['id']}",
|
|
1437
|
+
)
|
|
1438
|
+
return self._owner_or_lead(card, caller)
|
|
1439
|
+
|
|
1440
|
+
def _done_gate(self, card):
|
|
1441
|
+
from ..runners import workspace
|
|
1442
|
+
from . import receipts
|
|
1443
|
+
|
|
1444
|
+
settings = self.settings
|
|
1445
|
+
explicit_off = "require_verify" in self.meta.get("explicit_settings", []) and not settings["require_verify"]
|
|
1446
|
+
if not explicit_off and (settings["require_verify"] or card.get("verify_cmd")):
|
|
1447
|
+
last = model.last_verification(card)
|
|
1448
|
+
if not last or not receipts.valid(self, card, last):
|
|
1449
|
+
raise Precondition(
|
|
1450
|
+
f"{card['id']} needs a valid verification receipt", card=card["id"], hint=self._verify_hint(card)
|
|
1451
|
+
)
|
|
1452
|
+
if not last.get("passed") or last.get("exit") != 0:
|
|
1453
|
+
raise Precondition(
|
|
1454
|
+
f"the last verify run of {card['id']} failed",
|
|
1455
|
+
card=card["id"],
|
|
1456
|
+
hint=f"fix the work, commit, then raidlock verify {card['id']}",
|
|
1457
|
+
)
|
|
1458
|
+
where = last.get("cwd")
|
|
1459
|
+
if not where:
|
|
1460
|
+
raise Precondition(f"{card['id']} verification receipt has no checkout")
|
|
1461
|
+
actual = self._verification_repo(card, where)
|
|
1462
|
+
leased_info = (self.index.lease(self.uid, card["id"]) or {}).get("checkout") or {}
|
|
1463
|
+
leased = leased_info.get("path")
|
|
1464
|
+
base = leased_info.get("runner_base")
|
|
1465
|
+
landed = bool(base) and workspace.is_ancestor(where, base, last.get("sha", ""))
|
|
1466
|
+
if leased and (
|
|
1467
|
+
self._verification_repo(card, leased) != actual
|
|
1468
|
+
or (not landed and workspace.head(leased) != last.get("sha"))
|
|
1469
|
+
):
|
|
1470
|
+
raise Precondition(f"{card['id']} verification receipt does not match the leased repo commit")
|
|
1471
|
+
if last.get("repo") != actual:
|
|
1472
|
+
raise Precondition(f"{card['id']} verification receipt repo does not match the card")
|
|
1473
|
+
if last.get("sha") != workspace.head(where):
|
|
1474
|
+
raise Precondition(f"{card['id']} verification receipt SHA does not match current HEAD")
|
|
1475
|
+
need = dict(self.settings["require_review"])
|
|
1476
|
+
if card.get("allow_protected"):
|
|
1477
|
+
need.update(count=max(1, need["count"]), other_provider=True)
|
|
1478
|
+
got = self.approvals(card)
|
|
1479
|
+
if len(got) < need["count"]:
|
|
1480
|
+
raise Precondition(
|
|
1481
|
+
f"{card['id']} needs {need['count']} approving review(s)"
|
|
1482
|
+
+ (" from another provider" if need["other_provider"] else "")
|
|
1483
|
+
+ f"; has {len(got)}",
|
|
1484
|
+
card=card["id"],
|
|
1485
|
+
approvals=got,
|
|
1486
|
+
hint=f"a reviewer on {'another' if need['other_provider'] else 'any'} provider runs raidlock review "
|
|
1487
|
+
f"{card['id']} --verdict approve; to request one: "
|
|
1488
|
+
+ lead_cmd(self, f"review {card['id']} --assign <reviewer>"),
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
def _apply_move(self, card, lane, role):
|
|
1492
|
+
if lane in ("done", "todo"):
|
|
1493
|
+
self._drop_lease(card["id"])
|
|
1494
|
+
if lane == "todo":
|
|
1495
|
+
card.update(owner="", released=True)
|
|
1496
|
+
if lane in ("review", "blocked"):
|
|
1497
|
+
lease = self.index.lease(self.uid, card["id"])
|
|
1498
|
+
if lease and card["lane"] not in ("review", "blocked"):
|
|
1499
|
+
lease["keys"] = [{**key, "advisory": True} for key in lease["keys"]]
|
|
1500
|
+
lease.update(expires=clock.later(float(self.settings["hold_minutes"]) * 60), renewed=clock.iso())
|
|
1501
|
+
self.index.put_lease(self.uid, lease)
|
|
1502
|
+
if role == "owner":
|
|
1503
|
+
card["rev"] = card.get("rev", 0) + 1
|
|
1504
|
+
card["lane"] = lane
|
|
1505
|
+
|
|
1506
|
+
def approvals(self, card, agents=None):
|
|
1507
|
+
"""Approving reviewers that count toward `require_review`: latest verdict per reviewer, after the owner's
|
|
1508
|
+
latest change."""
|
|
1509
|
+
need = dict(self.settings["require_review"])
|
|
1510
|
+
if card.get("allow_protected"):
|
|
1511
|
+
need["other_provider"] = True
|
|
1512
|
+
agents = self.agents() if agents is None else agents
|
|
1513
|
+
owner_provider = agents.get(card.get("owner"), {}).get("provider", "")
|
|
1514
|
+
latest = {}
|
|
1515
|
+
for r in card.get("reviews", []):
|
|
1516
|
+
if r.get("rev", -1) == card.get("rev", 0) and r["verdict"] != "comment":
|
|
1517
|
+
latest[r["by"]] = r
|
|
1518
|
+
out = []
|
|
1519
|
+
for name, r in sorted(latest.items()):
|
|
1520
|
+
if r["verdict"] != "approve" or name == card.get("owner"):
|
|
1521
|
+
continue
|
|
1522
|
+
if card.get("allow_protected") and (not owner_provider or not r.get("provider")):
|
|
1523
|
+
continue
|
|
1524
|
+
if need["other_provider"] and owner_provider and r.get("provider") == owner_provider:
|
|
1525
|
+
continue
|
|
1526
|
+
out.append(name)
|
|
1527
|
+
return out
|
|
1528
|
+
|
|
1529
|
+
@serialized_write
|
|
1530
|
+
def reopen(self, card_id, caller, note=None):
|
|
1531
|
+
"""Lead-only: move a done card back to todo."""
|
|
1532
|
+
self._auth(caller)
|
|
1533
|
+
self._require_lead(caller, "reopen a card")
|
|
1534
|
+
with self.lock():
|
|
1535
|
+
card = self.card(card_id)
|
|
1536
|
+
if card["lane"] not in ("done", "cancelled"):
|
|
1537
|
+
raise Precondition(f"{card_id} is in {card['lane']}, not done", card=card_id)
|
|
1538
|
+
card.update(lane="todo", owner="", released=False, rev=card.get("rev", 0) + 1)
|
|
1539
|
+
card.pop("cancel_reason", None)
|
|
1540
|
+
card.pop("evidence", None)
|
|
1541
|
+
self._note(card, caller.name, "reopened" + (f" ({_line(note)})" if note else ""))
|
|
1542
|
+
self._save_card(card)
|
|
1543
|
+
self._record("status", caller.name, f"{card_id} reopened", task=card_id, data={"a2a_state": "submitted"})
|
|
1544
|
+
return {"card": card_id, "lane": "todo"}
|
|
1545
|
+
|
|
1546
|
+
@serialized_write
|
|
1547
|
+
def review(self, card_id, caller, verdict, note=""):
|
|
1548
|
+
"""Record a review of a card in review. `changes` sends it back to doing (owner and fence unchanged)."""
|
|
1549
|
+
if verdict not in model.VERDICTS:
|
|
1550
|
+
raise Precondition(f"verdict must be one of {', '.join(model.VERDICTS)}", verdict=verdict)
|
|
1551
|
+
note = model.line_text(T.redact(note or ""), "note")
|
|
1552
|
+
with self.lock():
|
|
1553
|
+
self._auth(caller)
|
|
1554
|
+
card = self.card(card_id)
|
|
1555
|
+
if card["lane"] != "review":
|
|
1556
|
+
raise Precondition(f"{card_id} is in {card['lane']}, not review", card=card_id)
|
|
1557
|
+
if caller.name == card["owner"]:
|
|
1558
|
+
raise Denied("owners cannot review their own card", card=card_id)
|
|
1559
|
+
if card.get("reviewers") and caller.name not in card["reviewers"] and not caller.is_lead:
|
|
1560
|
+
raise Denied(f"{card_id} is assigned to reviewers {', '.join(card['reviewers'])}", card=card_id)
|
|
1561
|
+
agent = self.agents().get(caller.name, {})
|
|
1562
|
+
rec = {
|
|
1563
|
+
"ts": clock.iso(),
|
|
1564
|
+
"by": caller.name,
|
|
1565
|
+
"verdict": verdict,
|
|
1566
|
+
"note": note,
|
|
1567
|
+
"rev": card.get("rev", 0),
|
|
1568
|
+
"provider": agent.get("provider", ""),
|
|
1569
|
+
"model": agent.get("model", ""),
|
|
1570
|
+
}
|
|
1571
|
+
card.setdefault("reviews", []).append(rec)
|
|
1572
|
+
self._note(card, caller.name, f"review {verdict}" + (f": {note}" if note else ""))
|
|
1573
|
+
if verdict == "changes":
|
|
1574
|
+
self._back_to_doing(card)
|
|
1575
|
+
self._save_card(card)
|
|
1576
|
+
self._record(
|
|
1577
|
+
"review",
|
|
1578
|
+
caller.name,
|
|
1579
|
+
note,
|
|
1580
|
+
task=card_id,
|
|
1581
|
+
to=card["owner"],
|
|
1582
|
+
artifacts=[T.artifact("review", note, data={"verdict": verdict, "rev": rec["rev"]}, kind="review")],
|
|
1583
|
+
)
|
|
1584
|
+
return {"card": card_id, "verdict": verdict, "lane": card["lane"], "approvals": self.approvals(card)}
|
|
1585
|
+
|
|
1586
|
+
def _back_to_doing(self, card):
|
|
1587
|
+
if not card["owner"]:
|
|
1588
|
+
card["lane"] = "todo"
|
|
1589
|
+
return
|
|
1590
|
+
if not self.index.lease(self.uid, card["id"]):
|
|
1591
|
+
card["lane"] = "blocked"
|
|
1592
|
+
return
|
|
1593
|
+
card["lane"] = "doing"
|
|
1594
|
+
try:
|
|
1595
|
+
self._grant(card, card["owner"], card["locks"], card["fence"], self.ttl_seconds())
|
|
1596
|
+
except Denied:
|
|
1597
|
+
card["lane"] = "blocked"
|
|
1598
|
+
|
|
1599
|
+
@serialized_write
|
|
1600
|
+
def assign_reviewers(self, card_id, caller, names):
|
|
1601
|
+
"""Owner (with fence) or lead sets the card's reviewers. Returns warnings for same-provider reviewers."""
|
|
1602
|
+
names = parse_words(names)
|
|
1603
|
+
with self.lock():
|
|
1604
|
+
card = self.card(card_id)
|
|
1605
|
+
self._owner_or_lead(card, caller)
|
|
1606
|
+
agents = self.agents()
|
|
1607
|
+
for n in names:
|
|
1608
|
+
if n not in agents:
|
|
1609
|
+
raise Precondition(f"{n} has not joined", agent=n)
|
|
1610
|
+
if n == card["owner"]:
|
|
1611
|
+
raise Precondition("the owner cannot review its own card", agent=n)
|
|
1612
|
+
owner_provider = agents.get(card["owner"], {}).get("provider", "")
|
|
1613
|
+
warnings = [
|
|
1614
|
+
f"{n} has the same provider as the owner ({owner_provider})"
|
|
1615
|
+
for n in names
|
|
1616
|
+
if owner_provider and agents[n].get("provider") == owner_provider
|
|
1617
|
+
]
|
|
1618
|
+
card["reviewers"] = names
|
|
1619
|
+
self._note(card, caller.name, "reviewers: " + (", ".join(names) or "-"))
|
|
1620
|
+
self._save_card(card)
|
|
1621
|
+
return {"card": card_id, "reviewers": names, "warnings": warnings}
|
|
1622
|
+
|
|
1623
|
+
def _card_repo(self, card):
|
|
1624
|
+
from ..runners import policy
|
|
1625
|
+
|
|
1626
|
+
if card.get("repo"):
|
|
1627
|
+
target = self._resolver().repos.get(card["repo"])
|
|
1628
|
+
if not target:
|
|
1629
|
+
raise Precondition(f"unknown workspace repo {card['repo']!r}")
|
|
1630
|
+
if card.get("locks") and policy.target(self.workspace, self.dir, card["locks"])["path"] != target:
|
|
1631
|
+
raise Precondition("card repo and locks name different repositories")
|
|
1632
|
+
return target
|
|
1633
|
+
if card.get("locks"):
|
|
1634
|
+
return policy.target(self.workspace, self.dir, card["locks"])["path"]
|
|
1635
|
+
found = self._resolver().repos
|
|
1636
|
+
return repos.find_root(self.workspace) or (next(iter(found.values())) if len(found) == 1 else None)
|
|
1637
|
+
|
|
1638
|
+
def _verification_source(self, card):
|
|
1639
|
+
lease = self.index.lease(self.uid, card["id"]) or {}
|
|
1640
|
+
target = self._card_repo(card)
|
|
1641
|
+
root = repos.find_root(self.workspace)
|
|
1642
|
+
default = root if root and target and repos.identity(root) == repos.identity(target) else target
|
|
1643
|
+
return (
|
|
1644
|
+
(lease.get("checkout") or {}).get("path") or card.get("verification_checkout") or default or self.workspace
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
def trust_checkout(self, caller, card_id, checkout, source):
|
|
1648
|
+
with self.lead_write(caller, "record checkout provenance"):
|
|
1649
|
+
expected = self._verification_repo(self.card(card_id), source)
|
|
1650
|
+
root = repos.find_root(checkout)
|
|
1651
|
+
if not root:
|
|
1652
|
+
raise Precondition("trusted checkout must be a repository")
|
|
1653
|
+
info = os.stat(root)
|
|
1654
|
+
path = self._p("checkout-provenance.json")
|
|
1655
|
+
records = store.read_json(path, {}) or {}
|
|
1656
|
+
records[os.path.realpath(root)] = {
|
|
1657
|
+
"repo": expected,
|
|
1658
|
+
"identity": repos.identity(root),
|
|
1659
|
+
"device": info.st_dev,
|
|
1660
|
+
"inode": info.st_ino,
|
|
1661
|
+
}
|
|
1662
|
+
store.write_json(path, records)
|
|
1663
|
+
|
|
1664
|
+
def _verification_repo(self, card, where):
|
|
1665
|
+
target = self._card_repo(card)
|
|
1666
|
+
root = repos.find_root(where)
|
|
1667
|
+
if not target:
|
|
1668
|
+
raise Precondition(
|
|
1669
|
+
"the card has no repo to verify",
|
|
1670
|
+
hint=f"raidlock edit {card['id']} --repo <workspace-repo>; then verify with --cwd <checkout>",
|
|
1671
|
+
)
|
|
1672
|
+
expected = repos.identity(target)
|
|
1673
|
+
if root and repos.identity(root) == expected:
|
|
1674
|
+
return expected
|
|
1675
|
+
if root:
|
|
1676
|
+
provenance = store.read_json(os.path.join(self.dir, "checkout-provenance.json"), {}) or {}
|
|
1677
|
+
record = provenance.get(os.path.realpath(root), {})
|
|
1678
|
+
info = os.stat(root)
|
|
1679
|
+
if record == {
|
|
1680
|
+
"repo": expected,
|
|
1681
|
+
"identity": repos.identity(root),
|
|
1682
|
+
"device": info.st_dev,
|
|
1683
|
+
"inode": info.st_ino,
|
|
1684
|
+
}:
|
|
1685
|
+
return expected
|
|
1686
|
+
common = os.path.realpath(repos.common_dir(repos.git_dir(root)))
|
|
1687
|
+
target_common = os.path.realpath(repos.common_dir(repos.git_dir(target)))
|
|
1688
|
+
if common == target_common:
|
|
1689
|
+
return expected
|
|
1690
|
+
url = repos._read_remotes(os.path.join(common, "config")).get("origin", "")
|
|
1691
|
+
parts = urlsplit(url)
|
|
1692
|
+
from urllib.request import url2pathname
|
|
1693
|
+
|
|
1694
|
+
local = url2pathname(parts.path) if parts.scheme == "file" and parts.netloc in ("", "localhost") else url
|
|
1695
|
+
if local and not os.path.isabs(local):
|
|
1696
|
+
local = os.path.join(root, local)
|
|
1697
|
+
if (
|
|
1698
|
+
not repos.remote_url(target)
|
|
1699
|
+
and local
|
|
1700
|
+
and os.path.isdir(local)
|
|
1701
|
+
and os.path.realpath(local) == os.path.realpath(target)
|
|
1702
|
+
):
|
|
1703
|
+
return expected
|
|
1704
|
+
raise Precondition(
|
|
1705
|
+
f"verification checkout {where} does not belong to the card's repo ({target})",
|
|
1706
|
+
hint=f"use --checkout {target} for claim, or --cwd {target} for verify",
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
def verify(self, card_id, caller, timeout=600, cwd=None):
|
|
1710
|
+
from ..runners import isolation, verifycache, workspace
|
|
1711
|
+
from . import receipts
|
|
1712
|
+
|
|
1713
|
+
self._auth(caller)
|
|
1714
|
+
self._require_lead(caller, "run verify")
|
|
1715
|
+
card = self.card(card_id)
|
|
1716
|
+
cmd = card.get("verify_cmd")
|
|
1717
|
+
if not cmd:
|
|
1718
|
+
raise Precondition(
|
|
1719
|
+
f"{card_id} has no verify command",
|
|
1720
|
+
card=card_id,
|
|
1721
|
+
hint=lead_cmd(self, f'edit {card_id} --verify-cmd "<command>"'),
|
|
1722
|
+
)
|
|
1723
|
+
kind = isolation.mode()
|
|
1724
|
+
if kind is None:
|
|
1725
|
+
raise Precondition("verification requires an isolated verification environment")
|
|
1726
|
+
where = os.path.realpath(cwd or self._verification_source(card))
|
|
1727
|
+
if workspace.git(where, "status", "--porcelain", "--untracked-files=all").strip():
|
|
1728
|
+
raise Precondition(
|
|
1729
|
+
"verification checkout is dirty; receipts are bound to a commit",
|
|
1730
|
+
hint=f"raidlock verify {card_id} --cwd <clean checkout of the card branch>",
|
|
1731
|
+
)
|
|
1732
|
+
repo = self._verification_repo(card, where)
|
|
1733
|
+
sha = workspace.head(where)
|
|
1734
|
+
with verifycache.checkout(where, sha, os.path.join(self.home, "verify"), timeout=timeout + 60) as (
|
|
1735
|
+
clone,
|
|
1736
|
+
gitdir,
|
|
1737
|
+
):
|
|
1738
|
+
exit_code, tail, ms = isolation.run(
|
|
1739
|
+
cmd,
|
|
1740
|
+
clone,
|
|
1741
|
+
self.env,
|
|
1742
|
+
timeout,
|
|
1743
|
+
kind=kind,
|
|
1744
|
+
deny_read=isolation.hidden(self.env, [self.home, self.dir]),
|
|
1745
|
+
readable=[gitdir],
|
|
1746
|
+
redact=T.redact,
|
|
1747
|
+
)
|
|
1748
|
+
rec = {
|
|
1749
|
+
"ts": clock.iso(),
|
|
1750
|
+
"by": caller.name,
|
|
1751
|
+
"cmd": cmd,
|
|
1752
|
+
"cwd": where,
|
|
1753
|
+
"repo": repo,
|
|
1754
|
+
"exit": exit_code,
|
|
1755
|
+
"passed": exit_code == 0,
|
|
1756
|
+
"tail": tail,
|
|
1757
|
+
"duration_ms": ms,
|
|
1758
|
+
"rev": card.get("rev", 0),
|
|
1759
|
+
"card": card_id,
|
|
1760
|
+
"board": self.uid,
|
|
1761
|
+
"sha": sha,
|
|
1762
|
+
"command_hash": receipts.command_hash(cmd),
|
|
1763
|
+
"runner": "raidlock",
|
|
1764
|
+
"isolation": kind,
|
|
1765
|
+
"owner": card.get("owner", ""),
|
|
1766
|
+
}
|
|
1767
|
+
with self.lock():
|
|
1768
|
+
self._require_lead(caller, "record verify result")
|
|
1769
|
+
current = self.card(card_id)
|
|
1770
|
+
if current.get("verify_cmd") != cmd:
|
|
1771
|
+
raise Precondition("verify command changed during verification")
|
|
1772
|
+
rec = receipts.sign(self, rec)
|
|
1773
|
+
current["verification_checkout"] = where
|
|
1774
|
+
current.setdefault("verifications", []).append(rec)
|
|
1775
|
+
self._note(current, caller.name, f"verify {'passed' if rec['passed'] else 'failed'} (exit {exit_code})")
|
|
1776
|
+
self._save_card(current)
|
|
1777
|
+
self._record(
|
|
1778
|
+
"verify",
|
|
1779
|
+
caller.name,
|
|
1780
|
+
f"verify {'passed' if rec['passed'] else 'failed'}: {cmd}",
|
|
1781
|
+
task=card_id,
|
|
1782
|
+
artifacts=[T.artifact("verify", tail, data=rec, kind="verify")],
|
|
1783
|
+
)
|
|
1784
|
+
return {"card": card_id, **rec}
|
|
1785
|
+
|
|
1786
|
+
def reap(self, caller=None):
|
|
1787
|
+
"""Drop expired leases; doing cards become blocked, review/blocked cards keep their lane."""
|
|
1788
|
+
reaped = []
|
|
1789
|
+
with self.lock():
|
|
1790
|
+
leases = self.leases()
|
|
1791
|
+
for card_id, lease in sorted(leases.items()):
|
|
1792
|
+
try:
|
|
1793
|
+
card = self.card(card_id)
|
|
1794
|
+
except Precondition:
|
|
1795
|
+
self._drop_lease(card_id)
|
|
1796
|
+
continue
|
|
1797
|
+
if card["lane"] in ("doing", "review", "blocked") and not L.is_live(lease):
|
|
1798
|
+
self._drop_lease(card_id)
|
|
1799
|
+
if card["lane"] == "doing":
|
|
1800
|
+
card["lane"] = "blocked"
|
|
1801
|
+
self._note(
|
|
1802
|
+
card,
|
|
1803
|
+
"raidlock",
|
|
1804
|
+
f"lease expired; lane {card['lane']} ({card['owner']} may claim again, "
|
|
1805
|
+
"a teammate may claim --take-over)",
|
|
1806
|
+
)
|
|
1807
|
+
self._save_card(card)
|
|
1808
|
+
self._record(
|
|
1809
|
+
"status",
|
|
1810
|
+
card["owner"],
|
|
1811
|
+
f"lease expired; reaped in {card['lane']}",
|
|
1812
|
+
task=card_id,
|
|
1813
|
+
data={"a2a_state": "input-required"},
|
|
1814
|
+
)
|
|
1815
|
+
reaped.append(card_id)
|
|
1816
|
+
return {"reaped": reaped}
|
|
1817
|
+
|
|
1818
|
+
def claimable(self, agent=None, for_review=False, cards=None):
|
|
1819
|
+
"""{card_id: {ok, reasons, takeover}} for open cards, or cards awaiting review when `for_review`."""
|
|
1820
|
+
cards = self.cards() if cards is None else cards
|
|
1821
|
+
if for_review:
|
|
1822
|
+
return self._review_queue(cards, agent)
|
|
1823
|
+
agents, teams, leases = self.agents(), self.teams(), self.leases()
|
|
1824
|
+
out = {}
|
|
1825
|
+
for card in cards:
|
|
1826
|
+
if card["lane"] in ("done", "review"):
|
|
1827
|
+
continue
|
|
1828
|
+
lease = leases.get(card["id"])
|
|
1829
|
+
if card["lane"] == "doing" and lease and L.is_live(lease) and card["owner"] != agent:
|
|
1830
|
+
continue
|
|
1831
|
+
try:
|
|
1832
|
+
reasons, takeover, _ = self._claim_reasons(card, agent, agents, teams, lease=lease or {})
|
|
1833
|
+
except Precondition as e:
|
|
1834
|
+
reasons, takeover = [(3, str(e))], ""
|
|
1835
|
+
out[card["id"]] = {
|
|
1836
|
+
"ok": not reasons,
|
|
1837
|
+
"reasons": [r for _, r in reasons],
|
|
1838
|
+
"takeover": takeover,
|
|
1839
|
+
"title": card["title"],
|
|
1840
|
+
"lane": card["lane"],
|
|
1841
|
+
}
|
|
1842
|
+
return out
|
|
1843
|
+
|
|
1844
|
+
def _review_queue(self, cards, agent):
|
|
1845
|
+
out = {}
|
|
1846
|
+
for card in cards:
|
|
1847
|
+
if card["lane"] != "review" or (agent and agent == card["owner"]):
|
|
1848
|
+
continue
|
|
1849
|
+
if agent and card.get("reviewers") and agent not in card["reviewers"]:
|
|
1850
|
+
continue
|
|
1851
|
+
out[card["id"]] = {
|
|
1852
|
+
"ok": True,
|
|
1853
|
+
"reasons": [],
|
|
1854
|
+
"takeover": "",
|
|
1855
|
+
"title": card["title"],
|
|
1856
|
+
"lane": "review",
|
|
1857
|
+
"owner": card["owner"],
|
|
1858
|
+
"reviewers": card.get("reviewers", []),
|
|
1859
|
+
"approvals": self.approvals(card),
|
|
1860
|
+
}
|
|
1861
|
+
return out
|
|
1862
|
+
|
|
1863
|
+
def check_paths(self, paths, card_id=None):
|
|
1864
|
+
"""Conflicts for lock paths against every live lease in every registered workspace."""
|
|
1865
|
+
return self.index.conflicts(self._keys(parse_list(paths)), self.uid, card_id or "")
|
|
1866
|
+
|
|
1867
|
+
@serialized_write
|
|
1868
|
+
def check_write(self, path, caller):
|
|
1869
|
+
"""Enforcement decision for writing `path` (absolute or relative to cwd): {allowed, reason, card, holder}.
|
|
1870
|
+
|
|
1871
|
+
A denial for a named caller is recorded as a transcript status event with data.event lock_violation."""
|
|
1872
|
+
decision = self._write_decision(path, caller)
|
|
1873
|
+
if not decision["allowed"] and caller.name:
|
|
1874
|
+
with self.lock():
|
|
1875
|
+
self._record(
|
|
1876
|
+
"status",
|
|
1877
|
+
caller.name,
|
|
1878
|
+
f"write denied: {decision['reason']}",
|
|
1879
|
+
task=decision["card"],
|
|
1880
|
+
data={
|
|
1881
|
+
"event": "lock_violation",
|
|
1882
|
+
"agent": caller.name,
|
|
1883
|
+
"path": os.path.abspath(path),
|
|
1884
|
+
"holder": decision["holder"],
|
|
1885
|
+
"reason": decision["reason"],
|
|
1886
|
+
},
|
|
1887
|
+
)
|
|
1888
|
+
return decision
|
|
1889
|
+
|
|
1890
|
+
def _write_decision(self, path, caller):
|
|
1891
|
+
try:
|
|
1892
|
+
self._auth(caller, scoped_ok=True)
|
|
1893
|
+
except (IdentityError, Denied, Precondition, StaleLead) as exc:
|
|
1894
|
+
return _decision(False, exc.message)
|
|
1895
|
+
if not caller.authenticated:
|
|
1896
|
+
return _decision(False, "writes require an authenticated caller")
|
|
1897
|
+
full = os.path.abspath(path)
|
|
1898
|
+
if ".board" in full.replace("\\", "/").split("/"):
|
|
1899
|
+
return _decision(False, "board files are written only by raidlock")
|
|
1900
|
+
loc = repos.locate(full)
|
|
1901
|
+
if not loc:
|
|
1902
|
+
return _decision(True, "outside any git repo")
|
|
1903
|
+
mine, others = [], []
|
|
1904
|
+
for workspace, ws, lease in self.index.holders_of(loc):
|
|
1905
|
+
own = lease.get("agent") == caller.name and ws == self.uid
|
|
1906
|
+
(mine if own else others).append((workspace, lease["card"], lease))
|
|
1907
|
+
for _, card_id, lease in mine:
|
|
1908
|
+
if caller.scope_card and caller.scope_card != card_id:
|
|
1909
|
+
return _decision(False, f"capability is scoped to {caller.scope_card}", card=card_id)
|
|
1910
|
+
if caller.is_lead or (caller.fence and caller.fence == lease.get("fence")):
|
|
1911
|
+
return _decision(True, f"inside the lease of {card_id}", card=card_id, holder=caller.name)
|
|
1912
|
+
return _decision(False, f"stale fence {caller.fence} for {card_id}", card=card_id, holder=caller.name)
|
|
1913
|
+
if others:
|
|
1914
|
+
ws, card_id, lease = others[0]
|
|
1915
|
+
return _decision(
|
|
1916
|
+
False, f"held by {lease['agent']} on {card_id} in workspace {ws}", card=card_id, holder=lease["agent"]
|
|
1917
|
+
)
|
|
1918
|
+
if caller.is_lead:
|
|
1919
|
+
return _decision(True, "lead; not held by anyone")
|
|
1920
|
+
return _decision(False, f"{caller.name or 'caller'} holds no lease covering this path")
|
|
1921
|
+
|
|
1922
|
+
def _busy(self, name):
|
|
1923
|
+
return next(
|
|
1924
|
+
(c["id"] for c in self.index.card_rows(self.uid) if c["owner"] == name and c["lane"] == "doing"), ""
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
@serialized_write
|
|
1928
|
+
def send(self, caller, to, text, task=None, urgent=False):
|
|
1929
|
+
"""Send a message. Queued while the recipient is in doing on another card; ready when about its doing card,
|
|
1930
|
+
when urgent (lead only) or when the recipient is idle. Returns the message; `dropped` marks a repeat."""
|
|
1931
|
+
self._auth(caller, scoped_ok=True)
|
|
1932
|
+
text = model.block_text(T.redact(text), "message", M.MAX_BYTES)
|
|
1933
|
+
if not text.strip():
|
|
1934
|
+
raise Precondition("message text is empty")
|
|
1935
|
+
if urgent:
|
|
1936
|
+
self._require_lead(caller, "send urgent messages")
|
|
1937
|
+
with self.lock():
|
|
1938
|
+
if to not in self.agents():
|
|
1939
|
+
raise Precondition(f"no agent {to} on this board", to=to)
|
|
1940
|
+
if task:
|
|
1941
|
+
self.card(task)
|
|
1942
|
+
dup = M.check_limits(self.dir, caller.name, to, task or "", text, clock.now())
|
|
1943
|
+
if dup:
|
|
1944
|
+
return {**dup, "dropped": True}
|
|
1945
|
+
status = M.initial_status(task, urgent, self._busy(to))
|
|
1946
|
+
msg = M.make(
|
|
1947
|
+
caller.name, to, text, task, status, urgent, self.agents().get(caller.name, {}).get("model", "")
|
|
1948
|
+
)
|
|
1949
|
+
store.append_line(M.inbox_path(self.dir, to), _dumps(msg))
|
|
1950
|
+
self.index.bump(self.uid)
|
|
1951
|
+
self._touch(caller.name)
|
|
1952
|
+
self._record(
|
|
1953
|
+
"msg",
|
|
1954
|
+
caller.name,
|
|
1955
|
+
text,
|
|
1956
|
+
task=task or "",
|
|
1957
|
+
to=to,
|
|
1958
|
+
message_id=msg["id"],
|
|
1959
|
+
data={"status": status, "urgent": bool(urgent)},
|
|
1960
|
+
)
|
|
1961
|
+
return {**msg, "dropped": False}
|
|
1962
|
+
|
|
1963
|
+
def inbox(self, caller, show_all=False, wait=0.0, interval=1.0):
|
|
1964
|
+
"""Messages for the caller: ready ones are returned and marked read; queued ones stay until it leaves doing.
|
|
1965
|
+
With `wait` > 0, blocks in-process until a message is ready (raises Timeout after `wait` seconds)."""
|
|
1966
|
+
self._auth(caller, scoped_ok=True)
|
|
1967
|
+
deadline = time.monotonic() + wait
|
|
1968
|
+
while True:
|
|
1969
|
+
result = self._read_inbox(caller, show_all)
|
|
1970
|
+
if result["messages"] or wait <= 0:
|
|
1971
|
+
return result
|
|
1972
|
+
if time.monotonic() >= deadline:
|
|
1973
|
+
raise Timeout(f"no message for {caller.name} after {wait:g}s", agent=caller.name)
|
|
1974
|
+
db.close_all()
|
|
1975
|
+
time.sleep(max(0.01, min(interval, deadline - time.monotonic())))
|
|
1976
|
+
|
|
1977
|
+
def _read_inbox(self, caller, show_all):
|
|
1978
|
+
with self.lock():
|
|
1979
|
+
self._auth(caller, scoped_ok=True)
|
|
1980
|
+
rows = M.load(self.dir, caller.name)
|
|
1981
|
+
busy = self._busy(caller.name)
|
|
1982
|
+
changed = M.promote(rows, busy)
|
|
1983
|
+
shown = [dict(m) for m in rows if show_all or m.get("status") == "ready"]
|
|
1984
|
+
for m in rows:
|
|
1985
|
+
if m.get("status") == "ready":
|
|
1986
|
+
m.update(status="read", read=clock.iso())
|
|
1987
|
+
changed = True
|
|
1988
|
+
if changed:
|
|
1989
|
+
M.save(self.dir, caller.name, rows)
|
|
1990
|
+
self.index.bump(self.uid)
|
|
1991
|
+
queued = sum(1 for m in rows if m.get("status") == "queued")
|
|
1992
|
+
return {"agent": caller.name, "messages": shown, "queued": queued, "busy": busy}
|
|
1993
|
+
|
|
1994
|
+
def take_urgent(self, agent):
|
|
1995
|
+
"""Urgent messages for `agent` not yet read, marked read as delivered by the hook. Queued (non-urgent)
|
|
1996
|
+
messages stay queued. Used by the PreToolUse hook at the agent's next tool call, even in doing."""
|
|
1997
|
+
if not agent or agent not in self.agents():
|
|
1998
|
+
return []
|
|
1999
|
+
with self.lock():
|
|
2000
|
+
rows = M.load(self.dir, agent)
|
|
2001
|
+
out = []
|
|
2002
|
+
for m in rows:
|
|
2003
|
+
if m.get("urgent") and m.get("status") in ("ready", "queued"):
|
|
2004
|
+
m.update(status="read", read=clock.iso(), delivered="hook")
|
|
2005
|
+
out.append(dict(m))
|
|
2006
|
+
if out:
|
|
2007
|
+
M.save(self.dir, agent, rows)
|
|
2008
|
+
self.index.bump(self.uid)
|
|
2009
|
+
for m in out:
|
|
2010
|
+
self._record(
|
|
2011
|
+
"status",
|
|
2012
|
+
agent,
|
|
2013
|
+
f"urgent message {m['id']} delivered by hook",
|
|
2014
|
+
task=m.get("task", ""),
|
|
2015
|
+
data={"event": "urgent_delivered", "message_id": m["id"], "from": m.get("from", "")},
|
|
2016
|
+
)
|
|
2017
|
+
return out
|
|
2018
|
+
|
|
2019
|
+
def agent_for_session(self, session):
|
|
2020
|
+
"""The agent whose recorded session is `session` (from join), or ""."""
|
|
2021
|
+
if not session:
|
|
2022
|
+
return ""
|
|
2023
|
+
hits = sorted(n for n, a in self.agents().items() if a.get("session") == session)
|
|
2024
|
+
return hits[0] if len(hits) == 1 else ""
|
|
2025
|
+
|
|
2026
|
+
def wait(self, card_id, timeout=1800.0, interval=5.0):
|
|
2027
|
+
"""Block until the card leaves doing; returns its lane. Raises Timeout (exit 4)."""
|
|
2028
|
+
deadline = time.monotonic() + timeout
|
|
2029
|
+
while True:
|
|
2030
|
+
lane = self.card(card_id)["lane"]
|
|
2031
|
+
if lane != "doing":
|
|
2032
|
+
return {"card": card_id, "lane": lane}
|
|
2033
|
+
if time.monotonic() >= deadline:
|
|
2034
|
+
raise Timeout(f"{card_id} still in doing after {timeout:g}s", card=card_id)
|
|
2035
|
+
db.close_all()
|
|
2036
|
+
time.sleep(max(0.01, min(interval, deadline - time.monotonic())))
|
|
2037
|
+
|
|
2038
|
+
@serialized_write
|
|
2039
|
+
def record_cost(
|
|
2040
|
+
self, card_id, caller, model_id=None, usage=None, usd=None, duration_ms=0, billing=None, agent=None
|
|
2041
|
+
):
|
|
2042
|
+
"""Record one run's usage on a card; runs accumulate. `agent` (lead only when not the caller) attributes the
|
|
2043
|
+
run.
|
|
2044
|
+
Billing comes from the agent (api, plan, unknown) unless given."""
|
|
2045
|
+
self._auth(caller, card_id)
|
|
2046
|
+
name = agent or caller.name
|
|
2047
|
+
if name != caller.name:
|
|
2048
|
+
self._require_lead(caller, "record cost for another agent")
|
|
2049
|
+
usage = usage or C.Usage()
|
|
2050
|
+
if min(usage.as_dict().values()) < 0 or (usd is not None and (not math.isfinite(usd) or usd < 0)):
|
|
2051
|
+
raise Precondition("usage and usd must be finite and not negative")
|
|
2052
|
+
if billing is not None and billing not in C.BILLING:
|
|
2053
|
+
raise Precondition(f"billing must be one of {', '.join(C.BILLING)}")
|
|
2054
|
+
rec = self.agents().get(name, {})
|
|
2055
|
+
model_id = model_id or rec.get("model", "")
|
|
2056
|
+
prices = C.pricing(self.home)
|
|
2057
|
+
with self.lock():
|
|
2058
|
+
card = self.card(card_id)
|
|
2059
|
+
row = C.entry(
|
|
2060
|
+
model_id,
|
|
2061
|
+
usage,
|
|
2062
|
+
usd,
|
|
2063
|
+
duration_ms,
|
|
2064
|
+
prices,
|
|
2065
|
+
name,
|
|
2066
|
+
clock.iso(),
|
|
2067
|
+
billing=billing or rec.get("billing", "unknown"),
|
|
2068
|
+
provider=C.provider_for(model_id, prices, rec.get("provider", "")),
|
|
2069
|
+
)
|
|
2070
|
+
if any(v is not None and not math.isfinite(v) for v in (row.get("usd"), row.get("usd_equiv"))):
|
|
2071
|
+
raise Precondition("money must be finite")
|
|
2072
|
+
card.setdefault("costs", []).append(row)
|
|
2073
|
+
self._save_card(card)
|
|
2074
|
+
self.index.add_cost(self.uid, card_id, card.get("team", ""), name, row)
|
|
2075
|
+
self._record("cost", name, task=card_id, data=row)
|
|
2076
|
+
return {"card": card_id, "entry": row, "total": C.card_total(card)}
|
|
2077
|
+
|
|
2078
|
+
def spend(self, by="provider", since=None, until=None, everywhere=False):
|
|
2079
|
+
"""Spend grouped by provider, model, agent, team, card, billing, workspace, day or week; this workspace or
|
|
2080
|
+
all."""
|
|
2081
|
+
for card in self.cards():
|
|
2082
|
+
for row in card.get("costs", []):
|
|
2083
|
+
for key in ("usd", "usd_equiv"):
|
|
2084
|
+
value = row.get(key)
|
|
2085
|
+
if value is not None and (not math.isfinite(value) or value < 0):
|
|
2086
|
+
raise Precondition("invalid-data: nonfinite or negative money")
|
|
2087
|
+
return self.index.spend(by=by, ws=None if everywhere else self.uid, since=since, until=until)
|
|
2088
|
+
|
|
2089
|
+
def costs(self):
|
|
2090
|
+
"""Cost totals per card, per team and for the workspace."""
|
|
2091
|
+
return C.totals(self.cards())
|
|
2092
|
+
|
|
2093
|
+
def statuses(self, cards=None):
|
|
2094
|
+
cards = self.index.card_rows(self.uid) if cards is None else cards
|
|
2095
|
+
return S.statuses(self.agents(), cards, self.leases())
|
|
2096
|
+
|
|
2097
|
+
def who(self, cards=None):
|
|
2098
|
+
agents, st = self.agents(), self.statuses(cards)
|
|
2099
|
+
seen = self.index.last_seen(self.uid)
|
|
2100
|
+
spend = {r["key"]: r["usd"] for r in self.index.spend(by="agent", ws=self.uid)["rows"]}
|
|
2101
|
+
out = {
|
|
2102
|
+
name: {
|
|
2103
|
+
**agents.get(name, {}),
|
|
2104
|
+
**({"last_seen": seen[name]} if name in seen else {}),
|
|
2105
|
+
**s,
|
|
2106
|
+
"spend_usd": spend.get(name, 0.0),
|
|
2107
|
+
}
|
|
2108
|
+
for name, s in st.items()
|
|
2109
|
+
}
|
|
2110
|
+
if self.lead in out:
|
|
2111
|
+
lease = self.leadlease().summary()
|
|
2112
|
+
out[self.lead].update(
|
|
2113
|
+
session=lease.get("session", ""),
|
|
2114
|
+
lead_fence=lease.get("fence", 0),
|
|
2115
|
+
lead_live=lease.get("live", False),
|
|
2116
|
+
lead_expires=lease.get("expires", ""),
|
|
2117
|
+
)
|
|
2118
|
+
return out
|
|
2119
|
+
|
|
2120
|
+
def card_view(self, card, leases=None, agents=None):
|
|
2121
|
+
leases = self.leases() if leases is None else leases
|
|
2122
|
+
return {
|
|
2123
|
+
**card,
|
|
2124
|
+
"a2a_state": model.a2a_state(card),
|
|
2125
|
+
"approvals": self.approvals(card, agents),
|
|
2126
|
+
"cost": C.card_total(card),
|
|
2127
|
+
"lease": leases.get(card["id"]),
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
def snapshot(self, transcript_limit=200, messages_limit=50, transcript_since=None):
|
|
2131
|
+
"""Everything a UI needs in one read-only dict. `version` changes on every board write (use it as an ETag);
|
|
2132
|
+
pass the last transcript `seq` as `transcript_since` to page the transcript."""
|
|
2133
|
+
version = self.version()
|
|
2134
|
+
cards = self.cards()
|
|
2135
|
+
leases, agents = self.leases(), self.agents()
|
|
2136
|
+
busy = {c["owner"]: c["id"] for c in cards if c["lane"] == "doing" and c.get("owner")}
|
|
2137
|
+
msgs = sorted(M.all_messages(self.dir), key=lambda m: (m.get("sent", ""), m.get("id", "")))[-messages_limit:]
|
|
2138
|
+
return {
|
|
2139
|
+
"schema": store.SCHEMA,
|
|
2140
|
+
"version": version,
|
|
2141
|
+
"etag": f'"{version["workspace"]}-{version["global"]}"',
|
|
2142
|
+
"workspace": self.name,
|
|
2143
|
+
"board": self.dir,
|
|
2144
|
+
"lead": self.lead,
|
|
2145
|
+
"settings": self.settings,
|
|
2146
|
+
"warnings": self.warnings(),
|
|
2147
|
+
"now": clock.iso(),
|
|
2148
|
+
"lead_lease": self.leadlease().summary(),
|
|
2149
|
+
"cards": [self.card_view(c, leases, agents) for c in cards],
|
|
2150
|
+
"teams": self.team_list(),
|
|
2151
|
+
"agents": self.who(cards),
|
|
2152
|
+
"leases": {k: {**v, "live": L.is_live(v)} for k, v in leases.items()},
|
|
2153
|
+
"claimable": self.claimable(cards=cards),
|
|
2154
|
+
"review_queue": self.claimable(for_review=True, cards=cards),
|
|
2155
|
+
"inbox": M.counts(self.dir, busy),
|
|
2156
|
+
"messages": msgs,
|
|
2157
|
+
"costs": C.totals(cards),
|
|
2158
|
+
"transcript": self.transcript.read(limit=transcript_limit, since=transcript_since),
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def parse_words(value):
|
|
2163
|
+
if not value:
|
|
2164
|
+
return []
|
|
2165
|
+
items = value if isinstance(value, (list, tuple)) else value.replace(",", " ").split()
|
|
2166
|
+
return [x.strip() for x in items if x and x.strip()]
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _line(text):
|
|
2170
|
+
return model.line_text(T.redact(text), "note")
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
def _dumps(obj):
|
|
2174
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False)
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def _decision(allowed, reason, card="", holder=""):
|
|
2178
|
+
return {"allowed": allowed, "reason": reason, "card": card, "holder": holder}
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def _raise_reasons(card_id, reasons, clash):
|
|
2182
|
+
if not reasons:
|
|
2183
|
+
return
|
|
2184
|
+
text = f"cannot claim {card_id}: " + "; ".join(r for _, r in reasons)
|
|
2185
|
+
codes = {c for c, _ in reasons}
|
|
2186
|
+
cls = IdentityError if 5 in codes else Precondition if 3 in codes else Denied
|
|
2187
|
+
hint = (
|
|
2188
|
+
f"raidlock claim {card_id} --take-over"
|
|
2189
|
+
if any("--take-over" in r for _, r in reasons)
|
|
2190
|
+
else f"raidlock check {' '.join(c['path'] for c in clash)}"
|
|
2191
|
+
if clash
|
|
2192
|
+
else f"raidlock up --join --card {card_id}"
|
|
2193
|
+
if 5 in codes
|
|
2194
|
+
else "raidlock claimable"
|
|
2195
|
+
)
|
|
2196
|
+
if clash:
|
|
2197
|
+
hint += "; ask the lead to narrow the held lock with raidlock edit " + clash[0]["card"] + " --locks <files>"
|
|
2198
|
+
raise cls(text, card=card_id, reasons=[r for _, r in reasons], conflicts=clash, hint=hint)
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def _apply_setting(settings, key, value):
|
|
2202
|
+
text = str(value).strip().lower()
|
|
2203
|
+
if key == "lead_store":
|
|
2204
|
+
if text not in LD.STORES:
|
|
2205
|
+
raise Precondition(f"lead_store must be one of {', '.join(LD.STORES)}")
|
|
2206
|
+
settings["lead_store"] = text
|
|
2207
|
+
elif key == "auth":
|
|
2208
|
+
if text not in ("token", "off"):
|
|
2209
|
+
raise Precondition("auth must be token or off")
|
|
2210
|
+
settings["auth"] = text
|
|
2211
|
+
elif key in ("ttl_minutes", "hold_minutes"):
|
|
2212
|
+
settings[key] = _positive(value, key)
|
|
2213
|
+
elif key == "brief_max_tokens":
|
|
2214
|
+
settings["brief_max_tokens"] = int(_positive(value, "brief_max_tokens"))
|
|
2215
|
+
elif key.startswith("budget."):
|
|
2216
|
+
from ..budgets import _value
|
|
2217
|
+
|
|
2218
|
+
value = _value(key, value)
|
|
2219
|
+
if value is None:
|
|
2220
|
+
settings.pop(key, None)
|
|
2221
|
+
else:
|
|
2222
|
+
settings[key] = value
|
|
2223
|
+
elif key in PR_SETTING_KEYS:
|
|
2224
|
+
from ..pr import setting_value
|
|
2225
|
+
|
|
2226
|
+
value = setting_value(key, value)
|
|
2227
|
+
if value in ("", []):
|
|
2228
|
+
settings.pop(key, None)
|
|
2229
|
+
else:
|
|
2230
|
+
settings[key] = value
|
|
2231
|
+
elif key in ("require_review", "require_review.count"):
|
|
2232
|
+
settings["require_review"]["count"] = int(_positive(value, key, allow_zero=True))
|
|
2233
|
+
elif key in ("require_review.other_provider", "require_verify", *ENFORCE_KEYS):
|
|
2234
|
+
if text not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
|
2235
|
+
raise Precondition(f"{key} must be true or false")
|
|
2236
|
+
if key != "require_review.other_provider":
|
|
2237
|
+
settings[key] = text in ("true", "1", "yes", "on")
|
|
2238
|
+
else:
|
|
2239
|
+
settings["require_review"]["other_provider"] = text in ("true", "1", "yes", "on")
|
|
2240
|
+
else:
|
|
2241
|
+
raise Precondition(f"unknown setting {key}", key=key)
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _positive(value, key, allow_zero=False):
|
|
2245
|
+
try:
|
|
2246
|
+
n = float(value)
|
|
2247
|
+
except (TypeError, ValueError):
|
|
2248
|
+
raise Precondition(f"{key} must be a number", key=key) from None
|
|
2249
|
+
if not math.isfinite(n) or n < 0 or (n == 0 and not allow_zero):
|
|
2250
|
+
raise Precondition(f"{key} must be positive", key=key)
|
|
2251
|
+
return int(n) if n == int(n) else n
|