dotbrain 0.3.4__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.
- dotbrain/__init__.py +7 -0
- dotbrain/_cli_reference.py +115 -0
- dotbrain/adopter_repos.py +573 -0
- dotbrain/beads.py +511 -0
- dotbrain/bootstrap.py +199 -0
- dotbrain/brainspaces.py +238 -0
- dotbrain/cli.py +660 -0
- dotbrain/config.py +428 -0
- dotbrain/doctor.py +279 -0
- dotbrain/hooks.py +84 -0
- dotbrain/migrate.py +304 -0
- dotbrain/paths.py +139 -0
- dotbrain/resource_loader.py +47 -0
- dotbrain/resources/__init__.py +1 -0
- dotbrain/resources/agents/claude/implementer.md +47 -0
- dotbrain/resources/agents/claude/investigator.md +35 -0
- dotbrain/resources/agents/claude/reviewer.md +38 -0
- dotbrain/resources/agents/claude/verifier.md +35 -0
- dotbrain/resources/agents/codex/implementer.toml +26 -0
- dotbrain/resources/agents/codex/investigator.toml +21 -0
- dotbrain/resources/agents/codex/reviewer.toml +27 -0
- dotbrain/resources/agents/codex/verifier.toml +21 -0
- dotbrain/resources/config.yaml +20 -0
- dotbrain/resources/core.yaml +18 -0
- dotbrain/resources/templates/brain/AGENTS.md +9 -0
- dotbrain/resources/templates/brain/DOTBRAIN.md +105 -0
- dotbrain/resources/templates/brain/adr/README.md +8 -0
- dotbrain/resources/templates/brain/designs/README.md +28 -0
- dotbrain/resources/templates/brain/docs/README.md +9 -0
- dotbrain/resources/templates/brain/project.yaml +27 -0
- dotbrain/resources/templates/gitignore +17 -0
- dotbrain/skills.py +264 -0
- dotbrain/subagents.py +252 -0
- dotbrain/updater.py +105 -0
- dotbrain/workflows.py +529 -0
- dotbrain-0.3.4.dist-info/METADATA +21 -0
- dotbrain-0.3.4.dist-info/RECORD +40 -0
- dotbrain-0.3.4.dist-info/WHEEL +4 -0
- dotbrain-0.3.4.dist-info/entry_points.txt +2 -0
- dotbrain-0.3.4.dist-info/licenses/LICENSE +21 -0
dotbrain/doctor.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Read-only health check for dotbrain: machine readiness, project wiring, beads drift.
|
|
2
|
+
|
|
3
|
+
Self-contained diagnostic module. No filesystem mutations — pure inspections only.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dotbrain import config, paths, resource_loader
|
|
15
|
+
from dotbrain.adopter_repos import is_dotbrain_repo
|
|
16
|
+
|
|
17
|
+
# Same shape as bootstrap.Runner; injected so tests can record without real subprocess calls.
|
|
18
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_run(
|
|
22
|
+
argv: list[str], *, cwd: Path | None = None, check: bool = True, timeout: int | None = None
|
|
23
|
+
) -> subprocess.CompletedProcess[str]:
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
argv, cwd=cwd, check=check, capture_output=True, encoding="utf-8", timeout=timeout,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Finding:
|
|
31
|
+
status: str # "ok", "warn", "error"
|
|
32
|
+
message: str
|
|
33
|
+
suggestion: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DoctorReport:
|
|
38
|
+
machine: list[Finding] = field(default_factory=list)
|
|
39
|
+
projects: dict[str, list[Finding]] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------- machine readiness
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_binary(name: str) -> Finding:
|
|
46
|
+
if shutil.which(name):
|
|
47
|
+
return Finding("ok", f"{name} available")
|
|
48
|
+
return Finding("error", f"{name} not found on PATH",
|
|
49
|
+
f"install {name} or add it to PATH")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_dotbrain_config(root: Path) -> Finding:
|
|
53
|
+
try:
|
|
54
|
+
config.load_config(root)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
return Finding("error", f"config.yaml unreadable: {exc}",
|
|
57
|
+
"check config.yaml syntax")
|
|
58
|
+
return Finding("ok", "config.yaml readable")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _check_global_skills_config(root: Path) -> Finding:
|
|
62
|
+
path = root / "skills" / "skills.yaml"
|
|
63
|
+
if path.is_file():
|
|
64
|
+
return Finding("ok", "operator skills config present")
|
|
65
|
+
return Finding("ok", "using packaged required-core skills only")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_templates(root: Path) -> Finding:
|
|
69
|
+
if resource_loader.resource("templates/brain/AGENTS.md").is_file():
|
|
70
|
+
return Finding("ok", "package templates/brain present")
|
|
71
|
+
return Finding("error", "package templates/brain missing",
|
|
72
|
+
"installed package is missing bundled templates")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_machine(root: Path, home: Path) -> list[Finding]:
|
|
76
|
+
findings: list[Finding] = [
|
|
77
|
+
_check_binary("bd"),
|
|
78
|
+
_check_dotbrain_config(root),
|
|
79
|
+
_check_global_skills_config(root),
|
|
80
|
+
_check_templates(root),
|
|
81
|
+
]
|
|
82
|
+
return findings
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- project wiring
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_repo_file(brainspace: Path) -> tuple[Finding | None, Path | None]:
|
|
89
|
+
"""Returns (finding_or_None, resolved_repo_path_or_None).
|
|
90
|
+
|
|
91
|
+
finding is None when the repo exists and is valid; the caller proceeds with wiring checks.
|
|
92
|
+
resolved_repo_path is None for brain-only projects or when the target is missing.
|
|
93
|
+
"""
|
|
94
|
+
repo_file = brainspace / ".repo"
|
|
95
|
+
if not repo_file.is_file():
|
|
96
|
+
# Undeclared: the Brainspace names neither a repo nor brain-only. Both are
|
|
97
|
+
# resolvable, and only the operator knows which was intended, so name both.
|
|
98
|
+
return (Finding("error", f".repo file missing in {brainspace.name}",
|
|
99
|
+
f"run 'dotbrain wire --repo <repo>' to wire a code repo, "
|
|
100
|
+
f"or 'dotbrain wire --name {brainspace.name} --no-repo' "
|
|
101
|
+
f"to declare it brain-only"),
|
|
102
|
+
None)
|
|
103
|
+
content = repo_file.read_text(encoding="utf-8").strip()
|
|
104
|
+
if content.startswith("(brain-only)"):
|
|
105
|
+
return (Finding("ok", "brain-only project (no code repo)"), None)
|
|
106
|
+
resolved = Path(content).expanduser().resolve() if content else None
|
|
107
|
+
if not resolved or not resolved.is_dir():
|
|
108
|
+
return (Finding("error", f".repo target not found: {content}",
|
|
109
|
+
f"update {brainspace.name}/.repo or run 'dotbrain wire'"),
|
|
110
|
+
None)
|
|
111
|
+
return (None, resolved) # repo exists; wiring checks follow
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_brainspace_links(repo: Path, brainspace: Path) -> list[Finding]:
|
|
115
|
+
findings: list[Finding] = []
|
|
116
|
+
active_agents = config.load_project_agents(brainspace.parents[1], brainspace.name)
|
|
117
|
+
for agent in (agent for agent in ("claude", "codex") if agent in active_agents):
|
|
118
|
+
workspace = repo / f".{agent}"
|
|
119
|
+
if not workspace.is_dir() or workspace.is_symlink():
|
|
120
|
+
findings.append(Finding(
|
|
121
|
+
"warn",
|
|
122
|
+
f"{workspace} is not a materialized workspace",
|
|
123
|
+
f"run 'dotbrain refresh --name {brainspace.name}'",
|
|
124
|
+
))
|
|
125
|
+
for name in paths.BRAINSPACE_LINKS:
|
|
126
|
+
link = repo / name
|
|
127
|
+
if not link.is_symlink():
|
|
128
|
+
findings.append(Finding("warn", f"{repo}/{name} not wired",
|
|
129
|
+
f"run 'dotbrain wire --repo {repo}'"))
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
link.resolve(strict=True)
|
|
133
|
+
except OSError:
|
|
134
|
+
findings.append(Finding("error", f"{repo}/{name} broken symlink",
|
|
135
|
+
f"run 'dotbrain wire --repo {repo}' to repair"))
|
|
136
|
+
return findings
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_repo_excludes(repo: Path) -> list[Finding]:
|
|
140
|
+
findings: list[Finding] = []
|
|
141
|
+
present = paths.exclude_entries(repo)
|
|
142
|
+
for entry in paths.EXCLUDE_ENTRIES:
|
|
143
|
+
if entry not in present:
|
|
144
|
+
findings.append(Finding("warn",
|
|
145
|
+
f"{repo}/.git/info/exclude missing {entry}",
|
|
146
|
+
f"run 'dotbrain wire --repo {repo}' to repair"))
|
|
147
|
+
return findings
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Wire's idempotency marker in append_pointer_to_file: ".brain/AGENTS.md" substring.
|
|
151
|
+
# Doctor mirrors this so its "missing pointer" finding can actually be fixed by dotbrain wire.
|
|
152
|
+
_WIRE_POINTER_MARKER = ".brain/AGENTS.md"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_agent_pointer(repo: Path) -> list[Finding]:
|
|
156
|
+
findings: list[Finding] = []
|
|
157
|
+
for name in ("AGENTS.md", "CLAUDE.md"):
|
|
158
|
+
file = repo / name
|
|
159
|
+
if not file.exists():
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
target = file.resolve(strict=True) if file.is_symlink() else file
|
|
163
|
+
except OSError:
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
if _WIRE_POINTER_MARKER not in target.read_text(encoding="utf-8"):
|
|
167
|
+
findings.append(Finding("warn",
|
|
168
|
+
f"{file} missing dotbrain pointer",
|
|
169
|
+
f"run 'dotbrain wire --repo {repo}' to repair"))
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
return findings
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_project_wiring(brainspace: Path, dotbrain_home: Path) -> list[Finding]:
|
|
176
|
+
findings: list[Finding] = []
|
|
177
|
+
|
|
178
|
+
repo_finding, resolved = _check_repo_file(brainspace)
|
|
179
|
+
if repo_finding is not None:
|
|
180
|
+
findings.append(repo_finding)
|
|
181
|
+
if repo_finding.status == "error":
|
|
182
|
+
return findings
|
|
183
|
+
if resolved is None:
|
|
184
|
+
return findings # brain-only or repo target missing
|
|
185
|
+
|
|
186
|
+
# The dotbrain repo itself isn't an adopter — skip wiring checks on it.
|
|
187
|
+
if is_dotbrain_repo(resolved, dotbrain_home):
|
|
188
|
+
return [Finding("ok", "dotbrain repo (not an adopter)")]
|
|
189
|
+
|
|
190
|
+
findings += _check_brainspace_links(resolved, brainspace)
|
|
191
|
+
findings += _check_repo_excludes(resolved)
|
|
192
|
+
if paths.INJECT_ADOPTER_POINTER:
|
|
193
|
+
findings += _check_agent_pointer(resolved)
|
|
194
|
+
return findings
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --------------------------------------------------------------------------- beads state
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _check_beads_state(
|
|
201
|
+
brainspace: Path, name: str, dotbrain_home: Path, *, run: Runner = _default_run,
|
|
202
|
+
) -> list[Finding]:
|
|
203
|
+
findings: list[Finding] = []
|
|
204
|
+
beads_cfg = config.load_project_config(dotbrain_home, name)
|
|
205
|
+
|
|
206
|
+
if beads_cfg.mode == "none":
|
|
207
|
+
return [Finding("ok", "beads disabled (mode: none)")]
|
|
208
|
+
|
|
209
|
+
beads_dir = brainspace / ".beads"
|
|
210
|
+
if not beads_dir.is_dir() or not (beads_dir / "metadata.json").is_file():
|
|
211
|
+
return [Finding("warn", ".beads not initialized",
|
|
212
|
+
f"run 'dotbrain beads load --name {name}' or 'dotbrain wire --all'")]
|
|
213
|
+
|
|
214
|
+
if beads_cfg.mode == "server":
|
|
215
|
+
try:
|
|
216
|
+
run(["bd", "dolt", "test"], cwd=brainspace, check=True, timeout=15)
|
|
217
|
+
findings.append(Finding("ok", "beads server reachable"))
|
|
218
|
+
except subprocess.CalledProcessError as exc:
|
|
219
|
+
detail = (exc.stderr or exc.stdout or "").strip().splitlines()
|
|
220
|
+
msg = detail[-1] if detail else "bd dolt test failed"
|
|
221
|
+
findings.append(Finding("error", f"beads server unreachable: {msg}",
|
|
222
|
+
"check beads.server.host in dotbrain.yaml"))
|
|
223
|
+
except FileNotFoundError:
|
|
224
|
+
findings.append(Finding("error", "bd not found; skipping beads connectivity check",
|
|
225
|
+
"install bd to check beads server connectivity"))
|
|
226
|
+
except subprocess.TimeoutExpired:
|
|
227
|
+
findings.append(Finding("error", "beads server timed out",
|
|
228
|
+
"check connectivity to beads server"))
|
|
229
|
+
else:
|
|
230
|
+
findings.append(Finding("ok", f"beads mode: {beads_cfg.mode}"))
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
result = run(["bd", "-C", str(brainspace), "ready"], cwd=brainspace, check=False, timeout=15)
|
|
234
|
+
if result.returncode != 0:
|
|
235
|
+
msg = (result.stderr or "").strip().splitlines()
|
|
236
|
+
snippet = msg[-1] if msg else "bd ready returned non-zero"
|
|
237
|
+
findings.append(Finding("warn", f"bd ready failed: {snippet}",
|
|
238
|
+
"beads tracker may need attention: bd doctor, bd dolt pull"))
|
|
239
|
+
else:
|
|
240
|
+
findings.append(Finding("ok", "bd ready responds"))
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
findings.append(Finding("error", "bd not found; skipping beads check"))
|
|
243
|
+
|
|
244
|
+
return findings
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --------------------------------------------------------------------------- orchestration
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_doctor(
|
|
251
|
+
dotbrain_home: Path, home: Path | None = None, *, run: Runner = _default_run,
|
|
252
|
+
) -> DoctorReport:
|
|
253
|
+
home = Path(home) if home is not None else Path.home()
|
|
254
|
+
root = dotbrain_home.resolve()
|
|
255
|
+
|
|
256
|
+
report = DoctorReport(
|
|
257
|
+
machine=_check_machine(root, home),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
brainspaces = paths.brainspaces(root)
|
|
261
|
+
if not brainspaces:
|
|
262
|
+
report.machine.append(Finding("warn", f"no Brainspaces in {paths.data_dir(root).name}/",
|
|
263
|
+
"create a project with 'dotbrain wire'"))
|
|
264
|
+
return report
|
|
265
|
+
|
|
266
|
+
for brainspace in brainspaces:
|
|
267
|
+
name = brainspace.name
|
|
268
|
+
findings: list[Finding] = []
|
|
269
|
+
|
|
270
|
+
wiring_findings = _check_project_wiring(brainspace, root)
|
|
271
|
+
findings += wiring_findings
|
|
272
|
+
|
|
273
|
+
if shutil.which("bd"):
|
|
274
|
+
findings += _check_beads_state(brainspace, name, root, run=run)
|
|
275
|
+
|
|
276
|
+
if findings:
|
|
277
|
+
report.projects[name] = findings
|
|
278
|
+
|
|
279
|
+
return report
|
dotbrain/hooks.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""SessionStart hook: assemble a wired repo's Brain context.
|
|
2
|
+
|
|
3
|
+
Pure Python by design. This ran as a packaged bash script until the Windows chain
|
|
4
|
+
(PowerShell -> Python -> Git Bash) proved unreliable: locating a real ``bash`` behind
|
|
5
|
+
Windows' ``System32`` WSL launcher depends on how Git was installed, and when the
|
|
6
|
+
resolver picked the stub the script emitted nothing. Because the hook is fail-open,
|
|
7
|
+
that surfaced as a session with no Brain context and no error at all.
|
|
8
|
+
|
|
9
|
+
Only ``DOTBRAIN.md`` is injected. dotbrain owns that file, so its size is bounded and
|
|
10
|
+
guarded by a test. The project's own ``.brain/AGENTS.md`` is deliberately left out: it
|
|
11
|
+
grows with the project and would eventually cross the payload limit, at which point the
|
|
12
|
+
runtime spills stdout to a file and the model receives none of it — including the
|
|
13
|
+
convention. ``DOTBRAIN.md`` carries a rule telling the agent to read it instead.
|
|
14
|
+
|
|
15
|
+
Output is assembled as bytes and written to ``stdout.buffer`` so the payload is exactly
|
|
16
|
+
what ``cat`` produced: no newline translation on Windows, no re-encoding of Brain files.
|
|
17
|
+
|
|
18
|
+
Beads context lives in project hooks (``bd prime --hook-json`` on Claude Code,
|
|
19
|
+
``bd codex-hook SessionStart`` on Codex), so this hook does not set ``BEADS_DIR``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import BinaryIO
|
|
28
|
+
|
|
29
|
+
CONVENTION_HEADING = "## dotbrain convention"
|
|
30
|
+
|
|
31
|
+
# Hook stdout over this many bytes is spilled to a file and never reaches the model.
|
|
32
|
+
# Measured on Claude Code: 9,961 bytes delivered, 10,010 did not.
|
|
33
|
+
PAYLOAD_LIMIT = 10_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def repo_root(cwd: Path | None = None) -> Path | None:
|
|
37
|
+
"""Return the enclosing git repo root, or None outside one (and with no git)."""
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
completed = subprocess.run(
|
|
41
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
42
|
+
cwd=cwd,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
check=False,
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
49
|
+
if completed.returncode != 0:
|
|
50
|
+
return None
|
|
51
|
+
top = (completed.stdout or "").strip()
|
|
52
|
+
return Path(top) if top else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def brain_context(cwd: Path | None = None) -> bytes:
|
|
56
|
+
"""Build the SessionStart payload for a wired repo; empty bytes when there is no Brain."""
|
|
57
|
+
|
|
58
|
+
root = repo_root(cwd)
|
|
59
|
+
if root is None:
|
|
60
|
+
return b""
|
|
61
|
+
convention = root / ".brain" / "DOTBRAIN.md"
|
|
62
|
+
if not convention.is_file():
|
|
63
|
+
return b""
|
|
64
|
+
|
|
65
|
+
heading = (CONVENTION_HEADING + "\n\n").encode("utf-8")
|
|
66
|
+
return heading + convention.read_bytes() + b"\n\n"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def emit_brain_context(stream: BinaryIO | None = None, cwd: Path | None = None) -> None:
|
|
70
|
+
"""Write the payload to a binary stream. Fail-open: never raises, never exits nonzero."""
|
|
71
|
+
|
|
72
|
+
target = stream if stream is not None else sys.stdout.buffer
|
|
73
|
+
try:
|
|
74
|
+
payload = brain_context(cwd)
|
|
75
|
+
except OSError:
|
|
76
|
+
return
|
|
77
|
+
if not payload:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
target.write(payload)
|
|
81
|
+
target.flush()
|
|
82
|
+
except (OSError, ValueError):
|
|
83
|
+
# stdout closed or EPIPE at hook exit must not surface as a hook failure.
|
|
84
|
+
return
|
dotbrain/migrate.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Migrate a project's beads tracker from embedded Dolt to a remote sql-server.
|
|
2
|
+
|
|
3
|
+
dotbrain adopters start local-by-default: ``dotbrain wire`` initialises beads with an embedded
|
|
4
|
+
Dolt engine. Moving an already-wired brain onto a shared sql-server has to preserve the full
|
|
5
|
+
Dolt commit graph, so this uses ``bd backup`` (Dolt-native, history-preserving) rather than the
|
|
6
|
+
``bd export`` JSONL path, which drops branches/history.
|
|
7
|
+
|
|
8
|
+
Per-project sequence (all ``bd`` calls run with ``cwd=<brainspace>`` and ``_beads_env(brainspace)``):
|
|
9
|
+
|
|
10
|
+
1. ``bd stats --json`` -> capture ``summary.total_issues`` (pre_count)
|
|
11
|
+
2. ``bd backup init <backup-dir>``
|
|
12
|
+
3. ``bd backup sync`` -> full Dolt backup of the embedded db
|
|
13
|
+
4. ``bd init --server --external ... --reinit-local --destroy-token DESTROY-<prefix>``
|
|
14
|
+
-> repoint .beads at the server (fresh empty db)
|
|
15
|
+
5. ``bd backup restore --force <backup-dir>`` -> load full history into the server db
|
|
16
|
+
6. ``bd dolt test`` -> connection sanity
|
|
17
|
+
7. ``bd stats --json`` -> post_count; assert it matches pre_count
|
|
18
|
+
|
|
19
|
+
The embedded ``.beads/embeddeddolt`` and the backup dir are kept by default as the rollback path;
|
|
20
|
+
cleanup stays manual until a migration's history depth is verified on the server.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from dotbrain import config, paths
|
|
30
|
+
from dotbrain.beads import (
|
|
31
|
+
Runner,
|
|
32
|
+
_bd_init_args,
|
|
33
|
+
_beads_env,
|
|
34
|
+
_default_run,
|
|
35
|
+
_hide_root_beads,
|
|
36
|
+
_restore_root_beads,
|
|
37
|
+
normalize_server_beads_metadata,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- pure helpers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def destroy_token(project: str) -> str:
|
|
45
|
+
"""The non-interactive ``bd init`` confirmation token for re-initialising over local data."""
|
|
46
|
+
return f"DESTROY-{project}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def backup_dir_for(brainspace: Path) -> Path:
|
|
50
|
+
"""Backup destination, kept outside tracked project Brainspaces."""
|
|
51
|
+
brainspace = Path(brainspace)
|
|
52
|
+
return brainspace.parent.parent / "backups" / "beads" / brainspace.name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_total_issues(stdout: str) -> int | None:
|
|
56
|
+
"""Pull ``summary.total_issues`` out of ``bd stats --json``; None when it can't be parsed."""
|
|
57
|
+
try:
|
|
58
|
+
return int(json.loads(stdout)["summary"]["total_issues"])
|
|
59
|
+
except (ValueError, KeyError, TypeError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def beads_mode(brainspace: Path) -> str:
|
|
64
|
+
"""Classify a Brainspace's beads backend: ``embedded`` | ``server`` | ``none`` | ``unknown``.
|
|
65
|
+
|
|
66
|
+
Reads ``.beads/metadata.json`` directly (the same file bootstrap inspects); avoids shelling out.
|
|
67
|
+
"""
|
|
68
|
+
metadata = Path(brainspace) / ".beads" / "metadata.json"
|
|
69
|
+
if not (Path(brainspace) / ".beads").is_dir():
|
|
70
|
+
return "none"
|
|
71
|
+
if not metadata.is_file():
|
|
72
|
+
return "unknown"
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(metadata.read_text(encoding="utf-8"))
|
|
75
|
+
except (ValueError, OSError):
|
|
76
|
+
return "unknown"
|
|
77
|
+
mode = str(data.get("dolt_mode", "")).strip()
|
|
78
|
+
if mode == "embedded":
|
|
79
|
+
return "embedded"
|
|
80
|
+
if mode in {"server", "external"}:
|
|
81
|
+
return "server"
|
|
82
|
+
return "unknown"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- result type
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class MigrationResult:
|
|
90
|
+
project: str = ""
|
|
91
|
+
brainspace: Path | None = None
|
|
92
|
+
status: str = "" # migrated|migrated-unverified|skipped-server|skipped-no-beads|skipped-unknown|aborted-count-mismatch|dry-run|failed
|
|
93
|
+
pre_count: int | None = None
|
|
94
|
+
post_count: int | None = None
|
|
95
|
+
logs: list[str] = field(default_factory=list)
|
|
96
|
+
warnings: list[str] = field(default_factory=list)
|
|
97
|
+
planned_commands: list[list[str]] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------- orchestration
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _migration_argv(
|
|
104
|
+
project: str, database: str, host: str, port: str, user: str, backup: Path
|
|
105
|
+
) -> list[list[str]]:
|
|
106
|
+
"""The ordered bd command sequence for one embedded -> server migration."""
|
|
107
|
+
return [
|
|
108
|
+
["bd", "stats", "--json"],
|
|
109
|
+
["bd", "backup", "init", str(backup)],
|
|
110
|
+
["bd", "backup", "sync"],
|
|
111
|
+
*_backup_server_copy_argv(host, backup),
|
|
112
|
+
_bd_init_args(
|
|
113
|
+
project, "", host, port, user, database or project,
|
|
114
|
+
reinit_local=True, destroy_token=destroy_token(project),
|
|
115
|
+
),
|
|
116
|
+
["bd", "backup", "restore", "--force", str(backup)],
|
|
117
|
+
["bd", "dolt", "test"],
|
|
118
|
+
["bd", "stats", "--json"],
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Non-interactive SSH for the backup copy: the migration runs with no TTY, so a
|
|
123
|
+
# first-connect host-key prompt or an unreachable server would hang it forever.
|
|
124
|
+
# BatchMode disables prompts and ConnectTimeout bounds the wait, turning either
|
|
125
|
+
# case into a fast, surfaced error instead of a silent stall.
|
|
126
|
+
_SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]
|
|
127
|
+
_RSYNC_SSH = "ssh " + " ".join(_SSH_OPTS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _host_is_local(host: str) -> bool:
|
|
131
|
+
return host in {"", "localhost", "127.0.0.1", "::1"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _backup_server_copy_argv(host: str, backup: Path) -> list[list[str]]:
|
|
135
|
+
"""Make a local file:// backup visible to a remote external Dolt server."""
|
|
136
|
+
if _host_is_local(host):
|
|
137
|
+
return []
|
|
138
|
+
return [
|
|
139
|
+
["ssh", *_SSH_OPTS, host, "mkdir", "-p", str(backup.parent)],
|
|
140
|
+
["rsync", "-a", "--delete", "-e", _RSYNC_SSH, f"{backup}/", f"{host}:{backup}/"],
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def migrate_project(
|
|
145
|
+
*,
|
|
146
|
+
dotbrain_home: Path,
|
|
147
|
+
project: str,
|
|
148
|
+
server_host: str,
|
|
149
|
+
server_port: str = "3307",
|
|
150
|
+
server_user: str = "beads",
|
|
151
|
+
database: str = "",
|
|
152
|
+
dry_run: bool = False,
|
|
153
|
+
run: Runner = _default_run,
|
|
154
|
+
) -> MigrationResult:
|
|
155
|
+
dotbrain_home = Path(dotbrain_home)
|
|
156
|
+
brainspace = paths.brainspace(dotbrain_home, project)
|
|
157
|
+
result = MigrationResult(project=project, brainspace=brainspace)
|
|
158
|
+
|
|
159
|
+
mode = beads_mode(brainspace)
|
|
160
|
+
if mode == "server":
|
|
161
|
+
result.status = "skipped-server"
|
|
162
|
+
result.logs.append(f"{project}: already on a Dolt server; nothing to migrate")
|
|
163
|
+
return result
|
|
164
|
+
if mode == "none":
|
|
165
|
+
result.status = "skipped-no-beads"
|
|
166
|
+
result.logs.append(f"{project}: no .beads to migrate")
|
|
167
|
+
return result
|
|
168
|
+
if mode == "unknown":
|
|
169
|
+
result.status = "skipped-unknown"
|
|
170
|
+
result.warnings.append(
|
|
171
|
+
f"{project}: could not determine beads mode from metadata.json; skipping to avoid a "
|
|
172
|
+
"destructive re-init"
|
|
173
|
+
)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
database = database or project
|
|
177
|
+
backup = backup_dir_for(brainspace)
|
|
178
|
+
commands = _migration_argv(
|
|
179
|
+
project, database, server_host, server_port, server_user, backup
|
|
180
|
+
)
|
|
181
|
+
backup_copy_commands = _backup_server_copy_argv(server_host, backup)
|
|
182
|
+
init_args = commands[3 + len(backup_copy_commands)]
|
|
183
|
+
|
|
184
|
+
if dry_run:
|
|
185
|
+
result.status = "dry-run"
|
|
186
|
+
result.planned_commands = commands
|
|
187
|
+
result.logs.append(f"{project}: dry-run; {len(commands)} bd commands planned")
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
env = _beads_env(brainspace)
|
|
191
|
+
hidden = _hide_root_beads(brainspace, dotbrain_home)
|
|
192
|
+
try:
|
|
193
|
+
stats_pre = run(["bd", "stats", "--json"], cwd=brainspace, env=env, check=True)
|
|
194
|
+
result.pre_count = parse_total_issues(stats_pre.stdout or "")
|
|
195
|
+
|
|
196
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
run(["bd", "backup", "init", str(backup)], cwd=brainspace, env=env, check=True)
|
|
198
|
+
run(["bd", "backup", "sync"], cwd=brainspace, env=env, check=True)
|
|
199
|
+
for argv in backup_copy_commands:
|
|
200
|
+
run(argv, cwd=brainspace, env=env, check=True)
|
|
201
|
+
run(init_args, cwd=brainspace, env=env, check=True) # bd init --server ... --reinit-local
|
|
202
|
+
run(["bd", "backup", "restore", "--force", str(backup)], cwd=brainspace, env=env, check=True)
|
|
203
|
+
run(["bd", "dolt", "test"], cwd=brainspace, env=env, check=True)
|
|
204
|
+
# bd writes the legacy dolt_server_port into metadata.json; make the port file primary.
|
|
205
|
+
normalize_server_beads_metadata(brainspace / ".beads", server_port)
|
|
206
|
+
|
|
207
|
+
stats_post = run(["bd", "stats", "--json"], cwd=brainspace, env=env, check=True)
|
|
208
|
+
result.post_count = parse_total_issues(stats_post.stdout or "")
|
|
209
|
+
finally:
|
|
210
|
+
_restore_root_beads(hidden, dotbrain_home)
|
|
211
|
+
|
|
212
|
+
if result.pre_count is None or result.post_count is None:
|
|
213
|
+
result.status = "migrated-unverified"
|
|
214
|
+
result.warnings.append(
|
|
215
|
+
f"{project}: migrated, but could not verify issue count from bd stats; verify history "
|
|
216
|
+
"depth on the server before removing embedded data"
|
|
217
|
+
)
|
|
218
|
+
_record_migrated_backend(dotbrain_home, project, database, result)
|
|
219
|
+
return result
|
|
220
|
+
if result.post_count != result.pre_count:
|
|
221
|
+
result.status = "aborted-count-mismatch"
|
|
222
|
+
result.warnings.append(
|
|
223
|
+
f"{project}: post-restore count {result.post_count} != pre-migration "
|
|
224
|
+
f"{result.pre_count}; embedded data and backup left intact for rollback"
|
|
225
|
+
)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
result.status = "migrated"
|
|
229
|
+
result.logs.append(
|
|
230
|
+
f"{project}: migrated to {server_host}:{server_port}/{database} "
|
|
231
|
+
f"({result.post_count} issues); embedded data + backup kept for rollback"
|
|
232
|
+
)
|
|
233
|
+
_record_migrated_backend(dotbrain_home, project, database, result)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _record_migrated_backend(
|
|
238
|
+
dotbrain_home: Path, project: str, database: str, result: MigrationResult
|
|
239
|
+
) -> None:
|
|
240
|
+
"""The project is on the server now; its dotbrain.yaml entry must say so."""
|
|
241
|
+
if database != project:
|
|
242
|
+
log = config.record_project_beads(
|
|
243
|
+
dotbrain_home, project, config.ProjectBeads(mode="server", database=database)
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
log = config.remove_project_beads(dotbrain_home, project)
|
|
247
|
+
if log:
|
|
248
|
+
result.logs.append(log)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _looks_like_ssh_failure(exc: Exception) -> bool:
|
|
252
|
+
"""True when the failed command was the ssh/rsync backup copy (a likely host-key issue)."""
|
|
253
|
+
cmd = getattr(exc, "cmd", None)
|
|
254
|
+
return bool(cmd) and cmd[0] in {"ssh", "rsync"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def safe_migrate_project(**kwargs) -> MigrationResult:
|
|
258
|
+
"""``migrate_project`` that turns any subprocess failure into a ``failed`` result.
|
|
259
|
+
|
|
260
|
+
Both the single-project CLI path and the ``--all`` sweep use this so a failed migration
|
|
261
|
+
reports a clean status and an actionable hint instead of crashing with a traceback. Accepts
|
|
262
|
+
the same keyword arguments as ``migrate_project``.
|
|
263
|
+
"""
|
|
264
|
+
project = kwargs.get("project", "")
|
|
265
|
+
try:
|
|
266
|
+
return migrate_project(**kwargs)
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
brainspace = paths.brainspace(Path(kwargs["dotbrain_home"]), project) if project else None
|
|
269
|
+
detail = str(exc)
|
|
270
|
+
stderr = (getattr(exc, "stderr", "") or "").strip()
|
|
271
|
+
if stderr:
|
|
272
|
+
detail = f"{detail}: {stderr}"
|
|
273
|
+
warnings = [f"{project}: migration failed: {detail}"]
|
|
274
|
+
if _looks_like_ssh_failure(exc):
|
|
275
|
+
warnings.append(
|
|
276
|
+
f"{project}: the Dolt server's SSH host key may be untrusted; trust it first "
|
|
277
|
+
"(e.g. `ssh-keyscan <host> >> ~/.ssh/known_hosts`) and retry"
|
|
278
|
+
)
|
|
279
|
+
return MigrationResult(project=project, brainspace=brainspace, status="failed", warnings=warnings)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def migrate_all(
|
|
283
|
+
*,
|
|
284
|
+
dotbrain_home: Path,
|
|
285
|
+
server_host: str,
|
|
286
|
+
server_port: str = "3307",
|
|
287
|
+
server_user: str = "beads",
|
|
288
|
+
dry_run: bool = False,
|
|
289
|
+
run: Runner = _default_run,
|
|
290
|
+
) -> list[MigrationResult]:
|
|
291
|
+
"""Migrate every embedded Brainspace; skip server/none ones; never abort the whole sweep."""
|
|
292
|
+
dotbrain_home = Path(dotbrain_home)
|
|
293
|
+
return [
|
|
294
|
+
safe_migrate_project(
|
|
295
|
+
dotbrain_home=dotbrain_home,
|
|
296
|
+
project=brainspace.name,
|
|
297
|
+
server_host=server_host,
|
|
298
|
+
server_port=server_port,
|
|
299
|
+
server_user=server_user,
|
|
300
|
+
dry_run=dry_run,
|
|
301
|
+
run=run,
|
|
302
|
+
)
|
|
303
|
+
for brainspace in paths.brainspaces(dotbrain_home)
|
|
304
|
+
]
|