raidlock 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/core/db.py ADDED
@@ -0,0 +1,608 @@
1
+ import json
2
+ import math
3
+ import os
4
+ import sqlite3
5
+ import threading
6
+ from contextlib import contextmanager
7
+
8
+ from . import clock, repos
9
+ from .errors import Denied, Precondition
10
+
11
+ SCHEMA_VERSION = 1
12
+ BUSY_MS = 15000
13
+ DDL = """
14
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
15
+ CREATE TABLE IF NOT EXISTS workspaces (
16
+ uid TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, board TEXT UNIQUE NOT NULL, last_seen TEXT NOT NULL,
17
+ version INTEGER NOT NULL DEFAULT 0);
18
+ CREATE TABLE IF NOT EXISTS leases (
19
+ ws TEXT NOT NULL, card TEXT NOT NULL, agent TEXT NOT NULL, fence INTEGER NOT NULL, paths TEXT NOT NULL,
20
+ keys TEXT NOT NULL, since TEXT NOT NULL, renewed TEXT NOT NULL, expires TEXT, checkout TEXT, branch TEXT,
21
+ PRIMARY KEY (ws, card));
22
+ CREATE TABLE IF NOT EXISTS lease_keys (
23
+ ws TEXT NOT NULL, card TEXT NOT NULL, ident TEXT NOT NULL, frel TEXT NOT NULL);
24
+ CREATE INDEX IF NOT EXISTS lease_keys_path ON lease_keys (ident, frel);
25
+ CREATE INDEX IF NOT EXISTS lease_keys_card ON lease_keys (ws, card);
26
+ CREATE TABLE IF NOT EXISTS cards (
27
+ ws TEXT NOT NULL, id TEXT NOT NULL, lane TEXT NOT NULL, owner TEXT NOT NULL, team TEXT NOT NULL,
28
+ doc TEXT, mtime INTEGER, size INTEGER, PRIMARY KEY (ws, id));
29
+ CREATE INDEX IF NOT EXISTS cards_team ON cards (ws, team, lane);
30
+ CREATE TABLE IF NOT EXISTS agents (
31
+ ws TEXT NOT NULL, name TEXT NOT NULL, provider TEXT, runtime TEXT, model TEXT, display TEXT, last_seen TEXT,
32
+ PRIMARY KEY (ws, name));
33
+ CREATE TABLE IF NOT EXISTS transcript (
34
+ ws TEXT NOT NULL, seq INTEGER NOT NULL, message_id TEXT NOT NULL, ts TEXT NOT NULL, kind TEXT NOT NULL,
35
+ task TEXT NOT NULL, agent TEXT NOT NULL, recipient TEXT NOT NULL, segment INTEGER NOT NULL,
36
+ offset INTEGER NOT NULL, length INTEGER NOT NULL, PRIMARY KEY (ws, seq), UNIQUE (ws, message_id));
37
+ CREATE INDEX IF NOT EXISTS transcript_task ON transcript (ws, task, seq);
38
+ CREATE INDEX IF NOT EXISTS transcript_agent ON transcript (ws, agent, seq);
39
+ CREATE INDEX IF NOT EXISTS transcript_recipient ON transcript (ws, recipient, seq);
40
+ CREATE TABLE IF NOT EXISTS costs (
41
+ ws TEXT NOT NULL, card TEXT NOT NULL, team TEXT NOT NULL, agent TEXT NOT NULL, provider TEXT NOT NULL,
42
+ model TEXT NOT NULL, billing TEXT NOT NULL, ts TEXT NOT NULL, input INTEGER NOT NULL, cached_input INTEGER NOT NULL,
43
+ cache_write INTEGER NOT NULL, output INTEGER NOT NULL, reasoning_output INTEGER NOT NULL, tokens INTEGER NOT NULL,
44
+ usd REAL, usd_equiv REAL, duration_ms INTEGER NOT NULL);
45
+ CREATE INDEX IF NOT EXISTS costs_ws_ts ON costs (ws, ts);
46
+ CREATE INDEX IF NOT EXISTS costs_ts ON costs (ts);
47
+ """
48
+ SPEND_KEYS = ("provider", "model", "agent", "team", "card", "billing", "workspace", "day", "week")
49
+ SPEND_COLUMNS = (
50
+ "ROUND(COALESCE(SUM(c.usd), 0), 6) AS usd, ROUND(COALESCE(SUM(c.usd_equiv), 0), 6) AS usd_equiv, "
51
+ "SUM(c.tokens) AS tokens, SUM(c.input) AS input, SUM(c.cached_input) AS cached_input, "
52
+ "SUM(c.cache_write) AS cache_write, SUM(c.output) AS output, "
53
+ "SUM(c.reasoning_output) AS reasoning_output, COUNT(*) AS runs, "
54
+ "SUM(CASE WHEN c.usd IS NULL THEN 1 ELSE 0 END) AS unpriced_runs "
55
+ )
56
+ SPEND_FROM = (
57
+ "FROM costs c LEFT JOIN workspaces w ON w.uid = c.ws "
58
+ "WHERE (? IS NULL OR c.ws = ?) AND (? IS NULL OR c.ts >= ?) AND (? IS NULL OR c.ts < ?) "
59
+ )
60
+ SPEND_GROUPED = (
61
+ "SELECT CASE ? WHEN 'provider' THEN c.provider WHEN 'model' THEN c.model WHEN 'agent' THEN c.agent "
62
+ "WHEN 'team' THEN c.team WHEN 'card' THEN c.card WHEN 'billing' THEN c.billing "
63
+ "WHEN 'workspace' THEN COALESCE(w.name, c.ws) WHEN 'day' THEN substr(c.ts, 1, 10) "
64
+ "WHEN 'week' THEN strftime('%Y-W%W', substr(c.ts, 1, 19)) END AS key, "
65
+ + SPEND_COLUMNS
66
+ + SPEND_FROM
67
+ + "GROUP BY key ORDER BY usd DESC, key"
68
+ )
69
+ SPEND_TOTAL = "SELECT " + SPEND_COLUMNS + SPEND_FROM
70
+ WORKSPACE_TABLES = (
71
+ "DELETE FROM leases WHERE ws = ?",
72
+ "DELETE FROM lease_keys WHERE ws = ?",
73
+ "DELETE FROM cards WHERE ws = ?",
74
+ "DELETE FROM agents WHERE ws = ?",
75
+ "DELETE FROM transcript WHERE ws = ?",
76
+ "DELETE FROM costs WHERE ws = ?",
77
+ "DELETE FROM workspaces WHERE uid = ?",
78
+ )
79
+ CANDIDATES = (
80
+ "SELECT DISTINCT l.ws, l.card, l.agent, l.expires, l.keys, w.name, w.board FROM lease_keys k "
81
+ "JOIN leases l ON l.ws = k.ws AND l.card = k.card LEFT JOIN workspaces w ON w.uid = l.ws "
82
+ "WHERE k.ident = ? AND (k.frel IN (SELECT value FROM json_each(?)) "
83
+ "OR (? = '' OR (k.frel >= ? AND k.frel < ?))) AND (l.expires IS NULL OR l.expires > ?)"
84
+ )
85
+ HOLDERS = (
86
+ "SELECT DISTINCT l.*, w.name AS wsname FROM lease_keys k JOIN leases l ON l.ws = k.ws AND l.card = k.card "
87
+ "LEFT JOIN workspaces w ON w.uid = l.ws WHERE k.ident = ? AND k.frel IN (SELECT value FROM json_each(?)) "
88
+ "AND (l.expires IS NULL OR l.expires > ?)"
89
+ )
90
+
91
+ _local = threading.local()
92
+
93
+
94
+ def _connect(path):
95
+ cache = getattr(_local, "conns", None)
96
+ if cache is None:
97
+ cache = _local.conns = {}
98
+ conn = cache.get(path)
99
+ if conn is None:
100
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
101
+ conn = sqlite3.connect(path, timeout=BUSY_MS / 1000, isolation_level=None, check_same_thread=True)
102
+ try:
103
+ conn.row_factory = sqlite3.Row
104
+ conn.execute(f"PRAGMA busy_timeout={BUSY_MS}")
105
+ conn.execute("PRAGMA journal_mode=WAL")
106
+ conn.execute("PRAGMA synchronous=NORMAL")
107
+ conn.execute("PRAGMA foreign_keys=OFF")
108
+ _migrate(conn)
109
+ except BaseException:
110
+ conn.close()
111
+ raise
112
+ cache[path] = conn
113
+ return conn
114
+
115
+
116
+ def close_all():
117
+ """Close this thread's cached connections; the next access reopens them."""
118
+ cache = getattr(_local, "conns", None) or {}
119
+ while cache:
120
+ _, conn = cache.popitem()
121
+ conn.close()
122
+
123
+
124
+ def open_count():
125
+ """How many connections this thread holds open."""
126
+ return len(getattr(_local, "conns", None) or {})
127
+
128
+
129
+ @contextmanager
130
+ def released():
131
+ """Run a block and close this thread's cached connections afterwards, even on error."""
132
+ try:
133
+ yield
134
+ finally:
135
+ close_all()
136
+
137
+
138
+ def purge(conn, uid):
139
+ """Delete every index row of one workspace."""
140
+ for statement in WORKSPACE_TABLES:
141
+ conn.execute(statement, (uid,))
142
+
143
+
144
+ def _migrate(conn):
145
+ conn.execute("BEGIN IMMEDIATE")
146
+ try:
147
+ for stmt in DDL.strip().split(";"):
148
+ if stmt.strip():
149
+ conn.execute(stmt)
150
+ row = conn.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
151
+ if row and row[0] > SCHEMA_VERSION:
152
+ raise Precondition(f"raidlock.db has schema {row[0]}; upgrade raidlock")
153
+ conn.execute("INSERT OR IGNORE INTO meta VALUES ('schema', ?)", (SCHEMA_VERSION,))
154
+ conn.execute("INSERT OR IGNORE INTO meta VALUES ('fence', 0)")
155
+ conn.execute("INSERT OR IGNORE INTO meta VALUES ('version', 0)")
156
+ conn.execute("COMMIT")
157
+ except BaseException:
158
+ conn.execute("ROLLBACK")
159
+ raise
160
+
161
+
162
+ def ancestors(rel):
163
+ parts = [p for p in rel.split("/") if p]
164
+ return [""] + ["/".join(parts[: i + 1]) for i in range(len(parts))]
165
+
166
+
167
+ def _lease_row(row):
168
+ return {
169
+ "card": row["card"],
170
+ "agent": row["agent"],
171
+ "fence": row["fence"],
172
+ "paths": json.loads(row["paths"]),
173
+ "keys": json.loads(row["keys"]),
174
+ "since": row["since"],
175
+ "renewed": row["renewed"],
176
+ "expires": row["expires"],
177
+ "checkout": json.loads(row["checkout"]) if row["checkout"] else None,
178
+ "branch": row["branch"] or "",
179
+ }
180
+
181
+
182
+ class Index:
183
+ """Global index in `<home>/raidlock.db`: workspaces, leases + path keys, fence counter, card and agent mirrors,
184
+ transcript index, and version counters. SQLite WAL; grants run in BEGIN IMMEDIATE transactions."""
185
+
186
+ def __init__(self, home):
187
+ self.path = os.path.join(home, "raidlock.db")
188
+
189
+ @property
190
+ def conn(self):
191
+ return _connect(self.path)
192
+
193
+ @contextmanager
194
+ def write(self):
195
+ conn = self.conn
196
+ conn.execute("BEGIN IMMEDIATE")
197
+ try:
198
+ yield conn
199
+ conn.execute("COMMIT")
200
+ except BaseException:
201
+ conn.execute("ROLLBACK")
202
+ raise
203
+
204
+ def _bump(self, conn, ws):
205
+ conn.execute("UPDATE workspaces SET version = version + 1 WHERE uid = ?", (ws,))
206
+ conn.execute("UPDATE meta SET value = value + 1 WHERE key = 'version'")
207
+
208
+ def bump(self, ws):
209
+ with self.write() as conn:
210
+ self._bump(conn, ws)
211
+
212
+ def versions(self, ws):
213
+ row = self.conn.execute("SELECT version FROM workspaces WHERE uid = ?", (ws,)).fetchone()
214
+ glob = self.conn.execute("SELECT value FROM meta WHERE key='version'").fetchone()[0]
215
+ return {"workspace": row[0] if row else 0, "global": glob}
216
+
217
+ def register(self, ws, board, stem):
218
+ board = os.path.realpath(board)
219
+ with self.write() as conn:
220
+ row = conn.execute("SELECT name FROM workspaces WHERE uid = ? OR board = ?", (ws, board)).fetchone()
221
+ if row:
222
+ name = row["name"]
223
+ conn.execute("DELETE FROM workspaces WHERE board = ? AND uid != ?", (board, ws))
224
+ conn.execute(
225
+ "INSERT INTO workspaces(uid, name, board, last_seen) VALUES (?, ?, ?, ?) "
226
+ "ON CONFLICT(uid) DO UPDATE SET board = excluded.board, last_seen = excluded.last_seen",
227
+ (ws, name, board, clock.iso()),
228
+ )
229
+ return name
230
+ name, i = stem or "board", 2
231
+ while conn.execute("SELECT 1 FROM workspaces WHERE name = ?", (name,)).fetchone():
232
+ name, i = f"{stem}-{i}", i + 1
233
+ conn.execute(
234
+ "INSERT INTO workspaces(uid, name, board, last_seen) VALUES (?, ?, ?, ?)",
235
+ (ws, name, board, clock.iso()),
236
+ )
237
+ return name
238
+
239
+ def workspaces(self):
240
+ rows = self.conn.execute("SELECT uid, name, board, last_seen, version FROM workspaces ORDER BY name").fetchall()
241
+ return [dict(r) for r in rows]
242
+
243
+ def unregister(self, names):
244
+ gone = []
245
+ with self.write() as conn:
246
+ for name in names:
247
+ row = conn.execute("SELECT uid FROM workspaces WHERE name = ?", (name,)).fetchone()
248
+ if row:
249
+ purge(conn, row["uid"])
250
+ gone.append(name)
251
+ return gone
252
+
253
+ def registered_name(self, ws, board):
254
+ row = self.conn.execute(
255
+ "SELECT name FROM workspaces WHERE uid = ? AND board = ?", (ws, os.path.realpath(board))
256
+ ).fetchone()
257
+ return row["name"] if row else None
258
+
259
+ def touch_agent(self, ws, name):
260
+ with self.write() as conn:
261
+ conn.execute("UPDATE agents SET last_seen = ? WHERE ws = ? AND name = ?", (clock.iso(), ws, name))
262
+
263
+ def last_seen(self, ws):
264
+ rows = self.conn.execute("SELECT name, last_seen FROM agents WHERE ws = ?", (ws,)).fetchall()
265
+ return {r["name"]: r["last_seen"] for r in rows if r["last_seen"]}
266
+
267
+ def workspace_name(self, ws):
268
+ row = self.conn.execute("SELECT name FROM workspaces WHERE uid = ?", (ws,)).fetchone()
269
+ return row["name"] if row else ws
270
+
271
+ def lease(self, ws, card):
272
+ row = self.conn.execute("SELECT * FROM leases WHERE ws = ? AND card = ?", (ws, card)).fetchone()
273
+ return _lease_row(row) if row else None
274
+
275
+ def leases(self, ws):
276
+ rows = self.conn.execute("SELECT * FROM leases WHERE ws = ? ORDER BY card", (ws,)).fetchall()
277
+ return {r["card"]: _lease_row(r) for r in rows}
278
+
279
+ def conflicts(self, keys, exclude_ws="", exclude_card="", conn=None, now=None, advisory=False):
280
+ """Live leases overlapping any key, via the (ident, frel) index: ancestors by equality, descendants by range."""
281
+ conn = conn or self.conn
282
+ now = now or clock.iso()
283
+ out, seen = [], set()
284
+ for key in keys:
285
+ for row in self._candidates(conn, key, now):
286
+ if (row["ws"], row["card"]) == (exclude_ws, exclude_card):
287
+ continue
288
+ other = {"path": "", "id": key["id"], "rel": "", "ci": False}
289
+ for k in json.loads(row["keys"]):
290
+ if bool(k.get("advisory")) == advisory and repos.keys_overlap(key, k):
291
+ other = k
292
+ break
293
+ else:
294
+ continue
295
+ marker = (row["ws"], row["card"], key["path"], other["path"])
296
+ if marker in seen:
297
+ continue
298
+ seen.add(marker)
299
+ out.append(
300
+ {
301
+ "path": key["path"],
302
+ "held": other["path"],
303
+ "card": row["card"],
304
+ "agent": row["agent"],
305
+ "workspace": row["name"] or row["ws"],
306
+ "board": row["board"] or "",
307
+ "expires": row["expires"],
308
+ }
309
+ )
310
+ return out
311
+
312
+ def _candidates(self, conn, key, now):
313
+ frel = key["rel"].casefold()
314
+ anc = json.dumps(ancestors(frel))
315
+ return conn.execute(CANDIDATES, (key["id"], anc, frel, frel + "/", frel + "0", now)).fetchall()
316
+
317
+ def grant(self, ws, card, agent, paths, keys, fence, ttl_seconds, held=False, since=None, checkout=None):
318
+ """Atomically check conflicts and write the lease. Mints a fence when `fence` is None. Raises Denied."""
319
+ with self.write() as conn:
320
+ clash = self.conflicts([k for k in keys if not k.get("advisory")], ws, card, conn=conn)
321
+ if clash:
322
+ from .leases import describe
323
+
324
+ raise Denied(
325
+ f"lease denied for {card}:\n" + "\n".join(" " + describe(c) for c in clash),
326
+ card=card,
327
+ conflicts=clash,
328
+ )
329
+ if fence is None:
330
+ conn.execute("UPDATE meta SET value = value + 1 WHERE key = 'fence'")
331
+ fence = conn.execute("SELECT value FROM meta WHERE key = 'fence'").fetchone()[0]
332
+ old = conn.execute("SELECT since, checkout FROM leases WHERE ws = ? AND card = ?", (ws, card)).fetchone()
333
+ ts = clock.iso()
334
+ lease = {
335
+ "card": card,
336
+ "agent": agent,
337
+ "fence": fence,
338
+ "paths": list(paths),
339
+ "keys": list(keys),
340
+ "since": since or (old["since"] if old else ts),
341
+ "renewed": ts,
342
+ "expires": None if held else clock.later(ttl_seconds),
343
+ "checkout": checkout or (json.loads(old["checkout"]) if old and old["checkout"] else None),
344
+ }
345
+ lease["branch"] = (lease["checkout"] or {}).get("branch", "")
346
+ self._put(conn, ws, lease)
347
+ self._bump(conn, ws)
348
+ return lease
349
+
350
+ def _put(self, conn, ws, lease):
351
+ conn.execute("DELETE FROM lease_keys WHERE ws = ? AND card = ?", (ws, lease["card"]))
352
+ conn.execute(
353
+ "INSERT OR REPLACE INTO leases VALUES (?,?,?,?,?,?,?,?,?,?,?)",
354
+ (
355
+ ws,
356
+ lease["card"],
357
+ lease["agent"],
358
+ lease["fence"],
359
+ json.dumps(lease["paths"]),
360
+ json.dumps(lease["keys"]),
361
+ lease["since"],
362
+ lease["renewed"],
363
+ lease["expires"],
364
+ json.dumps(lease["checkout"]) if lease.get("checkout") else None,
365
+ lease.get("branch", ""),
366
+ ),
367
+ )
368
+ conn.executemany(
369
+ "INSERT INTO lease_keys VALUES (?,?,?,?)",
370
+ [(ws, lease["card"], k["id"], k["rel"].casefold()) for k in lease["keys"]],
371
+ )
372
+
373
+ def put_lease(self, ws, lease, mint=False):
374
+ """Insert a lease without a conflict check (migration). `mint` assigns a fresh fence."""
375
+ with self.write() as conn:
376
+ if mint:
377
+ conn.execute("UPDATE meta SET value = value + 1 WHERE key = 'fence'")
378
+ lease["fence"] = conn.execute("SELECT value FROM meta WHERE key = 'fence'").fetchone()[0]
379
+ self._put(conn, ws, lease)
380
+ self._bump(conn, ws)
381
+ return lease
382
+
383
+ def update_lease(self, ws, card, **fields):
384
+ allowed = {"renewed", "expires"}
385
+ with self.write() as conn:
386
+ if not conn.execute("SELECT 1 FROM leases WHERE ws = ? AND card = ?", (ws, card)).fetchone():
387
+ return None
388
+ if not set(fields) & allowed:
389
+ return self.lease(ws, card)
390
+ conn.execute(
391
+ "UPDATE leases SET renewed = CASE WHEN ? THEN ? ELSE renewed END, "
392
+ "expires = CASE WHEN ? THEN ? ELSE expires END WHERE ws = ? AND card = ?",
393
+ (
394
+ "renewed" in fields,
395
+ fields.get("renewed"),
396
+ "expires" in fields,
397
+ fields.get("expires"),
398
+ ws,
399
+ card,
400
+ ),
401
+ )
402
+ self._bump(conn, ws)
403
+ return self.lease(ws, card)
404
+
405
+ def drop_lease(self, ws, card):
406
+ with self.write() as conn:
407
+ n = conn.execute("DELETE FROM leases WHERE ws = ? AND card = ?", (ws, card)).rowcount
408
+ conn.execute("DELETE FROM lease_keys WHERE ws = ? AND card = ?", (ws, card))
409
+ if n:
410
+ self._bump(conn, ws)
411
+ return bool(n)
412
+
413
+ def holders_of(self, loc, now=None):
414
+ """Live leases (any workspace) whose keys contain the located path `loc`."""
415
+ frel = loc["rel"].casefold()
416
+ rows = self.conn.execute(HOLDERS, (loc["id"], json.dumps(ancestors(frel)), now or clock.iso())).fetchall()
417
+ out = []
418
+ for r in rows:
419
+ lease = _lease_row(r)
420
+ if any(not k.get("advisory") and repos.key_contains(k, loc) for k in lease["keys"]):
421
+ out.append((r["wsname"] or r["ws"], r["ws"], lease))
422
+ return out
423
+
424
+ @staticmethod
425
+ def _card_row(ws, card, stat):
426
+ return (
427
+ ws,
428
+ card["id"],
429
+ card["lane"],
430
+ card.get("owner", ""),
431
+ card.get("team", ""),
432
+ json.dumps(card, sort_keys=True, ensure_ascii=False) if stat else None,
433
+ stat[0] if stat else None,
434
+ stat[1] if stat else None,
435
+ )
436
+
437
+ def put_card(self, ws, card, stat=None):
438
+ """Mirror a card; `stat` is (mtime_ns, size) of its file so reads can reuse the cached document."""
439
+ with self.write() as conn:
440
+ conn.execute("INSERT OR REPLACE INTO cards VALUES (?,?,?,?,?,?,?,?)", self._card_row(ws, card, stat))
441
+ self._bump(conn, ws)
442
+
443
+ def put_cards(self, ws, cards, stats=None, replace=True, bump=True):
444
+ stats = stats or {}
445
+ with self.write() as conn:
446
+ if replace:
447
+ conn.execute("DELETE FROM cards WHERE ws = ?", (ws,))
448
+ conn.executemany(
449
+ "INSERT OR REPLACE INTO cards VALUES (?,?,?,?,?,?,?,?)",
450
+ [self._card_row(ws, c, stats.get(c["id"])) for c in cards],
451
+ )
452
+ if bump:
453
+ self._bump(conn, ws)
454
+
455
+ def card_doc(self, ws, card_id):
456
+ row = self.conn.execute("SELECT doc, mtime, size FROM cards WHERE ws = ? AND id = ?", (ws, card_id)).fetchone()
457
+ return (row["doc"], row["mtime"], row["size"]) if row else (None, None, None)
458
+
459
+ def card_docs(self, ws):
460
+ rows = self.conn.execute("SELECT id, doc, mtime, size FROM cards WHERE ws = ?", (ws,)).fetchall()
461
+ return {r["id"]: (r["doc"], r["mtime"], r["size"]) for r in rows}
462
+
463
+ def card_count(self, ws):
464
+ return self.conn.execute("SELECT COUNT(*) FROM cards WHERE ws = ?", (ws,)).fetchone()[0]
465
+
466
+ def doing_in_team(self, ws, team, exclude):
467
+ return self.conn.execute(
468
+ "SELECT COUNT(*) FROM cards WHERE ws = ? AND team = ? AND lane = 'doing' AND id != ?", (ws, team, exclude)
469
+ ).fetchone()[0]
470
+
471
+ def lanes(self, ws, ids):
472
+ if not ids:
473
+ return {}
474
+ rows = self.conn.execute(
475
+ "SELECT id, lane FROM cards WHERE ws = ? AND id IN (SELECT value FROM json_each(?))", (ws, json.dumps(ids))
476
+ ).fetchall()
477
+ return {r["id"]: r["lane"] for r in rows}
478
+
479
+ def card_rows(self, ws):
480
+ return [
481
+ dict(r)
482
+ for r in self.conn.execute(
483
+ "SELECT id, lane, owner, team FROM cards WHERE ws = ? ORDER BY id", (ws,)
484
+ ).fetchall()
485
+ ]
486
+
487
+ def put_agent(self, ws, name, rec):
488
+ with self.write() as conn:
489
+ conn.execute(
490
+ "INSERT OR REPLACE INTO agents VALUES (?,?,?,?,?,?,?)",
491
+ (
492
+ ws,
493
+ name,
494
+ rec.get("provider", ""),
495
+ rec.get("runtime", ""),
496
+ rec.get("model", ""),
497
+ rec.get("display", ""),
498
+ rec.get("last_seen", ""),
499
+ ),
500
+ )
501
+ self._bump(conn, ws)
502
+
503
+ def next_seq(self, conn, ws):
504
+ row = conn.execute("SELECT MAX(seq) FROM transcript WHERE ws = ?", (ws,)).fetchone()
505
+ return (row[0] or 0) + 1
506
+
507
+ def has_message(self, ws, message_id):
508
+ return (
509
+ self.conn.execute("SELECT 1 FROM transcript WHERE ws = ? AND message_id = ?", (ws, message_id)).fetchone()
510
+ is not None
511
+ )
512
+
513
+ def add_transcript(self, ws, rows):
514
+ """Index transcript records [(message_id, ts, kind, task, agent, recipient, segment, offset, length)]."""
515
+ with self.write() as conn:
516
+ seq = self.next_seq(conn, ws)
517
+ out = []
518
+ for r in rows:
519
+ cur = conn.execute("INSERT OR IGNORE INTO transcript VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ws, seq, *r))
520
+ if cur.rowcount:
521
+ out.append(seq)
522
+ seq += 1
523
+ self._bump(conn, ws)
524
+ return out
525
+
526
+ def query_transcript(self, ws, task=None, kind=None, agent=None, since=None, limit=None):
527
+ sql, args = "SELECT * FROM transcript WHERE ws = ?", [ws]
528
+ if task:
529
+ sql, args = sql + " AND task = ?", args + [task]
530
+ if kind:
531
+ sql, args = sql + " AND kind = ?", args + [kind]
532
+ if agent:
533
+ sql, args = sql + " AND (agent = ? OR recipient = ?)", args + [agent, agent]
534
+ if since is not None:
535
+ sql, args = sql + " AND seq > ?", args + [int(since)]
536
+ sql += " ORDER BY seq" + (f" LIMIT {int(limit)}" if limit else "")
537
+ return [dict(r) for r in self.conn.execute(sql, args).fetchall()]
538
+ sql += " ORDER BY seq DESC" + (f" LIMIT {int(limit)}" if limit else "")
539
+ return [dict(r) for r in reversed(self.conn.execute(sql, args).fetchall())]
540
+
541
+ def transcript_count(self, ws):
542
+ return self.conn.execute("SELECT COUNT(*) FROM transcript WHERE ws = ?", (ws,)).fetchone()[0]
543
+
544
+ def clear_transcript(self, ws):
545
+ with self.write() as conn:
546
+ conn.execute("DELETE FROM transcript WHERE ws = ?", (ws,))
547
+
548
+ def add_cost(self, ws, card, team, agent, row):
549
+ u = row["usage"]
550
+ with self.write() as conn:
551
+ conn.execute(
552
+ "INSERT INTO costs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
553
+ (
554
+ ws,
555
+ card,
556
+ team or "",
557
+ agent,
558
+ row.get("provider", ""),
559
+ row.get("model", ""),
560
+ row.get("billing", "unknown"),
561
+ row["ts"],
562
+ u.get("input", 0),
563
+ u.get("cached_input", 0),
564
+ u.get("cache_write", 0),
565
+ u.get("output", 0),
566
+ u.get("reasoning_output", 0),
567
+ row["tokens"],
568
+ row.get("usd"),
569
+ row.get("usd_equiv"),
570
+ row.get("duration_ms", 0),
571
+ ),
572
+ )
573
+ self._bump(conn, ws)
574
+
575
+ def rebuild_costs(self, ws, cards):
576
+ with self.write() as conn:
577
+ conn.execute("DELETE FROM costs WHERE ws = ?", (ws,))
578
+ for c in cards:
579
+ for row in c.get("costs", []):
580
+ self.add_cost(
581
+ ws,
582
+ c["id"],
583
+ c.get("team", ""),
584
+ row.get("by", ""),
585
+ {"usage": {}, "tokens": 0, "billing": "unknown", **row},
586
+ )
587
+
588
+ def cost_count(self, ws):
589
+ return self.conn.execute("SELECT COUNT(*) FROM costs WHERE ws = ?", (ws,)).fetchone()[0]
590
+
591
+ def spend(self, by="provider", ws=None, since=None, until=None):
592
+ """Grouped spend: [{key, usd, usd_equiv, tokens, input, cached_input, cache_write, output, reasoning_output,
593
+ runs, unpriced_runs}] plus a total. `ws` None means every workspace."""
594
+ if by not in SPEND_KEYS:
595
+ raise Precondition(f"--by must be one of {', '.join(SPEND_KEYS)}", by=by)
596
+ args = (ws, ws, since, since, until, until)
597
+ values = self.conn.execute("SELECT c.usd, c.usd_equiv " + SPEND_FROM, args)
598
+ for row in values:
599
+ if any(v is not None and (not math.isfinite(v) or v < 0) for v in row):
600
+ raise Precondition("invalid-data: nonfinite or negative money")
601
+ rows = self.conn.execute(SPEND_GROUPED, (by, *args)).fetchall()
602
+ total = self.conn.execute(SPEND_TOTAL, args).fetchone()
603
+ zero = dict.fromkeys(total.keys(), 0)
604
+ return {
605
+ "by": by,
606
+ "rows": [dict(r) for r in rows],
607
+ "total": {k: (v if v is not None else zero[k]) for k, v in dict(total).items()},
608
+ }
@@ -0,0 +1,52 @@
1
+ class RaidlockError(Exception):
2
+ """Base error. `code` is the CLI exit code; `details` is JSON-safe context."""
3
+
4
+ code = 1
5
+ kind = "error"
6
+
7
+ def __init__(self, message, hint="", **details):
8
+ super().__init__(message)
9
+ self.message = message
10
+ self.hint = hint
11
+ self.details = details
12
+
13
+ def to_dict(self):
14
+ out = {"ok": False, "error": self.kind, "code": self.code, "message": self.message, **self.details}
15
+ if self.hint:
16
+ out["hint"] = self.hint
17
+ return out
18
+
19
+
20
+ class Denied(RaidlockError):
21
+ """A conflict or refusal: lock held, stale fence, not the owner, cap reached."""
22
+
23
+ code = 2
24
+ kind = "denied"
25
+
26
+
27
+ class Precondition(RaidlockError):
28
+ """The request is invalid in the current state: unknown card, bad lane, bad input."""
29
+
30
+ code = 3
31
+ kind = "precondition"
32
+
33
+
34
+ class Timeout(RaidlockError):
35
+ """A wait or lock acquisition ran out of time."""
36
+
37
+ code = 4
38
+ kind = "timeout"
39
+
40
+
41
+ class IdentityError(RaidlockError):
42
+ """The caller is unknown, unauthenticated, or lacks the role."""
43
+
44
+ code = 5
45
+ kind = "identity"
46
+
47
+
48
+ class StaleLead(RaidlockError):
49
+ """A lead write carried a lead fence older than the current lead lease (after a handover or takeover)."""
50
+
51
+ code = 4
52
+ kind = "stale_lead"
raidlock/core/exe.py ADDED
@@ -0,0 +1,6 @@
1
+ import shutil
2
+
3
+
4
+ def find(name):
5
+ """The absolute path of `name` on PATH, or `name` itself so the caller's usual missing-tool error applies."""
6
+ return shutil.which(name) or name