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/migrate.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import clock, model, safeio, store
|
|
11
|
+
from . import paths as P
|
|
12
|
+
from .errors import Precondition
|
|
13
|
+
from .portable import remove_tree
|
|
14
|
+
from .transcript import _clean
|
|
15
|
+
|
|
16
|
+
V1_FIELDS = (
|
|
17
|
+
"id",
|
|
18
|
+
"title",
|
|
19
|
+
"lane",
|
|
20
|
+
"owner",
|
|
21
|
+
"team",
|
|
22
|
+
"ticket",
|
|
23
|
+
"scope",
|
|
24
|
+
"locks",
|
|
25
|
+
"depends",
|
|
26
|
+
"verify",
|
|
27
|
+
"updated",
|
|
28
|
+
"model",
|
|
29
|
+
"tokens",
|
|
30
|
+
"duration_ms",
|
|
31
|
+
"cost_usd",
|
|
32
|
+
)
|
|
33
|
+
_NOTE = re.compile(r"^- (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (.*)$")
|
|
34
|
+
_BY = re.compile(r"^([A-Za-z0-9_.-]+): (.*)$")
|
|
35
|
+
V1_FILES = ("agents.json", "teams.json", "leases.json", "LOG.md", "settings.json", "config.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def needs_migration(board_dir):
|
|
39
|
+
if os.path.exists(os.path.join(board_dir, "board.json")):
|
|
40
|
+
return False
|
|
41
|
+
cards = os.path.join(board_dir, "cards")
|
|
42
|
+
has_cards = os.path.isdir(cards) and any(n.endswith(".md") for n in os.listdir(cards))
|
|
43
|
+
return (
|
|
44
|
+
has_cards
|
|
45
|
+
or any(os.path.exists(os.path.join(board_dir, f)) for f in V1_FILES)
|
|
46
|
+
or os.path.isdir(os.path.join(board_dir, "v1"))
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reject_legacy(board_dir):
|
|
51
|
+
if needs_migration(board_dir):
|
|
52
|
+
raise Precondition(
|
|
53
|
+
"legacy v1 board found (not migrated); run raid migrate to upgrade",
|
|
54
|
+
board=str(board_dir),
|
|
55
|
+
hint="raid migrate --board " + str(board_dir),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_json(path, data):
|
|
60
|
+
safeio.write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_doc(path, key, data):
|
|
64
|
+
_write_json(path, {"schema": store.SCHEMA, key: data})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_jsonl(path, rows):
|
|
68
|
+
safeio.write_text(path, "".join(json.dumps(_clean(row)) + "\n" for row in rows))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _state_path(board_dir, home):
|
|
72
|
+
if Path(os.path.abspath(home)).is_relative_to(Path(os.path.abspath(board_dir))):
|
|
73
|
+
raise Precondition("migration home must be outside the untrusted board")
|
|
74
|
+
key = hashlib.sha256(os.path.abspath(board_dir).encode()).hexdigest()
|
|
75
|
+
return safeio.check(Path(home) / "migrations" / (key + ".json"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _state(board_dir, home):
|
|
79
|
+
path = _state_path(board_dir, home)
|
|
80
|
+
state = json.loads(safeio.read(path)) if path.exists() else {}
|
|
81
|
+
if state and state.get("board_identity") != _board_identity(board_dir):
|
|
82
|
+
raise Precondition("migration state belongs to a different board")
|
|
83
|
+
return state
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _board_identity(board_dir):
|
|
87
|
+
info = safeio.check(board_dir).stat()
|
|
88
|
+
return [info.st_dev, info.st_ino]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _manifest(root):
|
|
92
|
+
root = safeio.check(root, tree=True)
|
|
93
|
+
return {
|
|
94
|
+
str(p.relative_to(root)): None if p.is_dir() else hashlib.sha256(safeio.read(p)).hexdigest()
|
|
95
|
+
for p in sorted(root.rglob("*"))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def backups(board_dir, home=None):
|
|
100
|
+
from . import registry
|
|
101
|
+
|
|
102
|
+
state = _state(board_dir, home or registry.home())
|
|
103
|
+
backup = state.get("backup")
|
|
104
|
+
return [Path(backup)] if backup else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_json(path, default=None):
|
|
108
|
+
return json.loads(safeio.read(path)) if Path(path).exists() else default
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _reject_existing_outputs(board_dir, home):
|
|
112
|
+
if _state(board_dir, home):
|
|
113
|
+
return
|
|
114
|
+
root = Path(board_dir)
|
|
115
|
+
if list((root / "cards").glob("*.json")) or list((root / "transcript").glob("*")):
|
|
116
|
+
raise Precondition("untrusted board contains v2 output without trusted migration state")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def preflight(board_dir, allow_auth_downgrade=False):
|
|
120
|
+
root = safeio.check(board_dir, tree=True)
|
|
121
|
+
source = root / "v1" if (root / "v1").is_dir() else root
|
|
122
|
+
settings = _settings(source)[0]
|
|
123
|
+
if settings["auth"] == "off" and not allow_auth_downgrade:
|
|
124
|
+
raise Precondition("authentication downgrade requires --allow-auth-downgrade")
|
|
125
|
+
seen = set()
|
|
126
|
+
for path in (source / "cards").glob("*.md"):
|
|
127
|
+
meta, body = _read_v1_card(path)
|
|
128
|
+
cid = model.card_id(meta["id"])
|
|
129
|
+
if cid in seen:
|
|
130
|
+
raise Precondition(f"duplicate card id: {cid}")
|
|
131
|
+
seen.add(cid)
|
|
132
|
+
for cid in _split(meta["depends"]):
|
|
133
|
+
model.card_id(cid)
|
|
134
|
+
if meta["owner"]:
|
|
135
|
+
model.agent_name(meta["owner"])
|
|
136
|
+
for note in _notes(body):
|
|
137
|
+
if note["by"]:
|
|
138
|
+
model.agent_name(note["by"])
|
|
139
|
+
for name, agent in _read_json(source / "agents.json", {}).items():
|
|
140
|
+
model.agent_name(name)
|
|
141
|
+
if agent.get("card"):
|
|
142
|
+
model.card_id(agent["card"])
|
|
143
|
+
for cid, lease in _read_json(source / "leases.json", {}).items():
|
|
144
|
+
model.card_id(cid)
|
|
145
|
+
if lease.get("agent"):
|
|
146
|
+
model.agent_name(lease["agent"])
|
|
147
|
+
for path in (source / "inbox").glob("*.jsonl"):
|
|
148
|
+
model.agent_name(path.stem)
|
|
149
|
+
for line in safeio.read(path).decode().splitlines():
|
|
150
|
+
if not line.strip():
|
|
151
|
+
continue
|
|
152
|
+
row = json.loads(line)
|
|
153
|
+
for key in ("from", "to", "agent"):
|
|
154
|
+
if row.get(key) and row[key] != "all":
|
|
155
|
+
model.agent_name(row[key])
|
|
156
|
+
for key in ("card", "task"):
|
|
157
|
+
if row.get(key):
|
|
158
|
+
model.card_id(row[key])
|
|
159
|
+
return source
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def upgrade(board_dir, home, allow_auth_downgrade=False, lead=None):
|
|
163
|
+
from . import identity
|
|
164
|
+
from .db import Index
|
|
165
|
+
|
|
166
|
+
preflight(board_dir, allow_auth_downgrade)
|
|
167
|
+
lead = _lead(_agents(_read_json(os.path.join(board_dir, "agents.json"), {})), lead)
|
|
168
|
+
safeio.check(home, tree=True)
|
|
169
|
+
_reject_existing_outputs(board_dir, home)
|
|
170
|
+
_state_path(board_dir, home)
|
|
171
|
+
safeio.check(Path(board_dir).parent / ".gitignore")
|
|
172
|
+
if not needs_migration(board_dir):
|
|
173
|
+
raise Precondition("no legacy v1 board found")
|
|
174
|
+
state = _state(board_dir, home)
|
|
175
|
+
if state.get("backup"):
|
|
176
|
+
backup = state["backup"]
|
|
177
|
+
if _manifest(backup) != state.get("manifest"):
|
|
178
|
+
raise Precondition("migration backup integrity check failed")
|
|
179
|
+
else:
|
|
180
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
181
|
+
backup = str(Path(board_dir).absolute()) + ".backup-" + stamp
|
|
182
|
+
safeio.copytree(board_dir, backup + ".partial")
|
|
183
|
+
safeio.replace(backup + ".partial", backup)
|
|
184
|
+
state = {
|
|
185
|
+
"uid": state.get("uid") or uuid.uuid4().hex,
|
|
186
|
+
"backup": backup,
|
|
187
|
+
"manifest": _manifest(backup),
|
|
188
|
+
"board_identity": _board_identity(board_dir),
|
|
189
|
+
}
|
|
190
|
+
_write_json(_state_path(board_dir, home), state)
|
|
191
|
+
workspace = Path(board_dir).resolve().parent
|
|
192
|
+
if any((parent / ".git").exists() for parent in (workspace, *workspace.parents)):
|
|
193
|
+
ignore = workspace / ".gitignore"
|
|
194
|
+
content = safeio.read(ignore).decode() if ignore.exists() else ""
|
|
195
|
+
pattern = "/" + Path(board_dir).name + ".backup-*"
|
|
196
|
+
if pattern not in content.splitlines():
|
|
197
|
+
safeio.write_text(
|
|
198
|
+
str(ignore), content + ("\n" if content and not content.endswith("\n") else "") + pattern + "\n"
|
|
199
|
+
)
|
|
200
|
+
result = migrate(board_dir, Index(home), allow_auth_downgrade=allow_auth_downgrade, lead=lead)
|
|
201
|
+
meta = _read_json(os.path.join(board_dir, "board.json"))
|
|
202
|
+
from .lead import mint
|
|
203
|
+
|
|
204
|
+
secret_path = identity.token_path(home, meta["uid"], meta["lead"])
|
|
205
|
+
safeio.write_text(secret_path, mint(1) + "\n")
|
|
206
|
+
result["secret_file"] = secret_path
|
|
207
|
+
result["takeover"] = shlex.join(
|
|
208
|
+
[
|
|
209
|
+
"raid",
|
|
210
|
+
"--board",
|
|
211
|
+
str(Path(board_dir).resolve()),
|
|
212
|
+
"lead",
|
|
213
|
+
"takeover",
|
|
214
|
+
"--lead-secret",
|
|
215
|
+
identity.token_path(home, meta["uid"], meta["lead"]),
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
result["backup"] = backup
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def rollback(board_dir, home, caller=None, board=None):
|
|
223
|
+
from .board import Board
|
|
224
|
+
from .lock import FileLock
|
|
225
|
+
|
|
226
|
+
board_dir = safeio.check(board_dir, tree=True)
|
|
227
|
+
if board and (Path(board.dir).resolve() != board_dir or Path(board.home).resolve() != Path(home).resolve()):
|
|
228
|
+
raise Precondition("rollback authorization belongs to a different board")
|
|
229
|
+
with FileLock(os.path.join(board_dir, ".lock")):
|
|
230
|
+
board = board or (Board.open(board_dir) if Path(board_dir, "board.json").exists() else None)
|
|
231
|
+
if board and caller is None:
|
|
232
|
+
raise Precondition("rollback requires current lead authorization")
|
|
233
|
+
return _rollback(board_dir, home, board, caller)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _rollback(board_dir, home, board=None, caller=None):
|
|
237
|
+
from .db import Index, purge
|
|
238
|
+
|
|
239
|
+
safeio.check(board_dir, tree=True)
|
|
240
|
+
state = _state(board_dir, home)
|
|
241
|
+
choices = backups(board_dir, home)
|
|
242
|
+
if not choices:
|
|
243
|
+
raise Precondition("no recorded migration backup found")
|
|
244
|
+
backup = Path(state["backup"])
|
|
245
|
+
if _manifest(backup) != state.get("manifest"):
|
|
246
|
+
raise Precondition("migration backup integrity check failed")
|
|
247
|
+
if board:
|
|
248
|
+
board._require_lead(caller, "roll back migration")
|
|
249
|
+
uid = state.get("uid")
|
|
250
|
+
staging = str(board_dir) + ".restore-" + uuid.uuid4().hex
|
|
251
|
+
safeio.copytree(backup, staging)
|
|
252
|
+
if _manifest(staging) != state.get("manifest"):
|
|
253
|
+
remove_tree(staging)
|
|
254
|
+
raise Precondition("migration backup changed during restore")
|
|
255
|
+
root = Path(board_dir)
|
|
256
|
+
for child in root.iterdir():
|
|
257
|
+
if child.name == ".lock":
|
|
258
|
+
continue
|
|
259
|
+
if child.is_dir():
|
|
260
|
+
remove_tree(child)
|
|
261
|
+
else:
|
|
262
|
+
safeio.unlink(child)
|
|
263
|
+
for child in Path(staging).iterdir():
|
|
264
|
+
if child.name != ".lock":
|
|
265
|
+
safeio.replace(child, root / child.name)
|
|
266
|
+
remove_tree(staging)
|
|
267
|
+
if uid:
|
|
268
|
+
index = Index(home)
|
|
269
|
+
with index.write() as conn:
|
|
270
|
+
purge(conn, uid)
|
|
271
|
+
safeio.unlink(_state_path(board_dir, home))
|
|
272
|
+
return {"restored": str(board_dir), "backup": str(backup)}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _backup(board_dir):
|
|
276
|
+
target = os.path.join(board_dir, "v1")
|
|
277
|
+
if os.path.isdir(target):
|
|
278
|
+
return target
|
|
279
|
+
staging = target + ".partial"
|
|
280
|
+
remove_tree(staging, ignore_errors=True)
|
|
281
|
+
safeio.mkdir(staging)
|
|
282
|
+
for name in V1_FILES:
|
|
283
|
+
src = os.path.join(board_dir, name)
|
|
284
|
+
if os.path.exists(src):
|
|
285
|
+
safeio.write_text(Path(staging, name), safeio.read(src))
|
|
286
|
+
for folder in ("cards", "inbox"):
|
|
287
|
+
src = os.path.join(board_dir, folder)
|
|
288
|
+
if os.path.isdir(src):
|
|
289
|
+
safeio.copytree(src, os.path.join(staging, folder))
|
|
290
|
+
safeio.replace(staging, target)
|
|
291
|
+
return target
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _read_v1_card(path):
|
|
295
|
+
text = safeio.read(path).decode("utf-8", errors="replace")
|
|
296
|
+
head, _, body = text.partition("\n---\n")
|
|
297
|
+
meta = {k: "" for k in V1_FIELDS}
|
|
298
|
+
for line in head.splitlines()[1:]:
|
|
299
|
+
k, _, v = line.partition(": ")
|
|
300
|
+
meta[k.strip()] = v.strip()
|
|
301
|
+
return meta, body
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _notes(body):
|
|
305
|
+
out = []
|
|
306
|
+
for line in body.splitlines():
|
|
307
|
+
m = _NOTE.match(line.strip())
|
|
308
|
+
if not m:
|
|
309
|
+
continue
|
|
310
|
+
by = _BY.match(m.group(2))
|
|
311
|
+
out.append(
|
|
312
|
+
{"ts": m.group(1), "by": by.group(1) if by else "", "model": "", "text": by.group(2) if by else m.group(2)}
|
|
313
|
+
)
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _split(value):
|
|
318
|
+
return [x for x in re.split(r"[,\s]+", value or "") if x]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _locks(value):
|
|
322
|
+
out = []
|
|
323
|
+
for raw in _split(value):
|
|
324
|
+
try:
|
|
325
|
+
out.append(P.canonical(raw))
|
|
326
|
+
except Precondition:
|
|
327
|
+
continue
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _num(value, cast):
|
|
332
|
+
try:
|
|
333
|
+
return cast(value)
|
|
334
|
+
except (TypeError, ValueError):
|
|
335
|
+
return cast(0)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _card(meta, body):
|
|
339
|
+
lane = meta["lane"] if meta["lane"] in ("todo", "doing", "review", "blocked", "done") else "todo"
|
|
340
|
+
card = {
|
|
341
|
+
"id": meta["id"],
|
|
342
|
+
"title": meta["title"],
|
|
343
|
+
"lane": lane,
|
|
344
|
+
"owner": meta["owner"],
|
|
345
|
+
"team": meta["team"],
|
|
346
|
+
"ticket": meta["ticket"],
|
|
347
|
+
"scope": meta["scope"],
|
|
348
|
+
"output": "",
|
|
349
|
+
"description": "".join(line for line in body.splitlines(keepends=True) if not _NOTE.match(line.strip())),
|
|
350
|
+
"verify": meta["verify"],
|
|
351
|
+
"verify_cmd": "",
|
|
352
|
+
"locks": _locks(meta["locks"]),
|
|
353
|
+
"depends": _split(meta["depends"]),
|
|
354
|
+
"reviewers": [],
|
|
355
|
+
"fence": 0,
|
|
356
|
+
"released": False,
|
|
357
|
+
"created": meta["updated"] or clock.iso(),
|
|
358
|
+
"updated": meta["updated"] or clock.iso(),
|
|
359
|
+
"notes": _notes(body),
|
|
360
|
+
"reviews": [],
|
|
361
|
+
"verifications": [],
|
|
362
|
+
"costs": [],
|
|
363
|
+
}
|
|
364
|
+
if meta.get("cost_usd") or meta.get("tokens"):
|
|
365
|
+
card["costs"].append(
|
|
366
|
+
{
|
|
367
|
+
"ts": card["updated"],
|
|
368
|
+
"by": meta["owner"],
|
|
369
|
+
"model": meta.get("model", ""),
|
|
370
|
+
"usage": {"input": 0, "cached": 0, "cache_write": 0, "output": 0},
|
|
371
|
+
"tokens": _num(meta.get("tokens"), int),
|
|
372
|
+
"usd": _num(meta.get("cost_usd"), float),
|
|
373
|
+
"usd_source": "migrated",
|
|
374
|
+
"duration_ms": _num(meta.get("duration_ms"), int),
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
return card
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _agents(data):
|
|
381
|
+
out = {}
|
|
382
|
+
for name, a in (data or {}).items():
|
|
383
|
+
out[name] = {
|
|
384
|
+
"team": a.get("team", ""),
|
|
385
|
+
"role": a.get("role", "worker"),
|
|
386
|
+
"joined": a.get("joined", ""),
|
|
387
|
+
"last_seen": a.get("last_seen", ""),
|
|
388
|
+
"card": a.get("card", ""),
|
|
389
|
+
"provider": "",
|
|
390
|
+
"runtime": "",
|
|
391
|
+
"model": "",
|
|
392
|
+
"model_version": "",
|
|
393
|
+
"effort": "",
|
|
394
|
+
"model_source": "unknown",
|
|
395
|
+
"session": "",
|
|
396
|
+
}
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _teams(data):
|
|
401
|
+
return {
|
|
402
|
+
n: {"max": t.get("max", 2), "mission": t.get("mission", ""), "repos": t.get("repos", [])}
|
|
403
|
+
for n, t in (data or {}).items()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _inbox(backup, board_dir):
|
|
408
|
+
src = os.path.join(backup, "inbox")
|
|
409
|
+
if not os.path.isdir(src):
|
|
410
|
+
return
|
|
411
|
+
for name in os.listdir(src):
|
|
412
|
+
if name.endswith(".jsonl"):
|
|
413
|
+
rows = [
|
|
414
|
+
json.loads(line) for line in safeio.read(os.path.join(src, name)).decode().splitlines() if line.strip()
|
|
415
|
+
]
|
|
416
|
+
for r in rows:
|
|
417
|
+
r["task"] = r.pop("card", r.get("task", ""))
|
|
418
|
+
r.setdefault("urgent", False)
|
|
419
|
+
r.setdefault("model", "")
|
|
420
|
+
_write_jsonl(os.path.join(board_dir, "inbox", name), rows)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def migrate(board_dir, index, allow_auth_downgrade=False, lead=None):
|
|
424
|
+
"""Migrate a team-board v1 `.board` in place. v1 files are copied to `.board/v1/` first; board.json is written
|
|
425
|
+
last."""
|
|
426
|
+
preflight(board_dir, allow_auth_downgrade)
|
|
427
|
+
home = os.path.dirname(index.path)
|
|
428
|
+
safeio.check(home, tree=True)
|
|
429
|
+
_reject_existing_outputs(board_dir, home)
|
|
430
|
+
state = _state(board_dir, home)
|
|
431
|
+
if not state:
|
|
432
|
+
state = {"uid": uuid.uuid4().hex, "board_identity": _board_identity(board_dir)}
|
|
433
|
+
_write_json(_state_path(board_dir, home), state)
|
|
434
|
+
provenance = Path(board_dir, "checkout-provenance.json")
|
|
435
|
+
if provenance.exists():
|
|
436
|
+
safeio.unlink(provenance)
|
|
437
|
+
lore = Path(board_dir, "lore")
|
|
438
|
+
dropped_lore = lore.exists()
|
|
439
|
+
if dropped_lore:
|
|
440
|
+
remove_tree(lore)
|
|
441
|
+
backup = _backup(board_dir)
|
|
442
|
+
settings, unmapped, explicit = _settings(backup)
|
|
443
|
+
uid = state["uid"]
|
|
444
|
+
cards = {}
|
|
445
|
+
cards_dir = os.path.join(backup, "cards")
|
|
446
|
+
for name in sorted(os.listdir(cards_dir)) if os.path.isdir(cards_dir) else []:
|
|
447
|
+
if name.endswith(".md"):
|
|
448
|
+
card = _card(*_read_v1_card(os.path.join(cards_dir, name)))
|
|
449
|
+
if card["id"]:
|
|
450
|
+
cards[card["id"]] = card
|
|
451
|
+
agents = _agents(_read_json(os.path.join(backup, "agents.json"), {}))
|
|
452
|
+
history = _read_json(os.path.join(backup, "leases.json"), {})
|
|
453
|
+
leases = len(history)
|
|
454
|
+
for cid, lease in history.items():
|
|
455
|
+
if cid in cards:
|
|
456
|
+
cards[cid]["imported_lease"] = {"active": False, "untrusted": True, "claimed": lease}
|
|
457
|
+
for existing in Path(board_dir, "cards").glob("*.json"):
|
|
458
|
+
safeio.unlink(existing)
|
|
459
|
+
for card in cards.values():
|
|
460
|
+
_write_doc(os.path.join(board_dir, "cards", card["id"] + ".json"), "card", card)
|
|
461
|
+
_write_doc(os.path.join(board_dir, "agents.json"), "agents", agents)
|
|
462
|
+
_write_doc(
|
|
463
|
+
os.path.join(board_dir, "teams.json"), "teams", _teams(_read_json(os.path.join(backup, "teams.json"), {}))
|
|
464
|
+
)
|
|
465
|
+
for stale in ("leases.json",):
|
|
466
|
+
if os.path.exists(os.path.join(board_dir, stale)):
|
|
467
|
+
safeio.unlink(os.path.join(board_dir, stale))
|
|
468
|
+
_inbox(backup, board_dir)
|
|
469
|
+
transcript_count = _transcript(backup, board_dir, index, uid)
|
|
470
|
+
lead = _lead(agents, lead)
|
|
471
|
+
_write_json(
|
|
472
|
+
os.path.join(board_dir, "board.json"),
|
|
473
|
+
{
|
|
474
|
+
"schema": store.SCHEMA,
|
|
475
|
+
"uid": uid,
|
|
476
|
+
"workspace": os.path.basename(os.path.dirname(os.path.abspath(board_dir))),
|
|
477
|
+
"created": clock.iso(),
|
|
478
|
+
"lead": lead,
|
|
479
|
+
"migrated_from": 1,
|
|
480
|
+
"settings": settings,
|
|
481
|
+
"explicit_settings": explicit,
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
for name in os.listdir(os.path.join(board_dir, "cards")) if os.path.isdir(os.path.join(board_dir, "cards")) else []:
|
|
485
|
+
if name.endswith(".md"):
|
|
486
|
+
safeio.unlink(os.path.join(board_dir, "cards", name))
|
|
487
|
+
return {
|
|
488
|
+
"cards": len(cards),
|
|
489
|
+
"agents": len(agents),
|
|
490
|
+
"leases": leases,
|
|
491
|
+
"backup": backup,
|
|
492
|
+
"lead": lead,
|
|
493
|
+
"unmapped_settings": unmapped,
|
|
494
|
+
"dropped_untrusted_lore": dropped_lore,
|
|
495
|
+
"transcript": transcript_count,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _lead(agents, selected=None):
|
|
500
|
+
candidates = sorted(n for n, a in agents.items() if a.get("role") == "lead")
|
|
501
|
+
if selected:
|
|
502
|
+
if selected not in candidates and not (selected == "lead" and ("lead" in agents or not candidates)):
|
|
503
|
+
raise Precondition("--lead must name a v1 lead-role agent")
|
|
504
|
+
return selected
|
|
505
|
+
if "lead" in agents:
|
|
506
|
+
return "lead"
|
|
507
|
+
if len(candidates) > 1:
|
|
508
|
+
raise Precondition("multiple v1 leads; select the confirmed lead with --lead <name>")
|
|
509
|
+
return candidates[0] if candidates else "lead"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _settings(backup):
|
|
513
|
+
from .board import DEFAULT_SETTINGS, _apply_setting, normalize_review_setting
|
|
514
|
+
|
|
515
|
+
settings = dict(DEFAULT_SETTINGS, require_review=normalize_review_setting(0))
|
|
516
|
+
unmapped, explicit = {}, []
|
|
517
|
+
aliases = {"ttl": "ttl_minutes", "default_ttl": "ttl_minutes", "default_ttl_minutes": "ttl_minutes"}
|
|
518
|
+
for filename in ("config.json", "settings.json"):
|
|
519
|
+
raw = _read_json(os.path.join(backup, filename), {})
|
|
520
|
+
for key, value in raw.get("settings", raw).items():
|
|
521
|
+
target = aliases.get(key, key)
|
|
522
|
+
try:
|
|
523
|
+
if target == "require_review" and isinstance(value, dict):
|
|
524
|
+
for subkey, item in value.items():
|
|
525
|
+
_apply_setting(settings, target + "." + subkey, item)
|
|
526
|
+
explicit.append(target + "." + subkey)
|
|
527
|
+
else:
|
|
528
|
+
_apply_setting(settings, target, value)
|
|
529
|
+
except (Precondition, TypeError, ValueError):
|
|
530
|
+
if target in DEFAULT_SETTINGS or target.startswith("require_review."):
|
|
531
|
+
raise Precondition(f"invalid migration setting {filename}:{key}") from None
|
|
532
|
+
unmapped[f"{filename}:{key}"] = value
|
|
533
|
+
else:
|
|
534
|
+
explicit.append(target)
|
|
535
|
+
return settings, unmapped, sorted(set(explicit))
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _transcript(backup, board_dir, index, uid):
|
|
539
|
+
from . import transcript as T
|
|
540
|
+
|
|
541
|
+
path = Path(backup) / "LOG.md"
|
|
542
|
+
for existing in Path(board_dir, "transcript").glob("*.jsonl"):
|
|
543
|
+
safeio.unlink(existing)
|
|
544
|
+
if not path.exists():
|
|
545
|
+
T.Transcript(board_dir, index, uid).reindex()
|
|
546
|
+
return 0
|
|
547
|
+
records = []
|
|
548
|
+
for number, line in enumerate(safeio.read(path).decode().splitlines()):
|
|
549
|
+
match = _NOTE.match(line)
|
|
550
|
+
ts, event = (match.group(1), match.group(2)) if match else ("", line)
|
|
551
|
+
words = event.split()
|
|
552
|
+
verb = words[0] if words else ""
|
|
553
|
+
card, actor = "", ""
|
|
554
|
+
if verb in {"new", "claim", "lock", "move", "release", "reap", "cost", "note", "heartbeat"}:
|
|
555
|
+
card = words[1].rstrip(":") if len(words) > 1 else ""
|
|
556
|
+
by = re.search(r"\bby ([A-Za-z0-9_.-]+)", event)
|
|
557
|
+
if by:
|
|
558
|
+
actor = by.group(1)
|
|
559
|
+
elif verb in {"join", "inbox"} and len(words) > 1:
|
|
560
|
+
actor = words[1]
|
|
561
|
+
elif verb == "msg":
|
|
562
|
+
message = re.match(r"msg \S+ (\S+) -> \S+(?: on (\S+))?", event)
|
|
563
|
+
if message:
|
|
564
|
+
actor, card = message.group(1), message.group(2) or ""
|
|
565
|
+
record = T.make(
|
|
566
|
+
"status",
|
|
567
|
+
{"agent": ""},
|
|
568
|
+
"[untrusted v1 import] " + line,
|
|
569
|
+
task=card,
|
|
570
|
+
context=uid,
|
|
571
|
+
src="v1-log",
|
|
572
|
+
message_id=f"v1-log:{uid}:{number}",
|
|
573
|
+
data={"claimed_action": verb, "claimed_actor": actor, "trust": "untrusted v1 import"},
|
|
574
|
+
)
|
|
575
|
+
record.update(ts=ts, trust="untrusted v1 import", claimed_action=verb, claimed_actor=actor, claimed_text=line)
|
|
576
|
+
records.append(record)
|
|
577
|
+
_write_jsonl(os.path.join(board_dir, "transcript", "000000.jsonl"), records)
|
|
578
|
+
T.Transcript(board_dir, index, uid).reindex()
|
|
579
|
+
return len(records)
|