gitgrip 1.5.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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- gr2_overlay/__init__.py +37 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import tomllib
|
|
11
|
+
import contextlib
|
|
12
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from . import grip
|
|
17
|
+
from .gitops import git
|
|
18
|
+
from gr2.prototypes import lane_workspace_prototype as lane_proto
|
|
19
|
+
from gr2.prototypes import repo_maintenance_prototype as repo_proto
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _transaction_phase_hook(_phase: str) -> None:
|
|
23
|
+
"""Inert production seam; scratch interruption tests replace this callable."""
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def gr1_manifest_path(workspace_root: Path) -> Path:
|
|
28
|
+
return workspace_root / ".gitgrip" / "spaces" / "main" / "gripspace.yml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def gr1_agents_path(workspace_root: Path) -> Path:
|
|
32
|
+
return workspace_root / ".gitgrip" / "agents.toml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def gr1_state_paths(workspace_root: Path) -> dict[str, Path]:
|
|
36
|
+
gitgrip = workspace_root / ".gitgrip"
|
|
37
|
+
return {
|
|
38
|
+
"state_json": gitgrip / "state.json",
|
|
39
|
+
"sync_state_json": gitgrip / "sync-state.json",
|
|
40
|
+
"griptrees_json": gitgrip / "griptrees.json",
|
|
41
|
+
"manifest_yaml": gr1_manifest_path(workspace_root),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_gr1_workspace(workspace_root: Path) -> dict[str, object]:
|
|
46
|
+
manifest_path = gr1_manifest_path(workspace_root)
|
|
47
|
+
if not manifest_path.exists():
|
|
48
|
+
return {
|
|
49
|
+
"detected": False,
|
|
50
|
+
"workspace_root": str(workspace_root),
|
|
51
|
+
"reason": f"missing {manifest_path}",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
manifest = yaml.safe_load(manifest_path.read_text()) or {}
|
|
55
|
+
repos = manifest.get("repos", {}) or {}
|
|
56
|
+
agents_doc = _load_agents_doc(workspace_root)
|
|
57
|
+
agent_names = sorted(((agents_doc.get("agents") or {}) or {}).keys())
|
|
58
|
+
reference_repos = sorted(name for name, repo in repos.items() if bool((repo or {}).get("reference", False)))
|
|
59
|
+
writable_repos = sorted(name for name in repos.keys() if name not in reference_repos)
|
|
60
|
+
state_paths = gr1_state_paths(workspace_root)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"detected": True,
|
|
64
|
+
"workspace_root": str(workspace_root),
|
|
65
|
+
"manifest_path": str(manifest_path),
|
|
66
|
+
"repo_count": len(repos),
|
|
67
|
+
"reference_repo_count": len(reference_repos),
|
|
68
|
+
"agent_count": len(agent_names),
|
|
69
|
+
"repos": sorted(repos.keys()),
|
|
70
|
+
"reference_repos": reference_repos,
|
|
71
|
+
"writable_repos": writable_repos,
|
|
72
|
+
"agents": agent_names,
|
|
73
|
+
"state_files": {key: str(path) for key, path in state_paths.items() if path.exists()},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def migrate_gr1_workspace(workspace_root: Path, *, force: bool = False) -> dict[str, object]:
|
|
78
|
+
detection = detect_gr1_workspace(workspace_root)
|
|
79
|
+
if not detection["detected"]:
|
|
80
|
+
raise SystemExit(detection["reason"])
|
|
81
|
+
|
|
82
|
+
grip_dir = workspace_root / ".grip"
|
|
83
|
+
spec_path = grip_dir / "workspace_spec.toml"
|
|
84
|
+
if spec_path.exists() and not force:
|
|
85
|
+
raise SystemExit(f"refusing to overwrite existing gr2 workspace spec: {spec_path}")
|
|
86
|
+
|
|
87
|
+
manifest = yaml.safe_load(Path(str(detection["manifest_path"])).read_text()) or {}
|
|
88
|
+
agents_doc = _load_agents_doc(workspace_root)
|
|
89
|
+
compiled = compile_gr1_to_workspace_spec(workspace_root, manifest, agents_doc)
|
|
90
|
+
|
|
91
|
+
grip_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
spec_path.write_text(render_workspace_spec(compiled))
|
|
93
|
+
|
|
94
|
+
migration_dir = grip_dir / "migrations" / "gr1"
|
|
95
|
+
migration_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
snapshots = preserve_gr1_state(workspace_root, migration_dir)
|
|
97
|
+
summary_path = migration_dir / "migration-summary.json"
|
|
98
|
+
summary = {
|
|
99
|
+
"source": "gr1",
|
|
100
|
+
"workspace_root": str(workspace_root),
|
|
101
|
+
"workspace_spec_path": str(spec_path),
|
|
102
|
+
"repo_count": len(compiled["repos"]),
|
|
103
|
+
"unit_count": len(compiled["units"]),
|
|
104
|
+
"snapshots": snapshots,
|
|
105
|
+
}
|
|
106
|
+
summary_path.write_text(json.dumps(summary, indent=2) + "\n")
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
**summary,
|
|
110
|
+
"units": [unit["name"] for unit in compiled["units"]],
|
|
111
|
+
"repos": [repo["name"] for repo in compiled["repos"]],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _compiled_gr1_spec(workspace_root: Path) -> tuple[Path, bytes, bytes, dict[str, object]]:
|
|
116
|
+
"""Compile and parse the generated bytes before a caller mutates state."""
|
|
117
|
+
manifest_path = gr1_manifest_path(workspace_root)
|
|
118
|
+
if not manifest_path.is_file():
|
|
119
|
+
raise SystemExit(f"missing canonical gripspace manifest: {manifest_path}")
|
|
120
|
+
manifest_bytes = manifest_path.read_bytes()
|
|
121
|
+
try:
|
|
122
|
+
manifest = yaml.safe_load(manifest_bytes) or {}
|
|
123
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("repos", {}), dict):
|
|
124
|
+
raise ValueError("canonical gripspace manifest repos must be a mapping")
|
|
125
|
+
agents_doc = _load_agents_doc(workspace_root)
|
|
126
|
+
if not isinstance(agents_doc, dict):
|
|
127
|
+
raise ValueError("gr1 agents manifest must be a mapping")
|
|
128
|
+
compiled = compile_gr1_to_workspace_spec(workspace_root, manifest, agents_doc)
|
|
129
|
+
spec_bytes = render_workspace_spec(compiled).encode()
|
|
130
|
+
tomllib.loads(spec_bytes.decode())
|
|
131
|
+
except (yaml.YAMLError, OSError, TypeError, ValueError, KeyError, tomllib.TOMLDecodeError) as exc:
|
|
132
|
+
raise SystemExit(f"cannot compile canonical gripspace manifest: {exc}") from exc
|
|
133
|
+
return manifest_path, manifest_bytes, spec_bytes, compiled
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def bootstrap_gr1_workspace(workspace_root: Path) -> dict[str, object]:
|
|
137
|
+
"""Compile the authoritative gr1 manifest and make its gr2 object store usable.
|
|
138
|
+
|
|
139
|
+
This is intentionally narrower than migration: it does not snapshot mutable
|
|
140
|
+
gr1 state or materialize repositories. It first builds and parses the
|
|
141
|
+
exact WorkspaceSpec bytes, so malformed source cannot create ``.grip``.
|
|
142
|
+
The only persistent result is the git-native grip store plus that generated
|
|
143
|
+
spec. Repeating the operation accepts only the identical compiled bytes.
|
|
144
|
+
"""
|
|
145
|
+
manifest_path, manifest_bytes, spec_bytes, compiled = _compiled_gr1_spec(workspace_root)
|
|
146
|
+
|
|
147
|
+
grip_dir = workspace_root / ".grip"
|
|
148
|
+
spec_path = grip_dir / "workspace_spec.toml"
|
|
149
|
+
git_dir = grip_dir / ".git"
|
|
150
|
+
for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml")):
|
|
151
|
+
_refuse_symlink(path, label)
|
|
152
|
+
|
|
153
|
+
grip_dir_existed = grip_dir.exists()
|
|
154
|
+
git_dir_existed = git_dir.exists()
|
|
155
|
+
spec_existed = spec_path.exists()
|
|
156
|
+
if grip_dir.exists() and not grip_dir.is_dir():
|
|
157
|
+
raise SystemExit(f"refusing bootstrap: {grip_dir} is not a directory")
|
|
158
|
+
if git_dir.is_file():
|
|
159
|
+
raise SystemExit(f"refusing bootstrap: {git_dir} is not a directory")
|
|
160
|
+
if git_dir.is_dir():
|
|
161
|
+
probe = git(grip_dir, "rev-parse", "--is-inside-work-tree")
|
|
162
|
+
if probe.returncode != 0 or probe.stdout.strip() != "true":
|
|
163
|
+
raise SystemExit(f"refusing bootstrap: {grip_dir} is not a valid git object store")
|
|
164
|
+
|
|
165
|
+
if spec_path.exists() and spec_path.read_bytes() != spec_bytes:
|
|
166
|
+
raise SystemExit(f"refusing bootstrap: existing generated spec differs: {spec_path}")
|
|
167
|
+
|
|
168
|
+
already_initialized = git_dir.is_dir() and spec_path.exists()
|
|
169
|
+
if not already_initialized:
|
|
170
|
+
try:
|
|
171
|
+
grip.grip_init(workspace_root)
|
|
172
|
+
if not spec_path.exists():
|
|
173
|
+
_atomic_write(spec_path, spec_bytes)
|
|
174
|
+
except grip.GripInitError as exc:
|
|
175
|
+
_rollback_bootstrap(grip_dir, git_dir, spec_path, grip_dir_existed, git_dir_existed, spec_existed)
|
|
176
|
+
raise SystemExit(str(exc)) from exc
|
|
177
|
+
except BaseException:
|
|
178
|
+
_rollback_bootstrap(grip_dir, git_dir, spec_path, grip_dir_existed, git_dir_existed, spec_existed)
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"status": "already_initialized" if already_initialized else "initialized",
|
|
183
|
+
"workspace_root": str(workspace_root),
|
|
184
|
+
"manifest_path": str(manifest_path),
|
|
185
|
+
"workspace_spec_path": str(spec_path),
|
|
186
|
+
"grip_repo_path": str(grip_dir),
|
|
187
|
+
"repo_count": len(compiled["repos"]),
|
|
188
|
+
"unit_count": len(compiled["units"]),
|
|
189
|
+
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
|
190
|
+
"workspace_spec_sha256": hashlib.sha256(spec_bytes).hexdigest(),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def regenerate_gr1_workspace(workspace_root: Path, *, expected_spec_sha256: str, receipt_path: Path) -> dict[str, object]:
|
|
195
|
+
"""Atomically replace only an already-generated spec after strict binding."""
|
|
196
|
+
workspace_root = workspace_root.resolve()
|
|
197
|
+
if not isinstance(expected_spec_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_spec_sha256):
|
|
198
|
+
raise SystemExit("--expected-spec-sha256 must be a lowercase 64-hex SHA-256")
|
|
199
|
+
manifest_path, manifest_bytes, candidate, compiled = _compiled_gr1_spec(workspace_root)
|
|
200
|
+
agents_path = gr1_agents_path(workspace_root)
|
|
201
|
+
_refuse_authority_symlinks(workspace_root, manifest_path, "canonical gripspace manifest")
|
|
202
|
+
_refuse_authority_symlinks(workspace_root, agents_path, "gr1 agents manifest")
|
|
203
|
+
agents_bytes = agents_path.read_bytes()
|
|
204
|
+
grip_dir = workspace_root / ".grip"
|
|
205
|
+
spec_path = grip_dir / "workspace_spec.toml"
|
|
206
|
+
git_dir = grip_dir / ".git"
|
|
207
|
+
for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml"), (grip_dir / "state", ".grip/state")):
|
|
208
|
+
_refuse_symlink(path, label)
|
|
209
|
+
if not git_dir.is_dir() or not spec_path.is_file():
|
|
210
|
+
raise SystemExit("regeneration requires an initialized object store and regular generated spec")
|
|
211
|
+
_refuse_active_lane_state(workspace_root)
|
|
212
|
+
_require_clean_regeneration_store(grip_dir)
|
|
213
|
+
_recover_or_refuse_prepared_marker(receipt_path, workspace_root, spec_path)
|
|
214
|
+
_require_new_receipt_target(receipt_path)
|
|
215
|
+
|
|
216
|
+
lock_path = grip_dir / "state" / "workspace_spec_regeneration.lock"
|
|
217
|
+
with _regeneration_lock(lock_path):
|
|
218
|
+
# The compare occurs inside the critical section, so two regenerators
|
|
219
|
+
# cannot both accept the same old bytes and silently overwrite each other.
|
|
220
|
+
# A lane can become active while a regenerator waits for the lock, so the
|
|
221
|
+
# active-lane guard is re-checked HERE, after acquisition, not only before
|
|
222
|
+
# (the seam lets a test activate a lane at exactly this moment).
|
|
223
|
+
_transaction_phase_hook("lock_acquired")
|
|
224
|
+
_refuse_active_lane_state(workspace_root)
|
|
225
|
+
locked_manifest, locked_manifest_bytes, locked_candidate, locked_compiled = _compiled_gr1_spec(workspace_root)
|
|
226
|
+
locked_agents = gr1_agents_path(workspace_root)
|
|
227
|
+
_refuse_authority_symlinks(workspace_root, locked_manifest, "canonical gripspace manifest")
|
|
228
|
+
_refuse_authority_symlinks(workspace_root, locked_agents, "gr1 agents manifest")
|
|
229
|
+
locked_agents_bytes = locked_agents.read_bytes()
|
|
230
|
+
if locked_manifest != manifest_path or locked_manifest_bytes != manifest_bytes or locked_agents_bytes != agents_bytes or locked_candidate != candidate:
|
|
231
|
+
raise SystemExit("regeneration source authority changed while awaiting lock")
|
|
232
|
+
old = spec_path.read_bytes()
|
|
233
|
+
old_hash = hashlib.sha256(old).hexdigest()
|
|
234
|
+
if old_hash != expected_spec_sha256:
|
|
235
|
+
raise SystemExit(f"expected old spec hash {expected_spec_sha256}, observed {old_hash}")
|
|
236
|
+
if old == candidate:
|
|
237
|
+
raise SystemExit("regeneration candidate is identical to existing generated spec")
|
|
238
|
+
head = git(grip_dir, "rev-parse", "HEAD").stdout.strip()
|
|
239
|
+
lane_snapshot = _lane_snapshot(workspace_root)
|
|
240
|
+
status_snapshot = _store_status(grip_dir)
|
|
241
|
+
sidecar_path = _sidecar_path(receipt_path, old_hash)
|
|
242
|
+
_require_new_receipt_target(sidecar_path)
|
|
243
|
+
_atomic_write(sidecar_path, old)
|
|
244
|
+
payload = {
|
|
245
|
+
"schema": "gr2-workspace-regeneration/v2",
|
|
246
|
+
"status": "regenerated",
|
|
247
|
+
"workspace_root": str(workspace_root),
|
|
248
|
+
"manifest_path": str(manifest_path),
|
|
249
|
+
"workspace_spec_path": str(spec_path),
|
|
250
|
+
"grip_repo_path": str(grip_dir),
|
|
251
|
+
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
|
252
|
+
"agents_sha256": hashlib.sha256(agents_bytes).hexdigest(),
|
|
253
|
+
"expected_old_spec_sha256": expected_spec_sha256,
|
|
254
|
+
"observed_old_spec_sha256": old_hash,
|
|
255
|
+
"new_spec_sha256": hashlib.sha256(candidate).hexdigest(),
|
|
256
|
+
"sidecar_relative_path": sidecar_path.name,
|
|
257
|
+
"sidecar_sha256": old_hash,
|
|
258
|
+
"object_store_head": head,
|
|
259
|
+
"object_store_status": status_snapshot,
|
|
260
|
+
"lane_snapshot": lane_snapshot,
|
|
261
|
+
"operations": ["atomic_workspace_spec_replace"],
|
|
262
|
+
"materialization": False,
|
|
263
|
+
"repo_count": len(compiled["repos"]),
|
|
264
|
+
"unit_count": len(compiled["units"]),
|
|
265
|
+
}
|
|
266
|
+
marker_path = _prepared_marker_path(receipt_path)
|
|
267
|
+
_require_new_receipt_target(marker_path)
|
|
268
|
+
marker = {"schema": "gr2-workspace-regeneration-prepared/v1", "phase": "prepared", "forward_receipt": receipt_path.name, "payload": payload}
|
|
269
|
+
_atomic_write(marker_path, (json.dumps(marker, indent=2, sort_keys=True) + "\n").encode())
|
|
270
|
+
_transaction_phase_hook("marker_durable")
|
|
271
|
+
try:
|
|
272
|
+
_atomic_write(spec_path, candidate)
|
|
273
|
+
_transaction_phase_hook("spec_replaced")
|
|
274
|
+
if hashlib.sha256(spec_path.read_bytes()).hexdigest() != hashlib.sha256(candidate).hexdigest():
|
|
275
|
+
raise SystemExit("atomic generated spec replacement did not bind candidate bytes")
|
|
276
|
+
if git(grip_dir, "rev-parse", "HEAD").stdout.strip() != head:
|
|
277
|
+
raise SystemExit("regeneration changed object-store HEAD")
|
|
278
|
+
_write_new_receipt(receipt_path, payload)
|
|
279
|
+
_transaction_phase_hook("receipt_durable")
|
|
280
|
+
marker_path.unlink()
|
|
281
|
+
_transaction_phase_hook("marker_cleared")
|
|
282
|
+
except BaseException:
|
|
283
|
+
try:
|
|
284
|
+
_atomic_write(spec_path, old)
|
|
285
|
+
sidecar_path.unlink(missing_ok=True)
|
|
286
|
+
marker_path.unlink(missing_ok=True)
|
|
287
|
+
receipt_path.unlink(missing_ok=True)
|
|
288
|
+
except BaseException as rollback_error:
|
|
289
|
+
raise SystemExit(f"regeneration publication failed and restore failed: {rollback_error}") from rollback_error
|
|
290
|
+
raise
|
|
291
|
+
return payload
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _iter_legacy_lane_dirs(workspace_root: Path) -> list[tuple[str, str, Path]]:
|
|
295
|
+
"""Legacy lane trees: ``workspace_root/agents/<unit>/lanes/<lane>/``.
|
|
296
|
+
|
|
297
|
+
The unit HOME (``agents/<unit>/home/``) is deliberately NOT enumerated --
|
|
298
|
+
it is unit identity, not lane state, and stays where the workspace spec
|
|
299
|
+
places it. Only the ``lanes/`` subtree moves.
|
|
300
|
+
"""
|
|
301
|
+
legacy_root = workspace_root / "agents"
|
|
302
|
+
out: list[tuple[str, str, Path]] = []
|
|
303
|
+
if not legacy_root.is_dir():
|
|
304
|
+
return out
|
|
305
|
+
for unit_dir in sorted(legacy_root.iterdir()):
|
|
306
|
+
lanes_parent = unit_dir / "lanes"
|
|
307
|
+
if not lanes_parent.is_dir():
|
|
308
|
+
continue
|
|
309
|
+
for lane in sorted(lanes_parent.iterdir()):
|
|
310
|
+
if lane.is_dir():
|
|
311
|
+
out.append((unit_dir.name, lane.name, lane))
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _refuse_active_legacy_lane_state(workspace_root: Path) -> None:
|
|
316
|
+
"""Refuse migration while a lane is entered or holds a live lease.
|
|
317
|
+
|
|
318
|
+
``_refuse_active_lane_state`` reads the NEW ``.grip/state/lanes`` path;
|
|
319
|
+
migration runs BEFORE the move, so the live state is still at the OLD path
|
|
320
|
+
and must be judged there. current_lane pointers already live at their final
|
|
321
|
+
``.grip/state/current_lane`` home (never moved), so that half is shared.
|
|
322
|
+
"""
|
|
323
|
+
current_root = workspace_root / ".grip" / "state" / "current_lane"
|
|
324
|
+
if current_root.is_dir():
|
|
325
|
+
for current_path in current_root.glob("*.json"):
|
|
326
|
+
_refuse_symlink(current_path, f"{current_path.relative_to(workspace_root)}")
|
|
327
|
+
try:
|
|
328
|
+
current = json.loads(current_path.read_text())
|
|
329
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
330
|
+
raise SystemExit(f"lane-state migration cannot read lane state: {current_path}: {exc}") from exc
|
|
331
|
+
if current.get("current"):
|
|
332
|
+
raise SystemExit("lane-state migration refuses active lanes")
|
|
333
|
+
for leases_path in (workspace_root / "agents").glob("*/lanes/*/leases.json"):
|
|
334
|
+
_refuse_symlink(leases_path, f"{leases_path.relative_to(workspace_root)}")
|
|
335
|
+
try:
|
|
336
|
+
leases = json.loads(leases_path.read_text())
|
|
337
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
338
|
+
raise SystemExit(f"lane-state migration cannot read lane leases: {leases_path}: {exc}") from exc
|
|
339
|
+
if leases:
|
|
340
|
+
raise SystemExit("lane-state migration refuses active lane leases")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def migrate_lane_state(workspace_root: Path, *, receipt_path: Path) -> dict[str, object]:
|
|
344
|
+
"""Carry legacy ``agents/<unit>/lanes/<lane>/`` trees to
|
|
345
|
+
``.grip/state/lanes/<unit>/<lane>/`` ONCE, recording a receipt that names
|
|
346
|
+
every tree moved (unit, lane, file count) so what the move relocates is
|
|
347
|
+
explicit -- the receipt is the record of what would be lost if the new home
|
|
348
|
+
were ever cleared (the ``.grip`` overlay-reset durability follow-on).
|
|
349
|
+
|
|
350
|
+
Two-pass and no-clobber: every destination is checked BEFORE anything moves,
|
|
351
|
+
so a lane already present at the new path refuses the whole migration rather
|
|
352
|
+
than leaving a half-moved workspace. Idempotent across runs: once the legacy
|
|
353
|
+
trees are gone a fresh-receipt re-run is a zero-move no-op.
|
|
354
|
+
"""
|
|
355
|
+
workspace_root = workspace_root.resolve()
|
|
356
|
+
_require_new_receipt_target(receipt_path)
|
|
357
|
+
_refuse_active_legacy_lane_state(workspace_root)
|
|
358
|
+
|
|
359
|
+
dest_root = lane_proto.lane_state_root(workspace_root)
|
|
360
|
+
legacy = _iter_legacy_lane_dirs(workspace_root)
|
|
361
|
+
|
|
362
|
+
plan: list[tuple[str, str, Path, Path]] = []
|
|
363
|
+
conflicts: list[str] = []
|
|
364
|
+
for unit, lane, src in legacy:
|
|
365
|
+
_refuse_symlink(src, f"agents/{unit}/lanes/{lane}")
|
|
366
|
+
dest = dest_root / unit / lane
|
|
367
|
+
if dest.exists():
|
|
368
|
+
conflicts.append(str(dest.relative_to(workspace_root)))
|
|
369
|
+
else:
|
|
370
|
+
plan.append((unit, lane, src, dest))
|
|
371
|
+
if conflicts:
|
|
372
|
+
raise SystemExit(
|
|
373
|
+
"lane-state migration refuses to overwrite existing destinations "
|
|
374
|
+
f"(both layouts hold these lanes, reconcile by hand): {conflicts}"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
moved: list[dict[str, object]] = []
|
|
378
|
+
for unit, lane, src, dest in plan:
|
|
379
|
+
file_count = sum(1 for p in src.rglob("*") if p.is_file())
|
|
380
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
shutil.move(str(src), str(dest))
|
|
382
|
+
moved.append(
|
|
383
|
+
{
|
|
384
|
+
"unit": unit,
|
|
385
|
+
"lane": lane,
|
|
386
|
+
"source": f"agents/{unit}/lanes/{lane}",
|
|
387
|
+
"dest": str(dest.relative_to(workspace_root)),
|
|
388
|
+
"files": file_count,
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
payload: dict[str, object] = {
|
|
393
|
+
"schema": "gr2-lane-state-migration/v1",
|
|
394
|
+
"status": "migrated",
|
|
395
|
+
"workspace_root": str(workspace_root),
|
|
396
|
+
"from": "agents/<unit>/lanes/<lane>",
|
|
397
|
+
"to": ".grip/state/lanes/<unit>/<lane>",
|
|
398
|
+
"moved": moved,
|
|
399
|
+
"count": len(moved),
|
|
400
|
+
}
|
|
401
|
+
_write_new_receipt(receipt_path, payload)
|
|
402
|
+
return payload
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _prepared_marker_path(receipt_path: Path) -> Path:
|
|
406
|
+
return receipt_path.parent / f"{receipt_path.name}.prepared.json"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _recover_or_refuse_prepared_marker(receipt_path: Path, workspace_root: Path, spec_path: Path) -> None:
|
|
410
|
+
marker_path = _prepared_marker_path(receipt_path)
|
|
411
|
+
_refuse_symlink(marker_path, "prepared regeneration marker")
|
|
412
|
+
if not marker_path.exists():
|
|
413
|
+
return
|
|
414
|
+
try:
|
|
415
|
+
marker = json.loads(marker_path.read_text())
|
|
416
|
+
payload = marker["payload"]
|
|
417
|
+
if marker.get("schema") != "gr2-workspace-regeneration-prepared/v1" or marker.get("phase") != "prepared" or marker.get("forward_receipt") != receipt_path.name or payload.get("workspace_root") != str(workspace_root):
|
|
418
|
+
raise ValueError("marker binding is invalid")
|
|
419
|
+
if receipt_path.exists():
|
|
420
|
+
raise ValueError("receipt already exists")
|
|
421
|
+
if hashlib.sha256(spec_path.read_bytes()).hexdigest() != payload.get("new_spec_sha256"):
|
|
422
|
+
raise ValueError("generated spec does not match prepared new hash")
|
|
423
|
+
_write_new_receipt(receipt_path, payload)
|
|
424
|
+
marker_path.unlink()
|
|
425
|
+
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
426
|
+
raise SystemExit(f"pending regeneration marker requires manual resolution: {marker_path}: {exc}") from exc
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def rollback_gr1_workspace(
|
|
430
|
+
workspace_root: Path,
|
|
431
|
+
*,
|
|
432
|
+
rollback_receipt_path: Path,
|
|
433
|
+
expected_current_spec_sha256: str,
|
|
434
|
+
receipt_path: Path,
|
|
435
|
+
) -> dict[str, object]:
|
|
436
|
+
"""Restore one receipt-bound generated spec without materializing repos."""
|
|
437
|
+
if not isinstance(expected_current_spec_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_current_spec_sha256):
|
|
438
|
+
raise SystemExit("--expected-current-spec-sha256 must be a lowercase 64-hex SHA-256")
|
|
439
|
+
workspace_root = workspace_root.resolve()
|
|
440
|
+
grip_dir, spec_path, git_dir = workspace_root / ".grip", workspace_root / ".grip" / "workspace_spec.toml", workspace_root / ".grip" / ".git"
|
|
441
|
+
for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml"), (rollback_receipt_path, "rollback receipt")):
|
|
442
|
+
_refuse_symlink(path, label)
|
|
443
|
+
if not git_dir.is_dir() or not spec_path.is_file() or not rollback_receipt_path.is_file():
|
|
444
|
+
raise SystemExit("rollback requires an initialized object store, regular generated spec, and regular receipt")
|
|
445
|
+
_refuse_active_lane_state(workspace_root)
|
|
446
|
+
_require_clean_regeneration_store(grip_dir)
|
|
447
|
+
_require_new_receipt_target(receipt_path)
|
|
448
|
+
receipt = _load_forward_receipt(rollback_receipt_path, workspace_root, grip_dir, spec_path)
|
|
449
|
+
manifest_path = gr1_manifest_path(workspace_root)
|
|
450
|
+
agents_path = gr1_agents_path(workspace_root)
|
|
451
|
+
_refuse_authority_symlinks(workspace_root, manifest_path, "canonical gripspace manifest")
|
|
452
|
+
_refuse_authority_symlinks(workspace_root, agents_path, "gr1 agents manifest")
|
|
453
|
+
if not manifest_path.is_file() or hashlib.sha256(manifest_path.read_bytes()).hexdigest() != receipt["manifest_sha256"]:
|
|
454
|
+
raise SystemExit("rollback manifest binding differs from receipt")
|
|
455
|
+
if not agents_path.is_file() or hashlib.sha256(agents_path.read_bytes()).hexdigest() != receipt.get("agents_sha256"):
|
|
456
|
+
raise SystemExit("rollback agents binding differs from receipt")
|
|
457
|
+
sidecar_path = _bound_sidecar_path(rollback_receipt_path, receipt)
|
|
458
|
+
_refuse_symlink(sidecar_path, "rollback sidecar")
|
|
459
|
+
sidecar = sidecar_path.read_bytes()
|
|
460
|
+
if hashlib.sha256(sidecar).hexdigest() != receipt["sidecar_sha256"] or hashlib.sha256(sidecar).hexdigest() != receipt["observed_old_spec_sha256"]:
|
|
461
|
+
raise SystemExit("rollback sidecar hash does not match receipt")
|
|
462
|
+
|
|
463
|
+
lock_path = grip_dir / "state" / "workspace_spec_regeneration.lock"
|
|
464
|
+
with _regeneration_lock(lock_path):
|
|
465
|
+
_refuse_active_lane_state(workspace_root)
|
|
466
|
+
_refuse_authority_symlinks(workspace_root, manifest_path, "canonical gripspace manifest")
|
|
467
|
+
_refuse_authority_symlinks(workspace_root, agents_path, "gr1 agents manifest")
|
|
468
|
+
if hashlib.sha256(manifest_path.read_bytes()).hexdigest() != receipt["manifest_sha256"] or hashlib.sha256(agents_path.read_bytes()).hexdigest() != receipt.get("agents_sha256"):
|
|
469
|
+
raise SystemExit("rollback source authority changed while awaiting lock")
|
|
470
|
+
current = spec_path.read_bytes()
|
|
471
|
+
current_hash = hashlib.sha256(current).hexdigest()
|
|
472
|
+
if current_hash != expected_current_spec_sha256 or current_hash != receipt["new_spec_sha256"]:
|
|
473
|
+
raise SystemExit("rollback current spec hash does not match caller and receipt binding")
|
|
474
|
+
if _lane_snapshot(workspace_root) != receipt["lane_snapshot"]:
|
|
475
|
+
raise SystemExit("rollback lane or lease state differs from receipt binding")
|
|
476
|
+
head = git(grip_dir, "rev-parse", "HEAD").stdout.strip()
|
|
477
|
+
if head != receipt["object_store_head"] or _store_status(grip_dir) != receipt["object_store_status"]:
|
|
478
|
+
raise SystemExit("rollback object-store binding differs from receipt")
|
|
479
|
+
try:
|
|
480
|
+
_atomic_write(spec_path, sidecar)
|
|
481
|
+
if hashlib.sha256(spec_path.read_bytes()).hexdigest() != receipt["observed_old_spec_sha256"]:
|
|
482
|
+
raise SystemExit("rollback replacement did not restore receipt sidecar bytes")
|
|
483
|
+
if git(grip_dir, "rev-parse", "HEAD").stdout.strip() != head:
|
|
484
|
+
raise SystemExit("rollback changed object-store HEAD")
|
|
485
|
+
except BaseException:
|
|
486
|
+
_atomic_write(spec_path, current)
|
|
487
|
+
raise
|
|
488
|
+
payload = {
|
|
489
|
+
"schema": "gr2-workspace-regeneration-rollback/v1",
|
|
490
|
+
"status": "rolled_back",
|
|
491
|
+
"workspace_root": str(workspace_root),
|
|
492
|
+
"rollback_of_receipt_sha256": hashlib.sha256(rollback_receipt_path.read_bytes()).hexdigest(),
|
|
493
|
+
"rollback_of_receipt": rollback_receipt_path.name,
|
|
494
|
+
"expected_current_spec_sha256": expected_current_spec_sha256,
|
|
495
|
+
"restored_spec_sha256": receipt["observed_old_spec_sha256"],
|
|
496
|
+
"object_store_head": head,
|
|
497
|
+
"lane_snapshot": receipt["lane_snapshot"],
|
|
498
|
+
"operations": ["atomic_workspace_spec_restore"],
|
|
499
|
+
"materialization": False,
|
|
500
|
+
}
|
|
501
|
+
try:
|
|
502
|
+
_write_new_receipt(receipt_path, payload)
|
|
503
|
+
except BaseException:
|
|
504
|
+
try:
|
|
505
|
+
_atomic_write(spec_path, current)
|
|
506
|
+
receipt_path.unlink(missing_ok=True)
|
|
507
|
+
except BaseException as compensation_error:
|
|
508
|
+
raise SystemExit(f"rollback receipt publication failed and compensation failed: {compensation_error}") from compensation_error
|
|
509
|
+
raise
|
|
510
|
+
return payload
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@contextlib.contextmanager
|
|
514
|
+
def _regeneration_lock(lock_path: Path):
|
|
515
|
+
"""Hold the shared state lock. The lock FILE is KEPT across releases: flock
|
|
516
|
+
binds an inode, and a path that is unlinked and later re-created is a NEW
|
|
517
|
+
inode, so a concurrent waiter holding the old inode is not excluded at all
|
|
518
|
+
(measured: a third party acquired while a second still held). Keeping the
|
|
519
|
+
file -- no unlink, no rmdir -- is the mutual exclusion; a stale lock file is
|
|
520
|
+
not dirt (it lives under state/, excluded from the clean-store judgment)."""
|
|
521
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
522
|
+
with lane_proto.exclusive_lock(lock_path):
|
|
523
|
+
yield
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _require_clean_regeneration_store(grip_dir: Path) -> None:
|
|
527
|
+
probe = git(grip_dir, "rev-parse", "--is-inside-work-tree")
|
|
528
|
+
# The .grip store is PLUMBING-ONLY: grip.py writes objects via
|
|
529
|
+
# hash-object/mktree/commit-tree/update-ref and never checks out HEAD's
|
|
530
|
+
# tree. So once HEAD is a real commit (e.g. a review commit), every path in
|
|
531
|
+
# HEAD's tree is absent from the (unused) worktree and `git status
|
|
532
|
+
# --porcelain` reports it as a deletion. That is the store's NORMAL state,
|
|
533
|
+
# not dirt -- judging HEAD-vs-worktree here refuses every store that has
|
|
534
|
+
# ever held a commit. Regeneration only needs to refuse UNTRACKED files it
|
|
535
|
+
# would clobber, so judge those alone.
|
|
536
|
+
status = git(grip_dir, "status", "--porcelain", "--untracked-files=all")
|
|
537
|
+
if probe.returncode != 0 or probe.stdout.strip() != "true" or status.returncode != 0:
|
|
538
|
+
raise SystemExit("regeneration requires a valid readable object store")
|
|
539
|
+
# state/ is the lane control plane, not object-store dirt: inactive lane
|
|
540
|
+
# files and the regeneration lock file legitimately live there. Active lanes
|
|
541
|
+
# and leases are refused separately by _refuse_active_lane_state; counting an
|
|
542
|
+
# inactive lane file (or the held lock) as "dirty" here made a valid
|
|
543
|
+
# regeneration refuse, and made a second regenerator report "dirty" instead
|
|
544
|
+
# of waiting on the lock. Exclude the subtree; judge the object store only.
|
|
545
|
+
dirty = [
|
|
546
|
+
line for line in status.stdout.splitlines()
|
|
547
|
+
if line.startswith("??")
|
|
548
|
+
and line != "?? workspace_spec.toml"
|
|
549
|
+
and not line.startswith("?? state/")
|
|
550
|
+
]
|
|
551
|
+
if dirty:
|
|
552
|
+
raise SystemExit("regeneration refuses dirty object store outside generated workspace_spec.toml")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _store_status(grip_dir: Path) -> list[str]:
|
|
556
|
+
status = git(grip_dir, "status", "--porcelain")
|
|
557
|
+
if status.returncode != 0:
|
|
558
|
+
raise SystemExit("regeneration requires a readable object-store status")
|
|
559
|
+
return status.stdout.splitlines()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _lane_snapshot(workspace_root: Path) -> dict[str, object]:
|
|
563
|
+
"""Bind empty/inactive lane state, rather than merely assuming it remains so."""
|
|
564
|
+
rows: list[dict[str, str]] = []
|
|
565
|
+
for root, pattern in ((workspace_root / ".grip" / "state" / "current_lane", "*.json"), (lane_proto.lane_state_root(workspace_root), "*/*/leases.json")):
|
|
566
|
+
if not root.exists():
|
|
567
|
+
continue
|
|
568
|
+
for path in sorted(root.glob(pattern)):
|
|
569
|
+
_refuse_symlink(path, f"{path.relative_to(workspace_root)}")
|
|
570
|
+
rows.append({"path": str(path.relative_to(workspace_root)), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
|
|
571
|
+
return {"files": rows}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _refuse_active_lane_state(workspace_root: Path) -> None:
|
|
575
|
+
"""Do not change the control plane while a lane or lease can consume it."""
|
|
576
|
+
state_root = workspace_root / ".grip" / "state"
|
|
577
|
+
current_root = state_root / "current_lane"
|
|
578
|
+
if current_root.exists():
|
|
579
|
+
for current_path in current_root.glob("*.json"):
|
|
580
|
+
_refuse_symlink(current_path, f"{current_path.relative_to(workspace_root)}")
|
|
581
|
+
try:
|
|
582
|
+
current = json.loads(current_path.read_text())
|
|
583
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
584
|
+
raise SystemExit(f"regeneration cannot read lane state: {current_path}: {exc}") from exc
|
|
585
|
+
if current.get("current"):
|
|
586
|
+
raise SystemExit("regeneration refuses active lanes")
|
|
587
|
+
for leases_path in lane_proto.lane_state_root(workspace_root).glob("*/*/leases.json"):
|
|
588
|
+
_refuse_symlink(leases_path, f"{leases_path.relative_to(workspace_root)}")
|
|
589
|
+
try:
|
|
590
|
+
leases = json.loads(leases_path.read_text())
|
|
591
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
592
|
+
raise SystemExit(f"regeneration cannot read lane leases: {leases_path}: {exc}") from exc
|
|
593
|
+
if leases:
|
|
594
|
+
raise SystemExit("regeneration refuses active lane leases")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _require_new_receipt_target(path: Path) -> None:
|
|
598
|
+
"""Receipt artifacts are append-only authority, never overwriteable state."""
|
|
599
|
+
_refuse_symlink(path.parent, "receipt directory")
|
|
600
|
+
_refuse_symlink(path, "regeneration receipt")
|
|
601
|
+
if not path.parent.is_dir():
|
|
602
|
+
raise SystemExit(f"receipt parent must already exist: {path.parent}")
|
|
603
|
+
if path.exists():
|
|
604
|
+
raise SystemExit(f"refusing to overwrite existing receipt artifact: {path}")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _sidecar_path(receipt_path: Path, old_hash: str) -> Path:
|
|
608
|
+
relative = f"{receipt_path.name}.old-spec-{old_hash}.toml"
|
|
609
|
+
if Path(relative).is_absolute() or ".." in Path(relative).parts:
|
|
610
|
+
raise SystemExit("generated receipt sidecar path is unsafe")
|
|
611
|
+
return receipt_path.parent / relative
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _bound_sidecar_path(receipt_path: Path, receipt: dict[str, object]) -> Path:
|
|
615
|
+
relative = receipt.get("sidecar_relative_path")
|
|
616
|
+
if not isinstance(relative, str):
|
|
617
|
+
raise SystemExit("rollback receipt lacks a relative sidecar path")
|
|
618
|
+
candidate = Path(relative)
|
|
619
|
+
if candidate.is_absolute() or ".." in candidate.parts or len(candidate.parts) != 1:
|
|
620
|
+
raise SystemExit("rollback receipt sidecar path is unsafe")
|
|
621
|
+
return receipt_path.parent / candidate
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _write_new_receipt(receipt_path: Path, payload: dict[str, object]) -> None:
|
|
625
|
+
_require_new_receipt_target(receipt_path)
|
|
626
|
+
_atomic_write(receipt_path, (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode())
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _load_forward_receipt(receipt_path: Path, workspace_root: Path, grip_dir: Path, spec_path: Path) -> dict[str, object]:
|
|
630
|
+
try:
|
|
631
|
+
receipt = json.loads(receipt_path.read_text())
|
|
632
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
633
|
+
raise SystemExit(f"rollback cannot parse receipt: {exc}") from exc
|
|
634
|
+
if not isinstance(receipt, dict) or receipt.get("schema") != "gr2-workspace-regeneration/v2":
|
|
635
|
+
raise SystemExit("rollback receipt is not a successful regeneration receipt")
|
|
636
|
+
required = ("workspace_root", "workspace_spec_path", "grip_repo_path", "manifest_sha256", "object_store_head", "object_store_status", "lane_snapshot", "observed_old_spec_sha256", "new_spec_sha256", "sidecar_sha256", "materialization")
|
|
637
|
+
if any(key not in receipt for key in required) or receipt["materialization"] is not False:
|
|
638
|
+
raise SystemExit("rollback receipt is missing required immutable bindings")
|
|
639
|
+
if receipt["workspace_root"] != str(workspace_root) or receipt["workspace_spec_path"] != str(spec_path) or receipt["grip_repo_path"] != str(grip_dir):
|
|
640
|
+
raise SystemExit("rollback receipt workspace binding differs from requested workspace")
|
|
641
|
+
if not all(isinstance(receipt[key], str) and re.fullmatch(r"[0-9a-f]{64}", receipt[key]) for key in ("manifest_sha256", "observed_old_spec_sha256", "new_spec_sha256", "sidecar_sha256")):
|
|
642
|
+
raise SystemExit("rollback receipt has invalid SHA-256 bindings")
|
|
643
|
+
return receipt
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _atomic_write(path: Path, content: bytes) -> None:
|
|
647
|
+
"""Replace one generated control-plane file without exposing partial TOML."""
|
|
648
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
649
|
+
fd, raw_temp_path = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
650
|
+
temp_path = Path(raw_temp_path)
|
|
651
|
+
try:
|
|
652
|
+
with os.fdopen(fd, "wb") as fh:
|
|
653
|
+
fh.write(content)
|
|
654
|
+
fh.flush()
|
|
655
|
+
os.fsync(fh.fileno())
|
|
656
|
+
os.replace(temp_path, path)
|
|
657
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
658
|
+
try:
|
|
659
|
+
os.fsync(directory_fd)
|
|
660
|
+
finally:
|
|
661
|
+
os.close(directory_fd)
|
|
662
|
+
except BaseException:
|
|
663
|
+
temp_path.unlink(missing_ok=True)
|
|
664
|
+
raise
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _refuse_symlink(path: Path, label: str) -> None:
|
|
668
|
+
"""Inspect the entry itself, never the target an attacker could redirect."""
|
|
669
|
+
try:
|
|
670
|
+
mode = path.lstat().st_mode
|
|
671
|
+
except FileNotFoundError:
|
|
672
|
+
return
|
|
673
|
+
if stat.S_ISLNK(mode):
|
|
674
|
+
raise SystemExit(f"refusing bootstrap: {label} must not be a symlink")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _refuse_authority_symlinks(workspace_root: Path, path: Path, label: str) -> None:
|
|
678
|
+
"""Authority must stay physically below the canonical workspace root."""
|
|
679
|
+
root = workspace_root.resolve()
|
|
680
|
+
try:
|
|
681
|
+
relative = path.relative_to(root)
|
|
682
|
+
except ValueError as exc:
|
|
683
|
+
raise SystemExit(f"refusing regeneration: {label} escapes workspace") from exc
|
|
684
|
+
current = root
|
|
685
|
+
_refuse_symlink(current, "workspace root")
|
|
686
|
+
for part in relative.parts:
|
|
687
|
+
current = current / part
|
|
688
|
+
_refuse_symlink(current, label)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _rollback_bootstrap(
|
|
692
|
+
grip_dir: Path,
|
|
693
|
+
git_dir: Path,
|
|
694
|
+
spec_path: Path,
|
|
695
|
+
grip_dir_existed: bool,
|
|
696
|
+
git_dir_existed: bool,
|
|
697
|
+
spec_existed: bool,
|
|
698
|
+
) -> None:
|
|
699
|
+
"""Remove only control-plane entries created by this bootstrap attempt."""
|
|
700
|
+
for path, label in ((grip_dir, ".grip"), (git_dir, ".grip/.git"), (spec_path, ".grip/workspace_spec.toml")):
|
|
701
|
+
_refuse_symlink(path, label)
|
|
702
|
+
if not spec_existed and spec_path.exists():
|
|
703
|
+
spec_path.unlink()
|
|
704
|
+
if not git_dir_existed and git_dir.exists():
|
|
705
|
+
shutil.rmtree(git_dir)
|
|
706
|
+
if not grip_dir_existed and grip_dir.exists():
|
|
707
|
+
shutil.rmtree(grip_dir)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def compile_gr1_to_workspace_spec(
|
|
711
|
+
workspace_root: Path,
|
|
712
|
+
manifest: dict[str, object],
|
|
713
|
+
agents_doc: dict[str, object],
|
|
714
|
+
) -> dict[str, object]:
|
|
715
|
+
repos_doc = manifest.get("repos", {}) or {}
|
|
716
|
+
if not isinstance(repos_doc, dict):
|
|
717
|
+
raise ValueError("canonical gripspace manifest repos must be a mapping")
|
|
718
|
+
repos: list[dict[str, object]] = []
|
|
719
|
+
writable_repo_names: list[str] = []
|
|
720
|
+
|
|
721
|
+
for repo_name, repo_doc in repos_doc.items():
|
|
722
|
+
safe_repo_name = _safe_workspace_component(repo_name, "repo name")
|
|
723
|
+
if not isinstance(repo_doc, dict):
|
|
724
|
+
raise ValueError(f"repository {safe_repo_name!r} must be a mapping")
|
|
725
|
+
path = repo_doc.get("path", "")
|
|
726
|
+
normalized_path = _safe_workspace_relative_path(path, f"repository {safe_repo_name!r} path")
|
|
727
|
+
repo_item = {
|
|
728
|
+
"name": safe_repo_name,
|
|
729
|
+
"path": normalized_path,
|
|
730
|
+
"url": str(repo_doc.get("url", "")).strip(),
|
|
731
|
+
}
|
|
732
|
+
if "revision" in repo_doc:
|
|
733
|
+
repo_item["default_branch"] = str(repo_doc.get("revision") or "").strip()
|
|
734
|
+
elif "default_branch" in repo_doc:
|
|
735
|
+
repo_item["default_branch"] = str(repo_doc.get("default_branch") or "").strip()
|
|
736
|
+
if repo_doc.get("reference", False):
|
|
737
|
+
repo_item["reference"] = True
|
|
738
|
+
repos.append(repo_item)
|
|
739
|
+
if not repo_doc.get("reference", False):
|
|
740
|
+
writable_repo_names.append(safe_repo_name)
|
|
741
|
+
|
|
742
|
+
agents = (agents_doc.get("agents") or {}) or {}
|
|
743
|
+
if not isinstance(agents, dict):
|
|
744
|
+
raise ValueError("gr1 agents manifest agents must be a mapping")
|
|
745
|
+
unit_items = (
|
|
746
|
+
sorted((_safe_workspace_component(unit_name, "agent unit name"), unit_doc) for unit_name, unit_doc in agents.items())
|
|
747
|
+
if agents
|
|
748
|
+
else [("default", {})]
|
|
749
|
+
)
|
|
750
|
+
units: list[dict[str, object]] = []
|
|
751
|
+
for safe_unit_name, unit_doc in unit_items:
|
|
752
|
+
unit_doc = unit_doc or {}
|
|
753
|
+
if not isinstance(unit_doc, dict):
|
|
754
|
+
raise ValueError(f"agent unit {safe_unit_name!r} must be a mapping")
|
|
755
|
+
units.append(
|
|
756
|
+
{
|
|
757
|
+
"name": safe_unit_name,
|
|
758
|
+
"path": f"agents/{safe_unit_name}/home",
|
|
759
|
+
"repos": writable_repo_names,
|
|
760
|
+
"migration_source": {
|
|
761
|
+
"worktree": unit_doc.get("worktree"),
|
|
762
|
+
"channel": unit_doc.get("channel"),
|
|
763
|
+
},
|
|
764
|
+
}
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
"workspace_name": workspace_root.name,
|
|
769
|
+
"repos": repos,
|
|
770
|
+
"units": units,
|
|
771
|
+
"workspace_constraints": {
|
|
772
|
+
"migration_source": "gr1",
|
|
773
|
+
},
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _safe_workspace_component(value: object, field: str) -> str:
|
|
778
|
+
"""Accept one logical name, never a path fragment or invisible control data."""
|
|
779
|
+
if not isinstance(value, str) or not value or value in {".", ".."}:
|
|
780
|
+
raise ValueError(f"{field} must be a non-empty logical name")
|
|
781
|
+
if "/" in value or "\\" in value or ":" in value or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
|
|
782
|
+
raise ValueError(f"{field} must not contain separators or control characters: {value!r}")
|
|
783
|
+
return value
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _safe_workspace_relative_path(value: object, field: str) -> str:
|
|
787
|
+
"""Normalize only a safe, portable workspace-relative manifest path."""
|
|
788
|
+
if not isinstance(value, str) or not value or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
|
|
789
|
+
raise ValueError(f"{field} must be a non-empty relative path")
|
|
790
|
+
if "\\" in value:
|
|
791
|
+
raise ValueError(f"{field} must use portable '/' separators: {value!r}")
|
|
792
|
+
posix_path = PurePosixPath(value)
|
|
793
|
+
windows_path = PureWindowsPath(value)
|
|
794
|
+
if (
|
|
795
|
+
posix_path.is_absolute()
|
|
796
|
+
or windows_path.is_absolute()
|
|
797
|
+
or windows_path.drive
|
|
798
|
+
or windows_path.root
|
|
799
|
+
or ".." in posix_path.parts
|
|
800
|
+
or str(posix_path) in {".", ""}
|
|
801
|
+
):
|
|
802
|
+
raise ValueError(f"{field} escapes the workspace: {value!r}")
|
|
803
|
+
return posix_path.as_posix()
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def preserve_gr1_state(workspace_root: Path, migration_dir: Path) -> dict[str, str]:
|
|
807
|
+
snapshots: dict[str, str] = {}
|
|
808
|
+
for name, src in gr1_state_paths(workspace_root).items():
|
|
809
|
+
if not src.exists():
|
|
810
|
+
continue
|
|
811
|
+
dest = migration_dir / src.name
|
|
812
|
+
shutil.copy2(src, dest)
|
|
813
|
+
snapshots[name] = str(dest)
|
|
814
|
+
return snapshots
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def render_workspace_spec(compiled: dict[str, object]) -> str:
|
|
818
|
+
lines = [
|
|
819
|
+
f'workspace_name = "{compiled["workspace_name"]}"',
|
|
820
|
+
"",
|
|
821
|
+
]
|
|
822
|
+
constraints = compiled.get("workspace_constraints") or {}
|
|
823
|
+
if constraints:
|
|
824
|
+
lines.append("[workspace_constraints]")
|
|
825
|
+
for key, value in constraints.items():
|
|
826
|
+
lines.append(f'{key} = "{value}"')
|
|
827
|
+
lines.append("")
|
|
828
|
+
|
|
829
|
+
for repo in compiled["repos"]:
|
|
830
|
+
lines.extend(
|
|
831
|
+
[
|
|
832
|
+
"[[repos]]",
|
|
833
|
+
f'name = "{repo["name"]}"',
|
|
834
|
+
f'path = "{repo["path"]}"',
|
|
835
|
+
f'url = "{repo["url"]}"',
|
|
836
|
+
]
|
|
837
|
+
)
|
|
838
|
+
default_branch = str(repo.get("default_branch", "")).strip()
|
|
839
|
+
if default_branch:
|
|
840
|
+
lines.append(f'default_branch = "{default_branch}"')
|
|
841
|
+
if repo.get("reference", False):
|
|
842
|
+
lines.append("reference = true")
|
|
843
|
+
lines.append("")
|
|
844
|
+
|
|
845
|
+
for unit in compiled["units"]:
|
|
846
|
+
lines.extend(
|
|
847
|
+
[
|
|
848
|
+
"[[units]]",
|
|
849
|
+
f'name = "{unit["name"]}"',
|
|
850
|
+
f'path = "{unit["path"]}"',
|
|
851
|
+
"repos = [" + ", ".join(f'"{repo}"' for repo in unit["repos"]) + "]",
|
|
852
|
+
]
|
|
853
|
+
)
|
|
854
|
+
lines.append("")
|
|
855
|
+
|
|
856
|
+
return "\n".join(lines)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def render_detection(payload: dict[str, object]) -> str:
|
|
860
|
+
if not payload["detected"]:
|
|
861
|
+
return "\n".join(["Gr1Detection", "detected = false", f"reason = {payload['reason']}"])
|
|
862
|
+
lines = [
|
|
863
|
+
"Gr1Detection",
|
|
864
|
+
"detected = true",
|
|
865
|
+
f"workspace_root = {payload['workspace_root']}",
|
|
866
|
+
f"manifest_path = {payload['manifest_path']}",
|
|
867
|
+
f"repo_count = {payload['repo_count']}",
|
|
868
|
+
f"reference_repo_count = {payload['reference_repo_count']}",
|
|
869
|
+
f"agent_count = {payload['agent_count']}",
|
|
870
|
+
"REPOS",
|
|
871
|
+
]
|
|
872
|
+
lines.extend(f"- {repo}" for repo in payload["repos"])
|
|
873
|
+
lines.append("AGENTS")
|
|
874
|
+
lines.extend(f"- {agent}" for agent in payload["agents"])
|
|
875
|
+
return "\n".join(lines)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def render_migration(payload: dict[str, object]) -> str:
|
|
879
|
+
lines = [
|
|
880
|
+
"Gr1Migration",
|
|
881
|
+
f"workspace_root = {payload['workspace_root']}",
|
|
882
|
+
f"workspace_spec_path = {payload['workspace_spec_path']}",
|
|
883
|
+
f"repo_count = {payload['repo_count']}",
|
|
884
|
+
f"unit_count = {payload['unit_count']}",
|
|
885
|
+
"UNITS",
|
|
886
|
+
]
|
|
887
|
+
lines.extend(f"- {unit}" for unit in payload["units"])
|
|
888
|
+
lines.append("SNAPSHOTS")
|
|
889
|
+
lines.extend(f"- {name}\t{path}" for name, path in payload["snapshots"].items())
|
|
890
|
+
return "\n".join(lines)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def workspace_status(workspace_root: Path) -> dict[str, object]:
|
|
894
|
+
"""Report workspace state: gr1-only, gr2-only, coexistence, or none."""
|
|
895
|
+
has_gr1 = gr1_manifest_path(workspace_root).exists()
|
|
896
|
+
gr2_spec_path = workspace_root / ".grip" / "workspace_spec.toml"
|
|
897
|
+
has_gr2 = gr2_spec_path.exists()
|
|
898
|
+
migration_dir = workspace_root / ".grip" / "migrations" / "gr1"
|
|
899
|
+
has_migration = migration_dir.exists() and (migration_dir / "migration-summary.json").exists()
|
|
900
|
+
|
|
901
|
+
if has_gr1 and has_gr2:
|
|
902
|
+
phase = "coexistence"
|
|
903
|
+
elif has_gr1:
|
|
904
|
+
phase = "gr1-only"
|
|
905
|
+
elif has_gr2:
|
|
906
|
+
phase = "gr2-only"
|
|
907
|
+
else:
|
|
908
|
+
phase = "none"
|
|
909
|
+
|
|
910
|
+
result: dict[str, object] = {
|
|
911
|
+
"workspace_root": str(workspace_root),
|
|
912
|
+
"gr1": has_gr1,
|
|
913
|
+
"gr2": has_gr2,
|
|
914
|
+
"coexistence": has_gr1 and has_gr2,
|
|
915
|
+
"migration_snapshot": has_migration,
|
|
916
|
+
"phase": phase,
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if has_gr1:
|
|
920
|
+
detection = detect_gr1_workspace(workspace_root)
|
|
921
|
+
result["gr1_repo_count"] = detection.get("repo_count", 0)
|
|
922
|
+
result["gr1_agents"] = detection.get("agents", [])
|
|
923
|
+
|
|
924
|
+
if has_gr2:
|
|
925
|
+
import tomllib
|
|
926
|
+
with gr2_spec_path.open("rb") as fh:
|
|
927
|
+
spec = tomllib.load(fh)
|
|
928
|
+
repos = spec.get("repos", [])
|
|
929
|
+
units = spec.get("units", [])
|
|
930
|
+
result["gr2_repo_count"] = len(repos)
|
|
931
|
+
result["gr2_unit_count"] = len(units)
|
|
932
|
+
result["gr2_spec_path"] = str(gr2_spec_path)
|
|
933
|
+
|
|
934
|
+
# gr does not support worktree-backed repos (design/worktree-refactor
|
|
935
|
+
# ruling). `repo status` already flags a hand-made linked worktree
|
|
936
|
+
# per-repo; this reuses the SAME check (repo_maintenance_prototype's
|
|
937
|
+
# is_linked_worktree, via derive_targets so shared repos and per-unit
|
|
938
|
+
# repos are both covered) so `workspace status` reports it too --
|
|
939
|
+
# the playbook has always claimed both commands do this, and until
|
|
940
|
+
# now only one actually did.
|
|
941
|
+
parsed_spec = repo_proto.read_workspace_spec(gr2_spec_path)
|
|
942
|
+
linked_worktrees = [
|
|
943
|
+
str(target.path)
|
|
944
|
+
for target in repo_proto.derive_targets(workspace_root, parsed_spec)
|
|
945
|
+
if target.path.exists() and repo_proto.is_linked_worktree(target.path)
|
|
946
|
+
]
|
|
947
|
+
result["linked_worktrees"] = linked_worktrees
|
|
948
|
+
|
|
949
|
+
return result
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def render_status(payload: dict[str, object]) -> str:
|
|
953
|
+
lines = [
|
|
954
|
+
"WorkspaceStatus",
|
|
955
|
+
f"phase = {payload['phase']}",
|
|
956
|
+
f"workspace_root = {payload['workspace_root']}",
|
|
957
|
+
]
|
|
958
|
+
if payload["gr1"]:
|
|
959
|
+
lines.append(f"gr1 = true (repos: {payload.get('gr1_repo_count', '?')})")
|
|
960
|
+
if payload["gr2"]:
|
|
961
|
+
lines.append(f"gr2 = true (repos: {payload.get('gr2_repo_count', '?')}, units: {payload.get('gr2_unit_count', '?')})")
|
|
962
|
+
if payload["coexistence"]:
|
|
963
|
+
lines.append("coexistence = true (both .gitgrip and .grip present)")
|
|
964
|
+
if payload.get("migration_snapshot"):
|
|
965
|
+
lines.append("migration_snapshot = true (.grip/migrations/gr1/ present)")
|
|
966
|
+
linked = payload.get("linked_worktrees") or []
|
|
967
|
+
if linked:
|
|
968
|
+
lines.append(
|
|
969
|
+
"linked_worktrees = " + ", ".join(linked)
|
|
970
|
+
+ " -- gr does not support worktree-backed repos; "
|
|
971
|
+
"run `gr2 workspace convert-clone <path>` on each"
|
|
972
|
+
)
|
|
973
|
+
if not payload["gr1"] and not payload["gr2"]:
|
|
974
|
+
lines.append("No workspace detected. Run `gr2 workspace init` or `gr2 workspace migrate-gr1`.")
|
|
975
|
+
return "\n".join(lines)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _load_agents_doc(workspace_root: Path) -> dict[str, object]:
|
|
979
|
+
path = gr1_agents_path(workspace_root)
|
|
980
|
+
if not path.exists():
|
|
981
|
+
return {}
|
|
982
|
+
import tomllib
|
|
983
|
+
|
|
984
|
+
with path.open("rb") as fh:
|
|
985
|
+
return tomllib.load(fh)
|