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,2088 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prototype lane metadata, execution planning, and shared scratchpads for gr2.
|
|
3
|
+
|
|
4
|
+
This prototype does not mutate git state. It explores three UX questions:
|
|
5
|
+
|
|
6
|
+
1. are lane records legible enough to guide multi-repo work?
|
|
7
|
+
2. can lightweight shared scratchpads fill the collaboration gap without
|
|
8
|
+
violating private-workspace rules?
|
|
9
|
+
3. can the tool tell the user what to do next instead of forcing them to infer
|
|
10
|
+
the workflow?
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import contextlib
|
|
17
|
+
import dataclasses
|
|
18
|
+
import fcntl
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
import tomllib
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import tomli_w
|
|
31
|
+
from gr2.prototypes.jsonl_store import (
|
|
32
|
+
JsonlRead,
|
|
33
|
+
append_jsonl,
|
|
34
|
+
read_jsonl,
|
|
35
|
+
warn_unreadable,
|
|
36
|
+
)
|
|
37
|
+
from gr2.python_cli import gitops
|
|
38
|
+
from gr2.python_cli import review as _review
|
|
39
|
+
from gr2.python_cli import push as _push
|
|
40
|
+
|
|
41
|
+
LANE_SCHEMA_VERSION = 1
|
|
42
|
+
SCRATCHPAD_SCHEMA_VERSION = 1
|
|
43
|
+
MAX_PORTABLE_COMPONENT_BYTES = 255
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def serialize_toml(document: dict[str, object]) -> str:
|
|
47
|
+
"""Serialize structured data without interpolating values into TOML source."""
|
|
48
|
+
return tomli_w.dumps(document)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_lane_path_component(value: str, field: str) -> None:
|
|
52
|
+
"""Require one portable path component while allowing TOML-significant text."""
|
|
53
|
+
try:
|
|
54
|
+
encoded_length = len(value.encode("utf-8"))
|
|
55
|
+
except UnicodeEncodeError:
|
|
56
|
+
encoded_length = MAX_PORTABLE_COMPONENT_BYTES + 1
|
|
57
|
+
invalid = (
|
|
58
|
+
not value
|
|
59
|
+
or len(value) > 128
|
|
60
|
+
or encoded_length > MAX_PORTABLE_COMPONENT_BYTES
|
|
61
|
+
or value != value.strip()
|
|
62
|
+
or value in {".", ".."}
|
|
63
|
+
or "/" in value
|
|
64
|
+
or "\\" in value
|
|
65
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in value)
|
|
66
|
+
)
|
|
67
|
+
if invalid:
|
|
68
|
+
raise SystemExit(
|
|
69
|
+
f"invalid {field}: expected 1-128 characters and at most "
|
|
70
|
+
f"{MAX_PORTABLE_COMPONENT_BYTES} UTF-8 bytes forming one path component; "
|
|
71
|
+
"no separators, control characters, dot components, or outer whitespace"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclasses.dataclass
|
|
76
|
+
class LaneMetadata:
|
|
77
|
+
schema_version: int
|
|
78
|
+
lane_name: str
|
|
79
|
+
owner_unit: str
|
|
80
|
+
agent_id: str | None
|
|
81
|
+
lane_type: str
|
|
82
|
+
repos: list[str]
|
|
83
|
+
branch_map: dict[str, str]
|
|
84
|
+
pr_associations: list[str]
|
|
85
|
+
shared_context_roots: list[str]
|
|
86
|
+
private_context_roots: list[str]
|
|
87
|
+
exec_defaults: dict[str, object]
|
|
88
|
+
creation_source: str
|
|
89
|
+
shared_with: list[str]
|
|
90
|
+
handoff_source: dict[str, str] | None
|
|
91
|
+
# gr2-lane-author-shape ruling (2026-09-03): lane_kind is required on every
|
|
92
|
+
# lane document so a reader never infers the reconstruction guarantee. A
|
|
93
|
+
# "materialized" lane holds an isolated clone pinned at a head; a "bound"
|
|
94
|
+
# lane is a label on the author's own existing worktree (single-repo only),
|
|
95
|
+
# honest under the clean-tree/HEAD guard bind re-checks. bound_worktree is
|
|
96
|
+
# the resolved worktree path, present only for bound lanes. bound_head is the
|
|
97
|
+
# worktree HEAD recorded at create time — the drift baseline a review bind on a
|
|
98
|
+
# bound lane re-checks against (a moved HEAD or a dirty tree refuses).
|
|
99
|
+
lane_kind: str = "materialized"
|
|
100
|
+
bound_worktree: str | None = None
|
|
101
|
+
bound_head: str | None = None
|
|
102
|
+
# fork-base ruling (2026-09-04): the per-repo coordinate the lane
|
|
103
|
+
# forked from on its integration branch, recorded ONCE at create and never
|
|
104
|
+
# recomputed. Each entry is {branch: <integration branch, e.g. dev>, sha: <its
|
|
105
|
+
# tip when the lane was cut>}. The reproduction paths (workspace snapshot,
|
|
106
|
+
# review bind) read `base` from here; a lane with no entry reads base as
|
|
107
|
+
# unknown (explicit), never HEAD^. Recorded, not derived: the caller that knows
|
|
108
|
+
# the integration tip (materialization) supplies it; create_lane only stores it.
|
|
109
|
+
fork_base: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
def as_toml(self) -> str:
|
|
112
|
+
document: dict[str, object] = {
|
|
113
|
+
"schema_version": self.schema_version,
|
|
114
|
+
"lane_name": self.lane_name,
|
|
115
|
+
"owner_unit": self.owner_unit,
|
|
116
|
+
"agent_id": self.agent_id or "",
|
|
117
|
+
"lane_type": self.lane_type,
|
|
118
|
+
"lane_kind": self.lane_kind,
|
|
119
|
+
"creation_source": self.creation_source,
|
|
120
|
+
"repos": self.repos,
|
|
121
|
+
"shared_with": self.shared_with,
|
|
122
|
+
"branch_map": dict(sorted(self.branch_map.items())),
|
|
123
|
+
"context": {
|
|
124
|
+
"shared_roots": self.shared_context_roots,
|
|
125
|
+
"private_roots": self.private_context_roots,
|
|
126
|
+
},
|
|
127
|
+
"exec_defaults": self.exec_defaults,
|
|
128
|
+
}
|
|
129
|
+
if self.bound_worktree is not None:
|
|
130
|
+
document["bound_worktree"] = self.bound_worktree
|
|
131
|
+
if self.bound_head is not None:
|
|
132
|
+
document["bound_head"] = self.bound_head
|
|
133
|
+
if self.fork_base:
|
|
134
|
+
document["fork_base"] = {
|
|
135
|
+
repo: {"branch": entry["branch"], "sha": entry["sha"]}
|
|
136
|
+
for repo, entry in sorted(self.fork_base.items())
|
|
137
|
+
}
|
|
138
|
+
if self.pr_associations:
|
|
139
|
+
document["pr_associations"] = [{"ref": ref} for ref in self.pr_associations]
|
|
140
|
+
if self.handoff_source:
|
|
141
|
+
document["handoff"] = self.handoff_source
|
|
142
|
+
return serialize_toml(document)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclasses.dataclass
|
|
146
|
+
class SharedScratchpad:
|
|
147
|
+
schema_version: int
|
|
148
|
+
name: str
|
|
149
|
+
kind: str
|
|
150
|
+
purpose: str
|
|
151
|
+
participants: list[str]
|
|
152
|
+
linked_refs: list[str]
|
|
153
|
+
lifecycle: str
|
|
154
|
+
creation_source: str
|
|
155
|
+
docs_root: str
|
|
156
|
+
notes_root: str
|
|
157
|
+
context_root: str
|
|
158
|
+
created_at: str
|
|
159
|
+
updated_at: str
|
|
160
|
+
|
|
161
|
+
def as_toml(self) -> str:
|
|
162
|
+
return serialize_toml(
|
|
163
|
+
{
|
|
164
|
+
"schema_version": self.schema_version,
|
|
165
|
+
"name": self.name,
|
|
166
|
+
"kind": self.kind,
|
|
167
|
+
"purpose": self.purpose,
|
|
168
|
+
"lifecycle": self.lifecycle,
|
|
169
|
+
"creation_source": self.creation_source,
|
|
170
|
+
"created_at": self.created_at,
|
|
171
|
+
"updated_at": self.updated_at,
|
|
172
|
+
"participants": self.participants,
|
|
173
|
+
"linked_refs": self.linked_refs,
|
|
174
|
+
"paths": {
|
|
175
|
+
"docs_root": self.docs_root,
|
|
176
|
+
"notes_root": self.notes_root,
|
|
177
|
+
"context_root": self.context_root,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclasses.dataclass(frozen=True)
|
|
184
|
+
class LaneTransitionOutcome:
|
|
185
|
+
"""One authoritative result for a lane state transition.
|
|
186
|
+
|
|
187
|
+
The prototype CLI and the Typer CLI both render this result. Neither is
|
|
188
|
+
allowed to infer success from a path print while the state writer reports a
|
|
189
|
+
different outcome.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
action: str
|
|
193
|
+
owner_unit: str
|
|
194
|
+
previous_lane: str | None
|
|
195
|
+
current_lane: str | None
|
|
196
|
+
state_path: Path
|
|
197
|
+
status: str = "ok"
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def exit_code(self) -> int:
|
|
201
|
+
return 0 if self.status == "ok" else 1
|
|
202
|
+
|
|
203
|
+
def as_dict(self) -> dict[str, object]:
|
|
204
|
+
return {
|
|
205
|
+
"status": self.status,
|
|
206
|
+
"action": self.action,
|
|
207
|
+
"owner_unit": self.owner_unit,
|
|
208
|
+
"previous_lane": self.previous_lane,
|
|
209
|
+
"current_lane": self.current_lane,
|
|
210
|
+
"state_path": str(self.state_path),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_args() -> argparse.Namespace:
|
|
215
|
+
parser = argparse.ArgumentParser(description="Prototype gr2 lanes + shared scratchpads")
|
|
216
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
217
|
+
|
|
218
|
+
create = sub.add_parser("create-lane")
|
|
219
|
+
create.add_argument("workspace_root", type=Path)
|
|
220
|
+
create.add_argument("owner_unit")
|
|
221
|
+
create.add_argument("lane_name")
|
|
222
|
+
create.add_argument("--type", default="feature")
|
|
223
|
+
create.add_argument("--repos", required=True, help="comma-separated repo names")
|
|
224
|
+
create.add_argument(
|
|
225
|
+
"--branch",
|
|
226
|
+
required=True,
|
|
227
|
+
help="default branch or repo=branch mappings separated by commas",
|
|
228
|
+
)
|
|
229
|
+
create.add_argument("--source", default="manual")
|
|
230
|
+
create.add_argument(
|
|
231
|
+
"--command",
|
|
232
|
+
dest="default_commands",
|
|
233
|
+
action="append",
|
|
234
|
+
default=[],
|
|
235
|
+
help="default lane command",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
review = sub.add_parser("create-review-lane")
|
|
239
|
+
review.add_argument("workspace_root", type=Path)
|
|
240
|
+
review.add_argument("owner_unit")
|
|
241
|
+
review.add_argument("repo")
|
|
242
|
+
review.add_argument("pr_number", type=int)
|
|
243
|
+
review.add_argument("--lane-name")
|
|
244
|
+
review.add_argument("--branch")
|
|
245
|
+
|
|
246
|
+
share_lane = sub.add_parser("share-lane")
|
|
247
|
+
share_lane.add_argument("workspace_root", type=Path)
|
|
248
|
+
share_lane.add_argument("owner_unit")
|
|
249
|
+
share_lane.add_argument("lane_name")
|
|
250
|
+
share_lane.add_argument("target_unit")
|
|
251
|
+
|
|
252
|
+
continuation = sub.add_parser("create-continuation-lane")
|
|
253
|
+
continuation.add_argument("workspace_root", type=Path)
|
|
254
|
+
continuation.add_argument("source_owner_unit")
|
|
255
|
+
continuation.add_argument("source_lane_name")
|
|
256
|
+
continuation.add_argument("target_unit")
|
|
257
|
+
continuation.add_argument("target_lane_name")
|
|
258
|
+
|
|
259
|
+
handoff = sub.add_parser("plan-handoff")
|
|
260
|
+
handoff.add_argument("workspace_root", type=Path)
|
|
261
|
+
handoff.add_argument("source_owner_unit")
|
|
262
|
+
handoff.add_argument("source_lane_name")
|
|
263
|
+
handoff.add_argument("target_unit")
|
|
264
|
+
handoff.add_argument("--mode", choices=["shared", "continuation"], required=True)
|
|
265
|
+
handoff.add_argument("--target-lane-name")
|
|
266
|
+
handoff.add_argument("--json", action="store_true")
|
|
267
|
+
|
|
268
|
+
show = sub.add_parser("show-lane")
|
|
269
|
+
show.add_argument("workspace_root", type=Path)
|
|
270
|
+
show.add_argument("owner_unit")
|
|
271
|
+
show.add_argument("lane_name")
|
|
272
|
+
|
|
273
|
+
lane_list = sub.add_parser("list-lanes")
|
|
274
|
+
lane_list.add_argument("workspace_root", type=Path)
|
|
275
|
+
lane_list.add_argument("--owner-unit")
|
|
276
|
+
|
|
277
|
+
next_step = sub.add_parser("next-step")
|
|
278
|
+
next_step.add_argument("workspace_root", type=Path)
|
|
279
|
+
next_step.add_argument("owner_unit")
|
|
280
|
+
next_step.add_argument("lane_name")
|
|
281
|
+
|
|
282
|
+
plan = sub.add_parser("plan-exec")
|
|
283
|
+
plan.add_argument("workspace_root", type=Path)
|
|
284
|
+
plan.add_argument("owner_unit")
|
|
285
|
+
plan.add_argument("lane_name")
|
|
286
|
+
plan.add_argument("command_text")
|
|
287
|
+
plan.add_argument("--repos", help="optional comma-separated repo subset")
|
|
288
|
+
plan.add_argument("--json", action="store_true")
|
|
289
|
+
|
|
290
|
+
enter = sub.add_parser("enter-lane")
|
|
291
|
+
enter.add_argument("workspace_root", type=Path)
|
|
292
|
+
enter.add_argument("owner_unit")
|
|
293
|
+
enter.add_argument("lane_name")
|
|
294
|
+
enter.add_argument(
|
|
295
|
+
"--actor", required=True, help="actor label, e.g. human:layne or agent:atlas"
|
|
296
|
+
)
|
|
297
|
+
enter.add_argument("--notify-channel", action="store_true")
|
|
298
|
+
enter.add_argument("--recall", action="store_true")
|
|
299
|
+
|
|
300
|
+
exit_lane = sub.add_parser("exit-lane")
|
|
301
|
+
exit_lane.add_argument("workspace_root", type=Path)
|
|
302
|
+
exit_lane.add_argument("owner_unit")
|
|
303
|
+
exit_lane.add_argument("--actor", required=True)
|
|
304
|
+
exit_lane.add_argument("--notify-channel", action="store_true")
|
|
305
|
+
exit_lane.add_argument("--recall", action="store_true")
|
|
306
|
+
|
|
307
|
+
current = sub.add_parser("current-lane")
|
|
308
|
+
current.add_argument("workspace_root", type=Path)
|
|
309
|
+
current.add_argument("owner_unit")
|
|
310
|
+
current.add_argument("--json", action="store_true")
|
|
311
|
+
|
|
312
|
+
history = sub.add_parser("lane-history")
|
|
313
|
+
history.add_argument("workspace_root", type=Path)
|
|
314
|
+
history.add_argument("owner_unit")
|
|
315
|
+
history.add_argument("--json", action="store_true")
|
|
316
|
+
|
|
317
|
+
lease = sub.add_parser("acquire-lane-lease")
|
|
318
|
+
lease.add_argument("workspace_root", type=Path)
|
|
319
|
+
lease.add_argument("owner_unit")
|
|
320
|
+
lease.add_argument("lane_name")
|
|
321
|
+
lease.add_argument("--actor", required=True)
|
|
322
|
+
lease.add_argument("--mode", choices=["edit", "exec", "review"], required=True)
|
|
323
|
+
lease.add_argument(
|
|
324
|
+
"--ttl-seconds",
|
|
325
|
+
type=int,
|
|
326
|
+
default=900,
|
|
327
|
+
help="lease TTL in seconds before it is considered stale",
|
|
328
|
+
)
|
|
329
|
+
lease.add_argument(
|
|
330
|
+
"--force",
|
|
331
|
+
action="store_true",
|
|
332
|
+
help="break conflicting stale leases with a warning",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
release = sub.add_parser("release-lane-lease")
|
|
336
|
+
release.add_argument("workspace_root", type=Path)
|
|
337
|
+
release.add_argument("owner_unit")
|
|
338
|
+
release.add_argument("lane_name")
|
|
339
|
+
release.add_argument("--actor", required=True)
|
|
340
|
+
|
|
341
|
+
show_leases = sub.add_parser("show-lane-leases")
|
|
342
|
+
show_leases.add_argument("workspace_root", type=Path)
|
|
343
|
+
show_leases.add_argument("owner_unit")
|
|
344
|
+
show_leases.add_argument("lane_name")
|
|
345
|
+
show_leases.add_argument("--json", action="store_true")
|
|
346
|
+
|
|
347
|
+
scratch = sub.add_parser("create-shared-scratchpad")
|
|
348
|
+
scratch.add_argument("workspace_root", type=Path)
|
|
349
|
+
scratch.add_argument("name")
|
|
350
|
+
scratch.add_argument("--kind", default="doc")
|
|
351
|
+
scratch.add_argument("--purpose", required=True)
|
|
352
|
+
scratch.add_argument("--participant", action="append", default=[])
|
|
353
|
+
scratch.add_argument("--ref", action="append", default=[])
|
|
354
|
+
scratch.add_argument("--source", default="manual")
|
|
355
|
+
|
|
356
|
+
scratch_show = sub.add_parser("show-shared-scratchpad")
|
|
357
|
+
scratch_show.add_argument("workspace_root", type=Path)
|
|
358
|
+
scratch_show.add_argument("name")
|
|
359
|
+
|
|
360
|
+
scratch_list = sub.add_parser("list-shared-scratchpads")
|
|
361
|
+
scratch_list.add_argument("workspace_root", type=Path)
|
|
362
|
+
|
|
363
|
+
scratch_audit = sub.add_parser("audit-shared-scratchpads")
|
|
364
|
+
scratch_audit.add_argument("workspace_root", type=Path)
|
|
365
|
+
scratch_audit.add_argument(
|
|
366
|
+
"--stale-days",
|
|
367
|
+
type=int,
|
|
368
|
+
default=7,
|
|
369
|
+
help="mark scratchpads as stale when untouched for this many days",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
promote = sub.add_parser("plan-promote-scratchpad")
|
|
373
|
+
promote.add_argument("workspace_root", type=Path)
|
|
374
|
+
promote.add_argument("name")
|
|
375
|
+
promote.add_argument("--target-repo", required=True)
|
|
376
|
+
promote.add_argument("--target-path", required=True)
|
|
377
|
+
promote.add_argument("--owner-unit", required=True)
|
|
378
|
+
promote.add_argument("--lane", help="optional lane that should carry the promotion")
|
|
379
|
+
|
|
380
|
+
recommend = sub.add_parser("recommend-surface")
|
|
381
|
+
recommend.add_argument("--kind", choices=["code", "doc", "review", "planning"], required=True)
|
|
382
|
+
recommend.add_argument("--collaborative", action="store_true")
|
|
383
|
+
recommend.add_argument("--formal-review", action="store_true")
|
|
384
|
+
recommend.add_argument("--repos", type=int, default=1)
|
|
385
|
+
recommend.add_argument("--shared-draft", action="store_true")
|
|
386
|
+
|
|
387
|
+
review_check = sub.add_parser("check-review-requirements")
|
|
388
|
+
review_check.add_argument("workspace_root", type=Path)
|
|
389
|
+
review_check.add_argument("repo")
|
|
390
|
+
review_check.add_argument("pr_number", type=int)
|
|
391
|
+
review_check.add_argument("--json", action="store_true")
|
|
392
|
+
|
|
393
|
+
return parser.parse_args()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def lane_state_root(workspace_root: Path) -> Path:
|
|
397
|
+
"""The one home for workspace lane state.
|
|
398
|
+
|
|
399
|
+
Lane state is grip plumbing: it lives beside the rest of the lane control
|
|
400
|
+
plane under ``.grip/state/`` -- the same tree that already holds
|
|
401
|
+
``current_lane``, ``lane_transitions``, ``lane_creation`` and the lease
|
|
402
|
+
locks. The old ``workspace_root/agents/`` root was pollution at the
|
|
403
|
+
workspace top level; every path that reaches into lane state funnels
|
|
404
|
+
through here so the layout has a single point of truth.
|
|
405
|
+
|
|
406
|
+
Deliberately NOT ``.grip/lanes``:
|
|
407
|
+
the boundary test (``test_oss_gr2_has_no_external_lane_envelope_reader``)
|
|
408
|
+
reserves the ``.grip/lanes`` fragment; records go beside the control plane
|
|
409
|
+
at ``.grip/state/lanes`` instead.
|
|
410
|
+
"""
|
|
411
|
+
return workspace_root / ".grip" / "state" / "lanes"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def lane_dir(workspace_root: Path, owner_unit: str, lane_name: str) -> Path:
|
|
415
|
+
return lane_state_root(workspace_root) / owner_unit / lane_name
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def lane_file(workspace_root: Path, owner_unit: str, lane_name: str) -> Path:
|
|
419
|
+
return lane_dir(workspace_root, owner_unit, lane_name) / "lane.toml"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def shared_scratchpad_dir(workspace_root: Path, name: str) -> Path:
|
|
423
|
+
return workspace_root / "shared" / "scratchpads" / name
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def shared_scratchpad_file(workspace_root: Path, name: str) -> Path:
|
|
427
|
+
return shared_scratchpad_dir(workspace_root, name) / "scratchpad.toml"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def current_lane_file(workspace_root: Path, owner_unit: str) -> Path:
|
|
431
|
+
return workspace_root / ".grip" / "state" / "current_lane" / f"{owner_unit}.json"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def lane_transition_lock_file(workspace_root: Path, owner_unit: str) -> Path:
|
|
435
|
+
"""One unit-scoped lock covers complete enter/exit metadata transitions."""
|
|
436
|
+
return workspace_root / ".grip" / "state" / "lane_transitions" / f"{owner_unit}.lock"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def lane_creation_lock_file(workspace_root: Path, owner_unit: str, lane_name: str) -> Path:
|
|
440
|
+
return workspace_root / ".grip" / "state" / "lane_creation" / owner_unit / f"{lane_name}.lock"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def scratchpad_creation_lock_file(workspace_root: Path, name: str) -> Path:
|
|
444
|
+
return workspace_root / ".grip" / "state" / "scratchpad_creation" / f"{name}.lock"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@contextlib.contextmanager
|
|
448
|
+
def exclusive_lock(path: Path):
|
|
449
|
+
"""Lock a complete read/modify/write scope, releasing on every path."""
|
|
450
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
with path.open("a+", encoding="utf-8") as handle:
|
|
452
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
453
|
+
try:
|
|
454
|
+
yield
|
|
455
|
+
finally:
|
|
456
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def atomic_replace_text(path: Path, content: str) -> None:
|
|
460
|
+
"""Publish complete metadata bytes or retain the preceding file."""
|
|
461
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
462
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
463
|
+
temporary = Path(temporary_name)
|
|
464
|
+
try:
|
|
465
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
466
|
+
handle.write(content)
|
|
467
|
+
handle.flush()
|
|
468
|
+
os.fsync(handle.fileno())
|
|
469
|
+
os.replace(temporary, path)
|
|
470
|
+
finally:
|
|
471
|
+
if temporary.exists():
|
|
472
|
+
temporary.unlink()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def lane_leases_file(workspace_root: Path, owner_unit: str, lane_name: str) -> Path:
|
|
476
|
+
return lane_dir(workspace_root, owner_unit, lane_name) / "leases.json"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def lane_leases_lock_file(workspace_root: Path, owner_unit: str, lane_name: str) -> Path:
|
|
480
|
+
return lane_dir(workspace_root, owner_unit, lane_name) / "leases.lock"
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def workspace_edit_leases_lock_file(workspace_root: Path) -> Path:
|
|
484
|
+
return workspace_root / ".grip" / "state" / "workspace_edit_leases.lock"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def shared_lane_access_file(workspace_root: Path, owner_unit: str, lane_name: str) -> Path:
|
|
488
|
+
return (
|
|
489
|
+
workspace_root / ".grip" / "state" / "shared_lane_access" / owner_unit / f"{lane_name}.json"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def events_dir(workspace_root: Path) -> Path:
|
|
494
|
+
return workspace_root / ".grip" / "events"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def lane_events_file(workspace_root: Path) -> Path:
|
|
498
|
+
return events_dir(workspace_root) / "lane_events.jsonl"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def recall_lane_events_file(workspace_root: Path) -> Path:
|
|
502
|
+
return events_dir(workspace_root) / "recall_lane_history.jsonl"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def load_workspace_spec(workspace_root: Path) -> dict:
|
|
506
|
+
with (workspace_root / ".grip" / "workspace_spec.toml").open("rb") as fh:
|
|
507
|
+
return tomllib.load(fh)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def find_unit_spec(workspace_root: Path, owner_unit: str) -> dict:
|
|
511
|
+
spec = load_workspace_spec(workspace_root)
|
|
512
|
+
for unit in spec.get("units", []):
|
|
513
|
+
if unit.get("name") == owner_unit:
|
|
514
|
+
return unit
|
|
515
|
+
raise SystemExit(f"unit not found in workspace spec: {owner_unit}")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def load_lane_doc(workspace_root: Path, owner_unit: str, lane_name: str) -> dict:
|
|
519
|
+
path = lane_file(workspace_root, owner_unit, lane_name)
|
|
520
|
+
if not path.exists():
|
|
521
|
+
raise SystemExit(f"lane not found: {owner_unit}/{lane_name}")
|
|
522
|
+
return tomllib.loads(path.read_text())
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def record_fork_base(
|
|
526
|
+
workspace_root: Path, owner_unit: str, lane_name: str, fork_base: dict[str, dict[str, str]]
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Record the fork base (the materialization point) into an existing lane doc.
|
|
529
|
+
|
|
530
|
+
``create_lane`` records ONLY the fork base the caller supplies (the fork-base
|
|
531
|
+
ruling: record, do not derive). The CLI `lane create` clones each repo at its
|
|
532
|
+
branch AFTER the doc is written, so the materialization point — the branch the
|
|
533
|
+
lane forked from and the sha it started at — is not known until then; this writes
|
|
534
|
+
it back. Without it a CLI-created lane has no fork base and `review create-project`
|
|
535
|
+
refuses — the multi-repo review producer failing its own fruit test for a stranger.
|
|
536
|
+
|
|
537
|
+
``fork_base`` maps repo -> {"branch": <name>, "sha": <40-hex>}. Each repo must be in
|
|
538
|
+
the lane; a non-hex sha or empty branch is refused, matching create_lane's checks.
|
|
539
|
+
"""
|
|
540
|
+
path = lane_file(workspace_root, owner_unit, lane_name)
|
|
541
|
+
document = load_lane_doc(workspace_root, owner_unit, lane_name)
|
|
542
|
+
repos_in_lane = set(document.get("repos", []))
|
|
543
|
+
validated: dict[str, dict[str, str]] = {}
|
|
544
|
+
for repo, entry in fork_base.items():
|
|
545
|
+
if repo not in repos_in_lane:
|
|
546
|
+
raise SystemExit(f"fork_base references repo outside lane: {repo}")
|
|
547
|
+
branch = str(entry.get("branch", "")).strip()
|
|
548
|
+
sha = str(entry.get("sha", "")).strip()
|
|
549
|
+
if not branch:
|
|
550
|
+
raise SystemExit(f"fork_base for {repo} has no branch")
|
|
551
|
+
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
|
552
|
+
raise SystemExit(f"fork_base sha for {repo} must be a 40-hex commit, got: {sha!r}")
|
|
553
|
+
validated[repo] = {"branch": branch, "sha": sha}
|
|
554
|
+
document["fork_base"] = {repo: validated[repo] for repo in sorted(validated)}
|
|
555
|
+
atomic_replace_text(path, serialize_toml(document))
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def load_shared_scratchpad_doc(workspace_root: Path, name: str) -> dict:
|
|
559
|
+
path = shared_scratchpad_file(workspace_root, name)
|
|
560
|
+
if not path.exists():
|
|
561
|
+
raise SystemExit(f"shared scratchpad not found: {name}")
|
|
562
|
+
return tomllib.loads(path.read_text())
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def load_current_lane_doc(workspace_root: Path, owner_unit: str) -> dict:
|
|
566
|
+
path = current_lane_file(workspace_root, owner_unit)
|
|
567
|
+
if not path.exists():
|
|
568
|
+
raise SystemExit(f"no current lane recorded for unit: {owner_unit}")
|
|
569
|
+
return json.loads(path.read_text())
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def require_current_lane(workspace_root: Path, owner_unit: str) -> dict:
|
|
573
|
+
"""The current lane record, refusing when there is not one.
|
|
574
|
+
|
|
575
|
+
``load_current_lane_doc`` raises only when the FILE is absent. After a
|
|
576
|
+
``lane exit`` the file is present and holds ``{"current": null}``, so every
|
|
577
|
+
caller that went straight to ``doc["current"]["lane_name"]`` raised
|
|
578
|
+
``TypeError: 'NoneType' object is not subscriptable`` and put a traceback in
|
|
579
|
+
front of the user -- for a state gr2 writes itself, by the most ordinary
|
|
580
|
+
sequence there is: enter, then exit.
|
|
581
|
+
|
|
582
|
+
Two absent-shapes, one of them guarded, was the whole defect. This exists
|
|
583
|
+
so callers cannot read the field without the check: three separate call
|
|
584
|
+
sites had each written the unguarded read, and a fourth copy of the guard
|
|
585
|
+
would only postpone the fifth.
|
|
586
|
+
"""
|
|
587
|
+
doc = load_current_lane_doc(workspace_root, owner_unit)
|
|
588
|
+
current = doc.get("current")
|
|
589
|
+
if not current:
|
|
590
|
+
raise SystemExit(f"no current lane recorded for unit: {owner_unit}")
|
|
591
|
+
return current
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def emit_lane_event(workspace_root: Path, payload: dict) -> None:
|
|
595
|
+
append_jsonl(lane_events_file(workspace_root), payload)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def emit_recall_lane_event(workspace_root: Path, payload: dict) -> None:
|
|
599
|
+
append_jsonl(recall_lane_events_file(workspace_root), payload)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def iter_lane_events(workspace_root: Path) -> JsonlRead:
|
|
603
|
+
"""Rows AND the lines that could not be read. See ``jsonl_store.JsonlRead``."""
|
|
604
|
+
|
|
605
|
+
return read_jsonl(lane_events_file(workspace_root))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def write_json(path: Path, payload: dict) -> None:
|
|
609
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
610
|
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def load_lane_leases(workspace_root: Path, owner_unit: str, lane_name: str) -> list[dict]:
|
|
614
|
+
path = lane_leases_file(workspace_root, owner_unit, lane_name)
|
|
615
|
+
if not path.exists():
|
|
616
|
+
return []
|
|
617
|
+
return json.loads(path.read_text())
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def workspace_constraints(workspace_root: Path) -> dict:
|
|
621
|
+
spec = load_workspace_spec(workspace_root)
|
|
622
|
+
return spec.get("workspace_constraints", {})
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def max_global_edit_leases(workspace_root: Path) -> int | None:
|
|
626
|
+
value = workspace_constraints(workspace_root).get("max_concurrent_edit_leases_global")
|
|
627
|
+
if value is None:
|
|
628
|
+
return None
|
|
629
|
+
return int(value)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def active_workspace_edit_leases(workspace_root: Path) -> list[dict]:
|
|
633
|
+
rows: list[dict] = []
|
|
634
|
+
for path in iter_lane_files(workspace_root):
|
|
635
|
+
lane_doc = tomllib.loads(path.read_text())
|
|
636
|
+
owner_unit = lane_doc["owner_unit"]
|
|
637
|
+
lane_name = lane_doc["lane_name"]
|
|
638
|
+
for lease in load_lane_leases(workspace_root, owner_unit, lane_name):
|
|
639
|
+
if lease["mode"] != "edit" or is_stale_lease(lease):
|
|
640
|
+
continue
|
|
641
|
+
rows.append(
|
|
642
|
+
{
|
|
643
|
+
"owner_unit": owner_unit,
|
|
644
|
+
"lane_name": lane_name,
|
|
645
|
+
**lease,
|
|
646
|
+
}
|
|
647
|
+
)
|
|
648
|
+
return rows
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def lease_locking_enabled() -> bool:
|
|
652
|
+
return os.environ.get("GR2_DISABLE_LEASE_LOCKING") != "1"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def write_lane_leases(
|
|
656
|
+
workspace_root: Path,
|
|
657
|
+
owner_unit: str,
|
|
658
|
+
lane_name: str,
|
|
659
|
+
leases: list[dict],
|
|
660
|
+
*,
|
|
661
|
+
lock_fh=None,
|
|
662
|
+
) -> None:
|
|
663
|
+
path = lane_leases_file(workspace_root, owner_unit, lane_name)
|
|
664
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
665
|
+
|
|
666
|
+
if lock_fh is None:
|
|
667
|
+
lock_path = lane_leases_lock_file(workspace_root, owner_unit, lane_name)
|
|
668
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
669
|
+
with lock_path.open("a+", encoding="utf-8") as owned_lock_fh:
|
|
670
|
+
if lease_locking_enabled():
|
|
671
|
+
fcntl.flock(owned_lock_fh.fileno(), fcntl.LOCK_EX)
|
|
672
|
+
tmp = path.with_suffix(".json.tmp")
|
|
673
|
+
tmp.write_text(json.dumps(leases, indent=2) + "\n")
|
|
674
|
+
os.replace(tmp, path)
|
|
675
|
+
if lease_locking_enabled():
|
|
676
|
+
fcntl.flock(owned_lock_fh.fileno(), fcntl.LOCK_UN)
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
tmp = path.with_suffix(".json.tmp")
|
|
680
|
+
tmp.write_text(json.dumps(leases, indent=2) + "\n")
|
|
681
|
+
os.replace(tmp, path)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def mutate_lane_leases(
|
|
685
|
+
workspace_root: Path,
|
|
686
|
+
owner_unit: str,
|
|
687
|
+
lane_name: str,
|
|
688
|
+
mutator,
|
|
689
|
+
):
|
|
690
|
+
lock_path = lane_leases_lock_file(workspace_root, owner_unit, lane_name)
|
|
691
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
692
|
+
with lock_path.open("a+", encoding="utf-8") as lock_fh:
|
|
693
|
+
if lease_locking_enabled():
|
|
694
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
695
|
+
path = lane_leases_file(workspace_root, owner_unit, lane_name)
|
|
696
|
+
if path.exists():
|
|
697
|
+
leases = json.loads(path.read_text())
|
|
698
|
+
else:
|
|
699
|
+
leases = []
|
|
700
|
+
if not lease_locking_enabled():
|
|
701
|
+
delay = float(os.environ.get("GR2_LEASE_TEST_DELAY", "0"))
|
|
702
|
+
if delay > 0:
|
|
703
|
+
time.sleep(delay)
|
|
704
|
+
result = mutator(leases)
|
|
705
|
+
if result.get("write"):
|
|
706
|
+
write_lane_leases(
|
|
707
|
+
workspace_root,
|
|
708
|
+
owner_unit,
|
|
709
|
+
lane_name,
|
|
710
|
+
result["leases"],
|
|
711
|
+
lock_fh=lock_fh,
|
|
712
|
+
)
|
|
713
|
+
if lease_locking_enabled():
|
|
714
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
|
715
|
+
return result
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def mutate_workspace_edit_lease(
|
|
719
|
+
workspace_root: Path,
|
|
720
|
+
owner_unit: str,
|
|
721
|
+
lane_name: str,
|
|
722
|
+
actor: str,
|
|
723
|
+
mutator,
|
|
724
|
+
):
|
|
725
|
+
"""Check the workspace cap and mutate one lane in a single critical section."""
|
|
726
|
+
lock_path = workspace_edit_leases_lock_file(workspace_root)
|
|
727
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
728
|
+
with lock_path.open("a+", encoding="utf-8") as lock_fh:
|
|
729
|
+
if lease_locking_enabled():
|
|
730
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
731
|
+
|
|
732
|
+
cap = max_global_edit_leases(workspace_root)
|
|
733
|
+
if cap is not None:
|
|
734
|
+
active_edits = [
|
|
735
|
+
lease
|
|
736
|
+
for lease in active_workspace_edit_leases(workspace_root)
|
|
737
|
+
if not (
|
|
738
|
+
lease["owner_unit"] == owner_unit
|
|
739
|
+
and lease["lane_name"] == lane_name
|
|
740
|
+
and lease["actor"] == actor
|
|
741
|
+
)
|
|
742
|
+
]
|
|
743
|
+
if len(active_edits) >= cap:
|
|
744
|
+
return {
|
|
745
|
+
"status": "blocked",
|
|
746
|
+
"payload": {
|
|
747
|
+
"status": "blocked",
|
|
748
|
+
"reason": "workspace-edit-lease-cap",
|
|
749
|
+
"requested": {
|
|
750
|
+
"owner_unit": owner_unit,
|
|
751
|
+
"lane_name": lane_name,
|
|
752
|
+
"actor": actor,
|
|
753
|
+
"mode": "edit",
|
|
754
|
+
},
|
|
755
|
+
"active_edit_leases": active_edits,
|
|
756
|
+
"max_concurrent_edit_leases_global": cap,
|
|
757
|
+
},
|
|
758
|
+
"write": False,
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
delay = float(os.environ.get("GR2_WORKSPACE_CAP_TEST_DELAY", "0"))
|
|
762
|
+
if delay > 0:
|
|
763
|
+
time.sleep(delay)
|
|
764
|
+
return mutate_lane_leases(workspace_root, owner_unit, lane_name, mutator)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def iter_lane_files(workspace_root: Path, owner_unit: str | None = None) -> list[Path]:
|
|
768
|
+
state_root = lane_state_root(workspace_root)
|
|
769
|
+
if not state_root.exists():
|
|
770
|
+
return []
|
|
771
|
+
if owner_unit:
|
|
772
|
+
unit_roots = [state_root / owner_unit]
|
|
773
|
+
else:
|
|
774
|
+
unit_roots = [path for path in state_root.iterdir() if path.is_dir()]
|
|
775
|
+
|
|
776
|
+
files: list[Path] = []
|
|
777
|
+
for root in unit_roots:
|
|
778
|
+
if not root.exists():
|
|
779
|
+
continue
|
|
780
|
+
files.extend(sorted(root.glob("*/lane.toml")))
|
|
781
|
+
return files
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def iter_shared_scratchpad_files(workspace_root: Path) -> list[Path]:
|
|
785
|
+
root = workspace_root / "shared" / "scratchpads"
|
|
786
|
+
if not root.exists():
|
|
787
|
+
return []
|
|
788
|
+
return sorted(root.glob("*/scratchpad.toml"))
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def now_utc() -> str:
|
|
792
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def parse_utc(raw: str) -> datetime:
|
|
796
|
+
return datetime.fromisoformat(raw)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def is_stale_lease(lease: dict) -> bool:
|
|
800
|
+
expires_at = lease.get("expires_at")
|
|
801
|
+
if not expires_at:
|
|
802
|
+
return False
|
|
803
|
+
return parse_utc(expires_at) <= datetime.now(UTC)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def build_lease(actor: str, mode: str, ttl_seconds: int) -> dict:
|
|
807
|
+
acquired = datetime.now(UTC).replace(microsecond=0)
|
|
808
|
+
expires = acquired + timedelta(seconds=ttl_seconds)
|
|
809
|
+
return {
|
|
810
|
+
"actor": actor,
|
|
811
|
+
"mode": mode,
|
|
812
|
+
"ttl_seconds": ttl_seconds,
|
|
813
|
+
"acquired_at": acquired.isoformat(),
|
|
814
|
+
"expires_at": expires.isoformat(),
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def lease_conflicts(existing_mode: str, requested_mode: str) -> bool:
|
|
819
|
+
matrix = {
|
|
820
|
+
"edit": {"edit", "exec", "review"},
|
|
821
|
+
"exec": {"edit", "review"},
|
|
822
|
+
"review": {"edit", "exec", "review"},
|
|
823
|
+
}
|
|
824
|
+
return requested_mode in matrix.get(existing_mode, set())
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def conflicting_leases(
|
|
828
|
+
leases: list[dict], actor: str, requested_mode: str
|
|
829
|
+
) -> tuple[list[dict], list[dict]]:
|
|
830
|
+
active: list[dict] = []
|
|
831
|
+
stale: list[dict] = []
|
|
832
|
+
for lease in leases:
|
|
833
|
+
if lease["actor"] == actor:
|
|
834
|
+
continue
|
|
835
|
+
if not lease_conflicts(lease["mode"], requested_mode):
|
|
836
|
+
continue
|
|
837
|
+
if is_stale_lease(lease):
|
|
838
|
+
stale.append(lease)
|
|
839
|
+
else:
|
|
840
|
+
active.append(lease)
|
|
841
|
+
return active, stale
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def age_days(path: Path) -> int:
|
|
845
|
+
modified = datetime.fromtimestamp(path.stat().st_mtime, UTC)
|
|
846
|
+
return max(0, int((datetime.now(UTC) - modified).total_seconds() // 86400))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def parse_repo_list(raw: str) -> list[str]:
|
|
850
|
+
return [repo.strip() for repo in raw.split(",") if repo.strip()]
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def parse_branch_arg(raw: str, repos: list[str]) -> dict[str, str]:
|
|
854
|
+
if "=" not in raw:
|
|
855
|
+
return {repo: raw for repo in repos}
|
|
856
|
+
|
|
857
|
+
branch_map: dict[str, str] = {}
|
|
858
|
+
for item in raw.split(","):
|
|
859
|
+
item = item.strip()
|
|
860
|
+
if not item:
|
|
861
|
+
continue
|
|
862
|
+
repo, branch = item.split("=", 1)
|
|
863
|
+
repo = repo.strip()
|
|
864
|
+
branch = branch.strip()
|
|
865
|
+
if repo not in repos:
|
|
866
|
+
raise SystemExit(f"branch mapping references repo outside lane: {repo}")
|
|
867
|
+
if not branch:
|
|
868
|
+
raise SystemExit(f"empty branch in mapping: {item}")
|
|
869
|
+
branch_map[repo] = branch
|
|
870
|
+
|
|
871
|
+
missing = [repo for repo in repos if repo not in branch_map]
|
|
872
|
+
if missing:
|
|
873
|
+
raise SystemExit("missing branch mapping for repos: " + ", ".join(missing))
|
|
874
|
+
|
|
875
|
+
return branch_map
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _validate_bound_worktree(bind_path: Path, workspace_root: Path) -> tuple[str, str]:
|
|
879
|
+
"""Validate a worktree an author wants a bound lane to LABEL, returning
|
|
880
|
+
``(head_sha, branch)``.
|
|
881
|
+
|
|
882
|
+
A bound lane derives its reviewed bytes from this worktree instead of a
|
|
883
|
+
pinned clone, so the reconstruction guarantee holds only if the tree is a
|
|
884
|
+
real, clean, non-detached git checkout — the same discipline the
|
|
885
|
+
freeze-public-range gate applies before it trusts a range. Any failure is a
|
|
886
|
+
hard refusal (``SystemExit``); a bound lane is never created over a tree that
|
|
887
|
+
could make its recorded head fail to reconstruct the reviewed bytes.
|
|
888
|
+
|
|
889
|
+
Containment: the resolved worktree must live UNDER ``workspace_root`` (a plain
|
|
890
|
+
filesystem containment, same shape as ``close_review_lane``'s strict-descendant
|
|
891
|
+
gate). This is the ruling's "a path outside the author's own gripspace"
|
|
892
|
+
refusal, implemented as a path check rather than via identity resolution — so
|
|
893
|
+
it needs no owner-unit -> gripspace mapping and stays clear of the premium
|
|
894
|
+
boundary."""
|
|
895
|
+
resolved = bind_path.resolve()
|
|
896
|
+
root = workspace_root.resolve()
|
|
897
|
+
if resolved != root and root not in resolved.parents:
|
|
898
|
+
raise SystemExit(
|
|
899
|
+
f"--bind path {resolved} is not under the workspace root {root}: a bound lane may "
|
|
900
|
+
"only label a worktree inside the author's own workspace"
|
|
901
|
+
)
|
|
902
|
+
if not resolved.is_dir():
|
|
903
|
+
raise SystemExit(f"--bind path is not a directory: {resolved}")
|
|
904
|
+
if not gitops.is_git_repo(resolved):
|
|
905
|
+
raise SystemExit(f"--bind path is not a git work tree: {resolved}")
|
|
906
|
+
branch = gitops.current_branch(resolved)
|
|
907
|
+
if not branch:
|
|
908
|
+
raise SystemExit(
|
|
909
|
+
f"--bind refuses a detached HEAD at {resolved}: a bound lane's head must be a "
|
|
910
|
+
"named branch so pr create has something to push"
|
|
911
|
+
)
|
|
912
|
+
if gitops.repo_dirty(resolved):
|
|
913
|
+
raise SystemExit(
|
|
914
|
+
f"--bind refuses a dirty tree at {resolved}: a bound lane's receipt records the "
|
|
915
|
+
"worktree HEAD, and uncommitted changes (tracked OR untracked) would make the "
|
|
916
|
+
"recorded head fail to reconstruct the reviewed bytes. Commit or stash first."
|
|
917
|
+
)
|
|
918
|
+
head = gitops.current_head_sha(resolved)
|
|
919
|
+
if not head:
|
|
920
|
+
raise SystemExit(f"--bind could not read HEAD at {resolved}")
|
|
921
|
+
return head, branch
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def create_lane(args: argparse.Namespace) -> int:
|
|
925
|
+
workspace_root = args.workspace_root.resolve()
|
|
926
|
+
validate_lane_path_component(args.owner_unit, "owner_unit")
|
|
927
|
+
validate_lane_path_component(args.lane_name, "lane_name")
|
|
928
|
+
spec = load_workspace_spec(workspace_root)
|
|
929
|
+
unit_spec = find_unit_spec(workspace_root, args.owner_unit)
|
|
930
|
+
repo_names = [item["name"] for item in spec.get("repos", [])]
|
|
931
|
+
repos = parse_repo_list(args.repos)
|
|
932
|
+
|
|
933
|
+
missing = [repo for repo in repos if repo not in repo_names]
|
|
934
|
+
if missing:
|
|
935
|
+
raise SystemExit(f"unknown repos for lane: {', '.join(missing)}")
|
|
936
|
+
|
|
937
|
+
# gr2-lane-author-shape ruling (2026-09-03): --bind makes the lane a LABEL on
|
|
938
|
+
# an existing worktree instead of a fresh materialization. A bound lane is
|
|
939
|
+
# single-repo only, and its head/branch come from the worktree under the
|
|
940
|
+
# clean-tree/HEAD guard; no clone is materialized (no repos/ subdir).
|
|
941
|
+
bind = getattr(args, "bind", None)
|
|
942
|
+
lane_kind = "materialized"
|
|
943
|
+
bound_worktree: str | None = None
|
|
944
|
+
bound_head: str | None = None
|
|
945
|
+
if bind is not None:
|
|
946
|
+
# Single-repo check comes BEFORE branch parsing: a bound lane's branch is
|
|
947
|
+
# derived from the worktree, not the --branch arg, so parse_branch_arg
|
|
948
|
+
# must not run (and must not mask the single-repo refusal with a
|
|
949
|
+
# missing-mapping error for a repo a bound lane would never carry).
|
|
950
|
+
if len(repos) != 1:
|
|
951
|
+
raise SystemExit(
|
|
952
|
+
"a bound lane is single-repo only (gr2-lane-author-shape ruling): pass exactly "
|
|
953
|
+
f"one repo to --repos, got {repos or '[]'}"
|
|
954
|
+
)
|
|
955
|
+
head, branch = _validate_bound_worktree(Path(bind), workspace_root)
|
|
956
|
+
lane_kind = "bound"
|
|
957
|
+
bound_worktree = str(Path(bind).resolve())
|
|
958
|
+
# Record the worktree HEAD at create: the drift baseline a review bind on
|
|
959
|
+
# this bound lane re-checks against (moved HEAD or dirty tree refuses).
|
|
960
|
+
bound_head = head
|
|
961
|
+
branch_map = {repos[0]: branch}
|
|
962
|
+
else:
|
|
963
|
+
branch_map = parse_branch_arg(args.branch, repos)
|
|
964
|
+
|
|
965
|
+
# fork-base ruling: record what the caller supplies, do not derive.
|
|
966
|
+
# Each entry names a repo IN the lane and carries a 40-hex integration-branch tip.
|
|
967
|
+
raw_fork_base = getattr(args, "fork_base", None) or {}
|
|
968
|
+
fork_base: dict[str, dict[str, str]] = {}
|
|
969
|
+
for repo, entry in raw_fork_base.items():
|
|
970
|
+
if repo not in repos:
|
|
971
|
+
raise SystemExit(f"fork_base references repo outside lane: {repo}")
|
|
972
|
+
branch = str(entry.get("branch", "")).strip()
|
|
973
|
+
sha = str(entry.get("sha", "")).strip()
|
|
974
|
+
if not branch:
|
|
975
|
+
raise SystemExit(f"fork_base for {repo} has no branch")
|
|
976
|
+
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
|
977
|
+
raise SystemExit(f"fork_base sha for {repo} must be a 40-hex commit, got: {sha!r}")
|
|
978
|
+
fork_base[repo] = {"branch": branch, "sha": sha}
|
|
979
|
+
|
|
980
|
+
metadata = LaneMetadata(
|
|
981
|
+
schema_version=LANE_SCHEMA_VERSION,
|
|
982
|
+
lane_name=args.lane_name,
|
|
983
|
+
owner_unit=args.owner_unit,
|
|
984
|
+
agent_id=unit_spec.get("agent_id"),
|
|
985
|
+
lane_type=args.type,
|
|
986
|
+
repos=repos,
|
|
987
|
+
branch_map=branch_map,
|
|
988
|
+
pr_associations=list(getattr(args, "pr_associations", [])),
|
|
989
|
+
shared_context_roots=["config", ".grip/context/shared"],
|
|
990
|
+
private_context_roots=[
|
|
991
|
+
f"agents/{args.owner_unit}/home/context",
|
|
992
|
+
str(
|
|
993
|
+
lane_dir(workspace_root, args.owner_unit, args.lane_name).relative_to(
|
|
994
|
+
workspace_root
|
|
995
|
+
)
|
|
996
|
+
/ "context"
|
|
997
|
+
),
|
|
998
|
+
],
|
|
999
|
+
exec_defaults={
|
|
1000
|
+
"parallelism": "workspace-default",
|
|
1001
|
+
"fail_fast": True,
|
|
1002
|
+
"default_command_family": ["build", "test"],
|
|
1003
|
+
"commands": args.default_commands,
|
|
1004
|
+
},
|
|
1005
|
+
creation_source=args.source,
|
|
1006
|
+
shared_with=[],
|
|
1007
|
+
handoff_source=None,
|
|
1008
|
+
lane_kind=lane_kind,
|
|
1009
|
+
bound_worktree=bound_worktree,
|
|
1010
|
+
bound_head=bound_head,
|
|
1011
|
+
fork_base=fork_base,
|
|
1012
|
+
)
|
|
1013
|
+
lane_root = lane_dir(workspace_root, args.owner_unit, args.lane_name)
|
|
1014
|
+
metadata_path = lane_file(workspace_root, args.owner_unit, args.lane_name)
|
|
1015
|
+
expected = metadata.as_toml()
|
|
1016
|
+
# Refuse the ordinary existing-target case before even publishing lock
|
|
1017
|
+
# scaffolding. The locked recheck below still protects a racing creator.
|
|
1018
|
+
if metadata_path.exists():
|
|
1019
|
+
if metadata_path.read_text() == expected:
|
|
1020
|
+
print(f"lane already exists unchanged: {metadata_path}")
|
|
1021
|
+
return 0
|
|
1022
|
+
raise SystemExit(f"refusing to replace existing lane: {metadata_path}")
|
|
1023
|
+
if lane_root.exists():
|
|
1024
|
+
raise SystemExit(f"refusing to create lane over existing path: {lane_root}")
|
|
1025
|
+
with exclusive_lock(lane_creation_lock_file(workspace_root, args.owner_unit, args.lane_name)):
|
|
1026
|
+
if metadata_path.exists():
|
|
1027
|
+
if metadata_path.read_text() == expected:
|
|
1028
|
+
print(f"lane already exists unchanged: {metadata_path}")
|
|
1029
|
+
return 0
|
|
1030
|
+
raise SystemExit(f"refusing to replace existing lane: {metadata_path}")
|
|
1031
|
+
if lane_root.exists():
|
|
1032
|
+
raise SystemExit(f"refusing to create lane over existing path: {lane_root}")
|
|
1033
|
+
lane_root.mkdir(parents=True)
|
|
1034
|
+
# A bound lane owns no clone, so it has no repos/ subdir — the absence is
|
|
1035
|
+
# itself a signal to materialization that there is nothing to clone.
|
|
1036
|
+
if lane_kind != "bound":
|
|
1037
|
+
(lane_root / "repos").mkdir()
|
|
1038
|
+
(lane_root / "context").mkdir()
|
|
1039
|
+
atomic_replace_text(metadata_path, expected)
|
|
1040
|
+
print(metadata_path)
|
|
1041
|
+
return 0
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def bind_bound_lane(
|
|
1045
|
+
workspace_root: Path, owner_unit: str, lane_name: str, *, base: str | None = None, allow_local: bool = False
|
|
1046
|
+
) -> "_review.ReviewRecord":
|
|
1047
|
+
"""Bind a review receipt for a BOUND lane, sourced LIVE from the author's
|
|
1048
|
+
worktree (gr2-lane-author-shape ruling verb #2).
|
|
1049
|
+
|
|
1050
|
+
A bound lane has no materialized clone and no carried range: its reviewed
|
|
1051
|
+
bytes are the author's own worktree, so reconstruction is "read the local
|
|
1052
|
+
tree". This re-checks, at bind time, the same invariants create imposed —
|
|
1053
|
+
the worktree is a clean, non-detached git checkout — AND that HEAD has not
|
|
1054
|
+
DRIFTED from the head recorded at create (``bound_head``). A moved HEAD or a
|
|
1055
|
+
dirty tree (tracked OR untracked) is a hard refusal: the receipt promises the
|
|
1056
|
+
recorded head reconstructs the reviewed bytes, and drift breaks that promise.
|
|
1057
|
+
|
|
1058
|
+
On success it writes the ``(repo, base, head, lane_kind="bound")`` receipt to
|
|
1059
|
+
the worktree's own ``.git/grip-review.json`` (the same path helper a
|
|
1060
|
+
materialized lane uses) and returns the record.
|
|
1061
|
+
|
|
1062
|
+
``base`` is the pin the reviewed range is measured from. It MUST be a full
|
|
1063
|
+
40-hex commit that is an ANCESTOR of the worktree head — a non-hex string, a
|
|
1064
|
+
well-formed-but-nonexistent sha (``ffff…``), or a commit head does not descend
|
|
1065
|
+
from is refused, so a nonsense base can never reach the receipt. Deriving base
|
|
1066
|
+
automatically from the lane branch's upstream is a follow-up (the open
|
|
1067
|
+
receipt-contract question flagged to review); the drift mechanism does not
|
|
1068
|
+
depend on how base is chosen.
|
|
1069
|
+
|
|
1070
|
+
``allow_local`` gates a non-portable ``local:<path>`` identity for a worktree
|
|
1071
|
+
with no GitHub origin. It defaults False (a review identity must be a portable
|
|
1072
|
+
GitHub source); pass it only for a local test worktree."""
|
|
1073
|
+
lane_doc = load_lane_doc(workspace_root.resolve(), owner_unit, lane_name)
|
|
1074
|
+
if lane_doc.get("lane_kind") != "bound":
|
|
1075
|
+
raise SystemExit(
|
|
1076
|
+
f"bind_bound_lane: lane {owner_unit}/{lane_name} is not a bound lane "
|
|
1077
|
+
f"(lane_kind={lane_doc.get('lane_kind')!r}); use the materialized review path"
|
|
1078
|
+
)
|
|
1079
|
+
# Base resolution (fork-base ruling): an explicit base wins; when omitted, read
|
|
1080
|
+
# the recorded fork base for this bound lane's single repo. A lane with neither
|
|
1081
|
+
# is refused with the unknown message — the review base is never HEAD^.
|
|
1082
|
+
if base is None:
|
|
1083
|
+
fork_base = lane_doc.get("fork_base", {})
|
|
1084
|
+
repos = lane_doc.get("repos", [])
|
|
1085
|
+
entry = fork_base.get(repos[0]) if repos else None
|
|
1086
|
+
recorded = (entry or {}).get("sha")
|
|
1087
|
+
if not recorded:
|
|
1088
|
+
raise SystemExit(
|
|
1089
|
+
f"bind refuses: lane {owner_unit}/{lane_name} has no recorded fork base and no "
|
|
1090
|
+
"--base was given; the review base is unknown (it is never HEAD^). Recreate the "
|
|
1091
|
+
"lane with a fork base, or pass --base explicitly."
|
|
1092
|
+
)
|
|
1093
|
+
base = recorded
|
|
1094
|
+
worktree = Path(lane_doc.get("bound_worktree") or "")
|
|
1095
|
+
recorded_head = lane_doc.get("bound_head")
|
|
1096
|
+
if not worktree or not recorded_head:
|
|
1097
|
+
raise SystemExit(
|
|
1098
|
+
f"bind_bound_lane: bound lane {owner_unit}/{lane_name} is missing "
|
|
1099
|
+
"bound_worktree or bound_head; it was not created by lane create --bind"
|
|
1100
|
+
)
|
|
1101
|
+
resolved = worktree.resolve()
|
|
1102
|
+
if not gitops.is_git_repo(resolved):
|
|
1103
|
+
raise SystemExit(f"bind refuses: bound worktree {resolved} is no longer a git work tree")
|
|
1104
|
+
if not gitops.current_branch(resolved):
|
|
1105
|
+
raise SystemExit(f"bind refuses: bound worktree {resolved} is in detached HEAD")
|
|
1106
|
+
if gitops.repo_dirty(resolved):
|
|
1107
|
+
raise SystemExit(
|
|
1108
|
+
f"bind refuses (DRIFT): bound worktree {resolved} is dirty (tracked OR untracked "
|
|
1109
|
+
"changes); the recorded head would not reconstruct the reviewed bytes. Commit or "
|
|
1110
|
+
"stash, then re-bind."
|
|
1111
|
+
)
|
|
1112
|
+
current_head = gitops.current_head_sha(resolved)
|
|
1113
|
+
if current_head != recorded_head:
|
|
1114
|
+
raise SystemExit(
|
|
1115
|
+
f"bind refuses (DRIFT): bound worktree {resolved} HEAD is {current_head}, not the "
|
|
1116
|
+
f"head recorded at create ({recorded_head}); the lane has moved. Re-create the bound "
|
|
1117
|
+
"lane at the new head, or reset the worktree to the recorded head."
|
|
1118
|
+
)
|
|
1119
|
+
# base must be a real ancestor of head, not just a well-formed sha: a 40-hex
|
|
1120
|
+
# string that is not a commit (ffff...), or a commit head does not descend
|
|
1121
|
+
# from, would put a base into the receipt that does not bound the reviewed
|
|
1122
|
+
# range. The hex check refuses non-sha strings before git sees them; the
|
|
1123
|
+
# merge-base --is-ancestor check refuses a well-formed-but-wrong base.
|
|
1124
|
+
if len(base) != 40 or any(c not in "0123456789abcdef" for c in base):
|
|
1125
|
+
raise SystemExit(
|
|
1126
|
+
f"bind refuses: base {base!r} is not a full 40-hex commit sha"
|
|
1127
|
+
)
|
|
1128
|
+
if gitops.git(resolved, "merge-base", "--is-ancestor", base, current_head).returncode != 0:
|
|
1129
|
+
raise SystemExit(
|
|
1130
|
+
f"bind refuses: base {base} is not an ancestor of the worktree head {current_head} "
|
|
1131
|
+
"(not a commit, or head does not descend from it); it cannot bound the reviewed range"
|
|
1132
|
+
)
|
|
1133
|
+
origin = gitops.remote_origin_url(resolved)
|
|
1134
|
+
try:
|
|
1135
|
+
repo_identity = _review.canonical_source_identity(origin or str(resolved), allow_local=allow_local)
|
|
1136
|
+
except _review.ReviewError as exc:
|
|
1137
|
+
raise SystemExit(
|
|
1138
|
+
f"bind refuses: bound worktree {resolved} has no portable GitHub origin "
|
|
1139
|
+
f"({exc}); pass --allow-local to bind a non-portable local: identity"
|
|
1140
|
+
) from exc
|
|
1141
|
+
record = _review.ReviewRecord(
|
|
1142
|
+
repo=repo_identity, base=base, head=current_head, lane_kind="bound"
|
|
1143
|
+
)
|
|
1144
|
+
receipt_path = _review.review_record_path(resolved)
|
|
1145
|
+
receipt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1146
|
+
receipt_path.write_text(json.dumps(record.to_dict(), indent=2) + "\n")
|
|
1147
|
+
return record
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def pr_create_bound_lane(
|
|
1151
|
+
workspace_root: Path, owner_unit: str, lane_name: str, *, remote: str | None = None,
|
|
1152
|
+
set_upstream: bool = True,
|
|
1153
|
+
) -> "_push.PushReceipt":
|
|
1154
|
+
"""`pr create` for a BOUND lane: push the reviewed head from the author's own
|
|
1155
|
+
worktree (gr2-lane-author-shape ruling verb #4).
|
|
1156
|
+
|
|
1157
|
+
A bound lane's PR is opened FROM the worktree, not from a materialized clone,
|
|
1158
|
+
so this reuses the ordinary push seam (``push_current_branch``, which verifies
|
|
1159
|
+
the remote ref equals HEAD). It is gated on the bind receipt:
|
|
1160
|
+
|
|
1161
|
+
* the lane must be bound and must have a bind receipt (``gr2 lane bind`` first);
|
|
1162
|
+
* the reviewed range ``base..head`` must be NON-EMPTY — ``base == head`` is
|
|
1163
|
+
refused HERE, at pr create, not at bind: bind records whatever head the
|
|
1164
|
+
author is on, but there is nothing to open a PR for when the range is empty;
|
|
1165
|
+
* the worktree HEAD must still equal the receipt head — if it drifted since
|
|
1166
|
+
bind, the push would carry an unreviewed head, so re-bind first.
|
|
1167
|
+
|
|
1168
|
+
The smallest end-to-end proof pushes to a local bare remote and opens nothing;
|
|
1169
|
+
the platform (gh) path sits behind this same push seam."""
|
|
1170
|
+
lane_doc = load_lane_doc(workspace_root.resolve(), owner_unit, lane_name)
|
|
1171
|
+
if lane_doc.get("lane_kind") != "bound":
|
|
1172
|
+
raise SystemExit(
|
|
1173
|
+
f"pr_create_bound_lane: lane {owner_unit}/{lane_name} is not a bound lane "
|
|
1174
|
+
f"(lane_kind={lane_doc.get('lane_kind')!r}); use the group/adapter pr path"
|
|
1175
|
+
)
|
|
1176
|
+
worktree = Path(lane_doc.get("bound_worktree") or "")
|
|
1177
|
+
if not worktree:
|
|
1178
|
+
raise SystemExit(f"pr create refuses: bound lane {owner_unit}/{lane_name} has no bound_worktree")
|
|
1179
|
+
resolved = worktree.resolve()
|
|
1180
|
+
receipt_path = _review.review_record_path(resolved)
|
|
1181
|
+
if not receipt_path.is_file():
|
|
1182
|
+
raise SystemExit(
|
|
1183
|
+
f"pr create refuses: bound lane {owner_unit}/{lane_name} has no review receipt at "
|
|
1184
|
+
f"{receipt_path}; run `gr2 lane bind` first"
|
|
1185
|
+
)
|
|
1186
|
+
try:
|
|
1187
|
+
receipt = json.loads(receipt_path.read_text())
|
|
1188
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1189
|
+
raise SystemExit(f"pr create refuses: review receipt is unreadable or malformed ({exc})")
|
|
1190
|
+
base, head = receipt.get("base"), receipt.get("head")
|
|
1191
|
+
if base == head:
|
|
1192
|
+
raise SystemExit(
|
|
1193
|
+
f"pr create refuses: the reviewed range is EMPTY (base == head == {head}); there is "
|
|
1194
|
+
"nothing to open a PR for. Commit work, re-bind, then pr create."
|
|
1195
|
+
)
|
|
1196
|
+
current_head = gitops.current_head_sha(resolved)
|
|
1197
|
+
if current_head != head:
|
|
1198
|
+
raise SystemExit(
|
|
1199
|
+
f"pr create refuses: bound worktree HEAD {current_head} no longer equals the reviewed "
|
|
1200
|
+
f"head {head} recorded in the bind receipt; the worktree moved since bind. Re-bind, "
|
|
1201
|
+
"then pr create."
|
|
1202
|
+
)
|
|
1203
|
+
try:
|
|
1204
|
+
return _push.push_current_branch(resolved, remote=remote, set_upstream=set_upstream)
|
|
1205
|
+
except _push.PushError as exc:
|
|
1206
|
+
raise SystemExit(f"pr create refuses: push from the bound worktree failed ({exc})")
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def enter_lane(args: argparse.Namespace) -> LaneTransitionOutcome:
|
|
1210
|
+
workspace_root = args.workspace_root.resolve()
|
|
1211
|
+
lane_doc = load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1212
|
+
unit_spec = find_unit_spec(workspace_root, args.owner_unit)
|
|
1213
|
+
path = current_lane_file(workspace_root, args.owner_unit)
|
|
1214
|
+
with exclusive_lock(lane_transition_lock_file(workspace_root, args.owner_unit)):
|
|
1215
|
+
previous: list[dict] = []
|
|
1216
|
+
previous_lane: str | None = None
|
|
1217
|
+
if path.exists():
|
|
1218
|
+
old = json.loads(path.read_text())
|
|
1219
|
+
previous = old.get("recent", [])
|
|
1220
|
+
current = old.get("current")
|
|
1221
|
+
if current:
|
|
1222
|
+
previous_lane = current.get("lane_name")
|
|
1223
|
+
previous.insert(0, current)
|
|
1224
|
+
|
|
1225
|
+
deduped: list[dict] = []
|
|
1226
|
+
seen: set[tuple[str, str]] = set()
|
|
1227
|
+
for item in previous:
|
|
1228
|
+
key = (item["owner_unit"], item["lane_name"])
|
|
1229
|
+
if key in seen or key == (args.owner_unit, args.lane_name):
|
|
1230
|
+
continue
|
|
1231
|
+
seen.add(key)
|
|
1232
|
+
deduped.append(item)
|
|
1233
|
+
doc = {
|
|
1234
|
+
"current": {
|
|
1235
|
+
"owner_unit": args.owner_unit,
|
|
1236
|
+
"agent_id": unit_spec.get("agent_id"),
|
|
1237
|
+
"lane_name": args.lane_name,
|
|
1238
|
+
"lane_type": lane_doc["lane_type"],
|
|
1239
|
+
"repos": lane_doc.get("repos", []),
|
|
1240
|
+
"actor": args.actor,
|
|
1241
|
+
"entered_at": now_utc(),
|
|
1242
|
+
},
|
|
1243
|
+
"recent": deduped[:5],
|
|
1244
|
+
}
|
|
1245
|
+
atomic_replace_text(path, json.dumps(doc, indent=2) + "\n")
|
|
1246
|
+
event = {
|
|
1247
|
+
"type": "lane_enter", "agent": args.actor, "agent_id": unit_spec.get("agent_id"),
|
|
1248
|
+
"owner_unit": args.owner_unit, "lane": args.lane_name,
|
|
1249
|
+
"lane_type": lane_doc["lane_type"], "repos": lane_doc.get("repos", []),
|
|
1250
|
+
"timestamp": now_utc(),
|
|
1251
|
+
}
|
|
1252
|
+
emit_lane_event(workspace_root, event)
|
|
1253
|
+
if args.notify_channel:
|
|
1254
|
+
event["channel_message"] = (
|
|
1255
|
+
f"{args.actor} entered {args.owner_unit}/{args.lane_name} "
|
|
1256
|
+
f"[{lane_doc['lane_type']}] repos={','.join(lane_doc.get('repos', []))}"
|
|
1257
|
+
)
|
|
1258
|
+
if args.recall:
|
|
1259
|
+
emit_recall_lane_event(
|
|
1260
|
+
workspace_root,
|
|
1261
|
+
{
|
|
1262
|
+
"kind": "lane_transition",
|
|
1263
|
+
"action": "enter",
|
|
1264
|
+
"owner_unit": args.owner_unit,
|
|
1265
|
+
"agent_id": unit_spec.get("agent_id"),
|
|
1266
|
+
"actor": args.actor,
|
|
1267
|
+
"lane": args.lane_name,
|
|
1268
|
+
"lane_type": lane_doc["lane_type"],
|
|
1269
|
+
"repos": lane_doc.get("repos", []),
|
|
1270
|
+
"timestamp": event["timestamp"],
|
|
1271
|
+
},
|
|
1272
|
+
)
|
|
1273
|
+
return LaneTransitionOutcome("enter", args.owner_unit, previous_lane, args.lane_name, path)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def exit_lane(args: argparse.Namespace) -> LaneTransitionOutcome:
|
|
1277
|
+
workspace_root = args.workspace_root.resolve()
|
|
1278
|
+
path = current_lane_file(workspace_root, args.owner_unit)
|
|
1279
|
+
with exclusive_lock(lane_transition_lock_file(workspace_root, args.owner_unit)):
|
|
1280
|
+
doc = load_current_lane_doc(workspace_root, args.owner_unit)
|
|
1281
|
+
current_doc = doc.get("current")
|
|
1282
|
+
if not current_doc:
|
|
1283
|
+
raise SystemExit(f"no current lane to exit for unit: {args.owner_unit}")
|
|
1284
|
+
event = {
|
|
1285
|
+
"type": "lane_exit", "agent": args.actor, "agent_id": current_doc.get("agent_id"),
|
|
1286
|
+
"owner_unit": args.owner_unit, "lane": current_doc["lane_name"],
|
|
1287
|
+
"lane_type": current_doc["lane_type"], "repos": current_doc.get("repos", []),
|
|
1288
|
+
"timestamp": now_utc(),
|
|
1289
|
+
}
|
|
1290
|
+
recent = doc.get("recent", [])
|
|
1291
|
+
next_current = recent[0] if recent else None
|
|
1292
|
+
updated = {"current": next_current, "recent": recent[1:] if next_current else []}
|
|
1293
|
+
atomic_replace_text(path, json.dumps(updated, indent=2) + "\n")
|
|
1294
|
+
emit_lane_event(workspace_root, event)
|
|
1295
|
+
if args.notify_channel:
|
|
1296
|
+
event["channel_message"] = (
|
|
1297
|
+
f"{args.actor} exited {args.owner_unit}/{current_doc['lane_name']} "
|
|
1298
|
+
f"[{current_doc['lane_type']}]"
|
|
1299
|
+
)
|
|
1300
|
+
if args.recall:
|
|
1301
|
+
emit_recall_lane_event(
|
|
1302
|
+
workspace_root,
|
|
1303
|
+
{
|
|
1304
|
+
"kind": "lane_transition",
|
|
1305
|
+
"action": "exit",
|
|
1306
|
+
"owner_unit": args.owner_unit,
|
|
1307
|
+
"agent_id": current_doc.get("agent_id"),
|
|
1308
|
+
"actor": args.actor,
|
|
1309
|
+
"lane": current_doc["lane_name"],
|
|
1310
|
+
"lane_type": current_doc["lane_type"],
|
|
1311
|
+
"repos": current_doc.get("repos", []),
|
|
1312
|
+
"timestamp": event["timestamp"],
|
|
1313
|
+
},
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
return LaneTransitionOutcome("exit", args.owner_unit, current_doc["lane_name"], next_current.get("lane_name") if next_current else None, path)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def current_lane(args: argparse.Namespace) -> int:
|
|
1320
|
+
doc = load_current_lane_doc(args.workspace_root.resolve(), args.owner_unit)
|
|
1321
|
+
if args.json:
|
|
1322
|
+
print(json.dumps(doc, indent=2))
|
|
1323
|
+
return 0
|
|
1324
|
+
current_doc = doc.get("current")
|
|
1325
|
+
if not current_doc:
|
|
1326
|
+
raise SystemExit(f"no current lane recorded for unit: {args.owner_unit}")
|
|
1327
|
+
print("gr2 prototype current-lane")
|
|
1328
|
+
print(
|
|
1329
|
+
f"owner={current_doc['owner_unit']} lane={current_doc['lane_name']} type={current_doc['lane_type']} actor={current_doc['actor']}"
|
|
1330
|
+
)
|
|
1331
|
+
print(f"entered_at={current_doc['entered_at']}")
|
|
1332
|
+
recent = doc.get("recent", [])
|
|
1333
|
+
if recent:
|
|
1334
|
+
print("recent:")
|
|
1335
|
+
for item in recent:
|
|
1336
|
+
print(f" - {item['owner_unit']}/{item['lane_name']} ({item['lane_type']})")
|
|
1337
|
+
return 0
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def lane_history(args: argparse.Namespace) -> int:
|
|
1341
|
+
read = iter_lane_events(args.workspace_root.resolve())
|
|
1342
|
+
warn_unreadable(read, "the lane event log")
|
|
1343
|
+
rows = [event for event in read.rows if event.get("owner_unit") == args.owner_unit]
|
|
1344
|
+
if args.json:
|
|
1345
|
+
print(json.dumps(rows, indent=2))
|
|
1346
|
+
return 0
|
|
1347
|
+
print("TIMESTAMP\tTYPE\tACTOR\tAGENT_ID\tLANE\tREPOS")
|
|
1348
|
+
for row in rows:
|
|
1349
|
+
print(
|
|
1350
|
+
f"{row.get('timestamp', '-')}\t{row.get('type', '-')}\t{row.get('agent', '-')}\t{row.get('agent_id', '-')}\t{row.get('lane', '-')}\t{','.join(row.get('repos', []))}"
|
|
1351
|
+
)
|
|
1352
|
+
return 0
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def acquire_lane_lease(args: argparse.Namespace) -> int:
|
|
1356
|
+
workspace_root = args.workspace_root.resolve()
|
|
1357
|
+
load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1358
|
+
mutator = lambda leases: _acquire_lane_lease_mutation(leases, args)
|
|
1359
|
+
if args.mode == "edit":
|
|
1360
|
+
result = mutate_workspace_edit_lease(
|
|
1361
|
+
workspace_root,
|
|
1362
|
+
args.owner_unit,
|
|
1363
|
+
args.lane_name,
|
|
1364
|
+
args.actor,
|
|
1365
|
+
mutator,
|
|
1366
|
+
)
|
|
1367
|
+
else:
|
|
1368
|
+
result = mutate_lane_leases(
|
|
1369
|
+
workspace_root,
|
|
1370
|
+
args.owner_unit,
|
|
1371
|
+
args.lane_name,
|
|
1372
|
+
mutator,
|
|
1373
|
+
)
|
|
1374
|
+
if result["status"] == "blocked":
|
|
1375
|
+
print(json.dumps(result["payload"], indent=2))
|
|
1376
|
+
return 1
|
|
1377
|
+
if result["status"] == "warning":
|
|
1378
|
+
print(json.dumps(result["warning"], indent=2))
|
|
1379
|
+
lane_doc = load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1380
|
+
unit_spec = find_unit_spec(workspace_root, args.owner_unit)
|
|
1381
|
+
emit_lane_event(
|
|
1382
|
+
workspace_root,
|
|
1383
|
+
{
|
|
1384
|
+
"type": "lease_acquire",
|
|
1385
|
+
"agent": args.actor,
|
|
1386
|
+
"agent_id": unit_spec.get("agent_id"),
|
|
1387
|
+
"owner_unit": args.owner_unit,
|
|
1388
|
+
"lane": args.lane_name,
|
|
1389
|
+
"lane_type": lane_doc["lane_type"],
|
|
1390
|
+
"lease_mode": args.mode,
|
|
1391
|
+
"ttl_seconds": args.ttl_seconds,
|
|
1392
|
+
"repos": lane_doc.get("repos", []),
|
|
1393
|
+
"timestamp": now_utc(),
|
|
1394
|
+
},
|
|
1395
|
+
)
|
|
1396
|
+
print(lane_leases_file(workspace_root, args.owner_unit, args.lane_name))
|
|
1397
|
+
return 0
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _acquire_lane_lease_mutation(leases: list[dict], args: argparse.Namespace) -> dict:
|
|
1401
|
+
retained = [lease for lease in leases if lease["actor"] != args.actor]
|
|
1402
|
+
active_conflicts, stale_conflicts = conflicting_leases(retained, args.actor, args.mode)
|
|
1403
|
+
|
|
1404
|
+
if active_conflicts:
|
|
1405
|
+
return {
|
|
1406
|
+
"status": "blocked",
|
|
1407
|
+
"payload": {
|
|
1408
|
+
"status": "blocked",
|
|
1409
|
+
"reason": "conflicting-active-lease",
|
|
1410
|
+
"lane": args.lane_name,
|
|
1411
|
+
"owner_unit": args.owner_unit,
|
|
1412
|
+
"requested": {"actor": args.actor, "mode": args.mode},
|
|
1413
|
+
"conflicting_leases": active_conflicts,
|
|
1414
|
+
},
|
|
1415
|
+
"write": False,
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if stale_conflicts and not args.force:
|
|
1419
|
+
return {
|
|
1420
|
+
"status": "blocked",
|
|
1421
|
+
"payload": {
|
|
1422
|
+
"status": "blocked",
|
|
1423
|
+
"reason": "stale-conflicting-lease",
|
|
1424
|
+
"lane": args.lane_name,
|
|
1425
|
+
"owner_unit": args.owner_unit,
|
|
1426
|
+
"requested": {"actor": args.actor, "mode": args.mode},
|
|
1427
|
+
"conflicting_leases": stale_conflicts,
|
|
1428
|
+
"hint": "rerun with --force to break stale conflicting leases",
|
|
1429
|
+
},
|
|
1430
|
+
"write": False,
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
warning = None
|
|
1434
|
+
if stale_conflicts and args.force:
|
|
1435
|
+
warning = {
|
|
1436
|
+
"status": "warning",
|
|
1437
|
+
"reason": "breaking-stale-conflicting-leases",
|
|
1438
|
+
"broken_leases": stale_conflicts,
|
|
1439
|
+
}
|
|
1440
|
+
stale_actors = {lease["actor"] for lease in stale_conflicts}
|
|
1441
|
+
retained = [lease for lease in retained if lease["actor"] not in stale_actors]
|
|
1442
|
+
|
|
1443
|
+
retained.append(build_lease(args.actor, args.mode, args.ttl_seconds))
|
|
1444
|
+
return {
|
|
1445
|
+
"status": "warning" if warning else "ok",
|
|
1446
|
+
"warning": warning,
|
|
1447
|
+
"leases": retained,
|
|
1448
|
+
"write": True,
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def release_lane_lease(args: argparse.Namespace) -> int:
|
|
1453
|
+
workspace_root = args.workspace_root.resolve()
|
|
1454
|
+
lane_doc = load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1455
|
+
unit_spec = find_unit_spec(workspace_root, args.owner_unit)
|
|
1456
|
+
mutate_lane_leases(
|
|
1457
|
+
workspace_root,
|
|
1458
|
+
args.owner_unit,
|
|
1459
|
+
args.lane_name,
|
|
1460
|
+
lambda leases: {
|
|
1461
|
+
"status": "ok",
|
|
1462
|
+
"leases": [lease for lease in leases if lease["actor"] != args.actor],
|
|
1463
|
+
"write": True,
|
|
1464
|
+
},
|
|
1465
|
+
)
|
|
1466
|
+
emit_lane_event(
|
|
1467
|
+
workspace_root,
|
|
1468
|
+
{
|
|
1469
|
+
"type": "lease_release",
|
|
1470
|
+
"agent": args.actor,
|
|
1471
|
+
"agent_id": unit_spec.get("agent_id"),
|
|
1472
|
+
"owner_unit": args.owner_unit,
|
|
1473
|
+
"lane": args.lane_name,
|
|
1474
|
+
"lane_type": lane_doc["lane_type"],
|
|
1475
|
+
"repos": lane_doc.get("repos", []),
|
|
1476
|
+
"timestamp": now_utc(),
|
|
1477
|
+
},
|
|
1478
|
+
)
|
|
1479
|
+
print(lane_leases_file(workspace_root, args.owner_unit, args.lane_name))
|
|
1480
|
+
return 0
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def show_lane_leases(args: argparse.Namespace) -> int:
|
|
1484
|
+
leases = load_lane_leases(args.workspace_root.resolve(), args.owner_unit, args.lane_name)
|
|
1485
|
+
if args.json:
|
|
1486
|
+
print(json.dumps(leases, indent=2))
|
|
1487
|
+
return 0
|
|
1488
|
+
print("ACTOR\tMODE\tTTL\tACQUIRED_AT\tEXPIRES_AT\tSTATE")
|
|
1489
|
+
for lease in leases:
|
|
1490
|
+
state = "stale" if is_stale_lease(lease) else "active"
|
|
1491
|
+
print(
|
|
1492
|
+
f"{lease['actor']}\t{lease['mode']}\t{lease.get('ttl_seconds', '-')}\t{lease['acquired_at']}\t{lease.get('expires_at', '-')}\t{state}"
|
|
1493
|
+
)
|
|
1494
|
+
return 0
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def create_review_lane(args: argparse.Namespace) -> int:
|
|
1498
|
+
lane_name = args.lane_name or f"review-{args.pr_number}"
|
|
1499
|
+
branch = args.branch or f"pr/{args.pr_number}"
|
|
1500
|
+
workspace_root = args.workspace_root.resolve()
|
|
1501
|
+
validate_lane_path_component(args.owner_unit, "owner_unit")
|
|
1502
|
+
validate_lane_path_component(lane_name, "lane_name")
|
|
1503
|
+
lane_path = lane_file(workspace_root, args.owner_unit, lane_name)
|
|
1504
|
+
association = f"{args.repo}#{args.pr_number}"
|
|
1505
|
+
if lane_path.exists():
|
|
1506
|
+
with exclusive_lock(lane_creation_lock_file(workspace_root, args.owner_unit, lane_name)):
|
|
1507
|
+
document = load_lane_doc(workspace_root, args.owner_unit, lane_name)
|
|
1508
|
+
if document.get("lane_type") != "review" or args.repo not in document.get("repos", []):
|
|
1509
|
+
raise SystemExit(
|
|
1510
|
+
f"existing lane is not a review lane for repo {args.repo}: "
|
|
1511
|
+
f"{args.owner_unit}/{lane_name}"
|
|
1512
|
+
)
|
|
1513
|
+
associations = document.setdefault("pr_associations", [])
|
|
1514
|
+
if association not in [item.get("ref") for item in associations]:
|
|
1515
|
+
associations.append({"ref": association})
|
|
1516
|
+
atomic_replace_text(lane_path, serialize_toml(document))
|
|
1517
|
+
print(f"created review lane {args.owner_unit}/{lane_name} for {association}")
|
|
1518
|
+
return 0
|
|
1519
|
+
|
|
1520
|
+
create_args = argparse.Namespace(
|
|
1521
|
+
workspace_root=workspace_root,
|
|
1522
|
+
owner_unit=args.owner_unit,
|
|
1523
|
+
lane_name=lane_name,
|
|
1524
|
+
type="review",
|
|
1525
|
+
repos=args.repo,
|
|
1526
|
+
branch=f"{args.repo}={branch}",
|
|
1527
|
+
source="pull-request",
|
|
1528
|
+
default_commands=[],
|
|
1529
|
+
pr_associations=[association],
|
|
1530
|
+
)
|
|
1531
|
+
create_lane(create_args)
|
|
1532
|
+
print(f"created review lane {args.owner_unit}/{lane_name} for {association}")
|
|
1533
|
+
return 0
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def check_review_requirements(args: argparse.Namespace) -> int:
|
|
1537
|
+
workspace_root = args.workspace_root.resolve()
|
|
1538
|
+
ref = f"{args.repo}#{args.pr_number}"
|
|
1539
|
+
required = int(
|
|
1540
|
+
workspace_constraints(workspace_root).get("required_reviewers", {}).get(args.repo, 0)
|
|
1541
|
+
)
|
|
1542
|
+
matching: list[dict] = []
|
|
1543
|
+
for path in iter_lane_files(workspace_root):
|
|
1544
|
+
doc = tomllib.loads(path.read_text())
|
|
1545
|
+
if doc.get("lane_type") != "review":
|
|
1546
|
+
continue
|
|
1547
|
+
refs = [item["ref"] for item in doc.get("pr_associations", [])]
|
|
1548
|
+
if ref not in refs:
|
|
1549
|
+
continue
|
|
1550
|
+
matching.append(
|
|
1551
|
+
{
|
|
1552
|
+
"owner_unit": doc["owner_unit"],
|
|
1553
|
+
"lane_name": doc["lane_name"],
|
|
1554
|
+
"repos": doc.get("repos", []),
|
|
1555
|
+
}
|
|
1556
|
+
)
|
|
1557
|
+
reviewer_units = sorted({row["owner_unit"] for row in matching})
|
|
1558
|
+
payload = {
|
|
1559
|
+
"repo": args.repo,
|
|
1560
|
+
"pr_number": args.pr_number,
|
|
1561
|
+
"required_reviewers": required,
|
|
1562
|
+
"actual_reviewers": len(reviewer_units),
|
|
1563
|
+
"satisfied": len(reviewer_units) >= required,
|
|
1564
|
+
"review_lanes": matching,
|
|
1565
|
+
}
|
|
1566
|
+
if args.json:
|
|
1567
|
+
print(json.dumps(payload, indent=2))
|
|
1568
|
+
else:
|
|
1569
|
+
print(json.dumps(payload, indent=2))
|
|
1570
|
+
return 0
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def share_lane(args: argparse.Namespace) -> int:
|
|
1574
|
+
workspace_root = args.workspace_root.resolve()
|
|
1575
|
+
load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1576
|
+
find_unit_spec(workspace_root, args.target_unit)
|
|
1577
|
+
access_path = shared_lane_access_file(workspace_root, args.owner_unit, args.lane_name)
|
|
1578
|
+
with exclusive_lock(lane_creation_lock_file(workspace_root, args.owner_unit, args.lane_name)):
|
|
1579
|
+
if access_path.exists():
|
|
1580
|
+
doc = json.loads(access_path.read_text())
|
|
1581
|
+
else:
|
|
1582
|
+
doc = {
|
|
1583
|
+
"owner_unit": args.owner_unit,
|
|
1584
|
+
"lane_name": args.lane_name,
|
|
1585
|
+
"shared_with": [],
|
|
1586
|
+
}
|
|
1587
|
+
if args.target_unit not in doc["shared_with"]:
|
|
1588
|
+
doc["shared_with"].append(args.target_unit)
|
|
1589
|
+
atomic_replace_text(access_path, json.dumps(doc, indent=2) + "\n")
|
|
1590
|
+
print(access_path)
|
|
1591
|
+
return 0
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def create_continuation_lane(args: argparse.Namespace) -> int:
|
|
1595
|
+
workspace_root = args.workspace_root.resolve()
|
|
1596
|
+
validate_lane_path_component(args.target_unit, "target_unit")
|
|
1597
|
+
validate_lane_path_component(args.target_lane_name, "target_lane_name")
|
|
1598
|
+
source = load_lane_doc(workspace_root, args.source_owner_unit, args.source_lane_name)
|
|
1599
|
+
unit_spec = find_unit_spec(workspace_root, args.target_unit)
|
|
1600
|
+
metadata = LaneMetadata(
|
|
1601
|
+
schema_version=LANE_SCHEMA_VERSION,
|
|
1602
|
+
lane_name=args.target_lane_name,
|
|
1603
|
+
owner_unit=args.target_unit,
|
|
1604
|
+
agent_id=unit_spec.get("agent_id"),
|
|
1605
|
+
lane_type=source["lane_type"],
|
|
1606
|
+
repos=source.get("repos", []),
|
|
1607
|
+
branch_map=source.get("branch_map", {}),
|
|
1608
|
+
pr_associations=[item["ref"] for item in source.get("pr_associations", [])],
|
|
1609
|
+
shared_context_roots=source.get("context", {}).get("shared_roots", []),
|
|
1610
|
+
private_context_roots=[
|
|
1611
|
+
f"agents/{args.target_unit}/home/context",
|
|
1612
|
+
str(
|
|
1613
|
+
lane_dir(
|
|
1614
|
+
workspace_root, args.target_unit, args.target_lane_name
|
|
1615
|
+
).relative_to(workspace_root)
|
|
1616
|
+
/ "context"
|
|
1617
|
+
),
|
|
1618
|
+
],
|
|
1619
|
+
exec_defaults=source.get("exec_defaults", {}),
|
|
1620
|
+
creation_source="lane-handoff",
|
|
1621
|
+
shared_with=[],
|
|
1622
|
+
handoff_source={
|
|
1623
|
+
"kind": "continuation",
|
|
1624
|
+
"source_owner_unit": args.source_owner_unit,
|
|
1625
|
+
"source_lane": args.source_lane_name,
|
|
1626
|
+
},
|
|
1627
|
+
)
|
|
1628
|
+
lane_root = lane_dir(workspace_root, args.target_unit, args.target_lane_name)
|
|
1629
|
+
metadata_path = lane_file(workspace_root, args.target_unit, args.target_lane_name)
|
|
1630
|
+
expected = metadata.as_toml()
|
|
1631
|
+
if metadata_path.exists():
|
|
1632
|
+
if metadata_path.read_text() == expected:
|
|
1633
|
+
print(f"lane already exists unchanged: {metadata_path}")
|
|
1634
|
+
return 0
|
|
1635
|
+
raise SystemExit(f"refusing to replace existing lane: {metadata_path}")
|
|
1636
|
+
if lane_root.exists():
|
|
1637
|
+
raise SystemExit(f"refusing to create lane over existing path: {lane_root}")
|
|
1638
|
+
with exclusive_lock(lane_creation_lock_file(workspace_root, args.target_unit, args.target_lane_name)):
|
|
1639
|
+
if metadata_path.exists():
|
|
1640
|
+
if metadata_path.read_text() == expected:
|
|
1641
|
+
print(f"lane already exists unchanged: {metadata_path}")
|
|
1642
|
+
return 0
|
|
1643
|
+
raise SystemExit(f"refusing to replace existing lane: {metadata_path}")
|
|
1644
|
+
if lane_root.exists():
|
|
1645
|
+
raise SystemExit(f"refusing to create lane over existing path: {lane_root}")
|
|
1646
|
+
lane_root.mkdir(parents=True)
|
|
1647
|
+
(lane_root / "repos").mkdir()
|
|
1648
|
+
(lane_root / "context").mkdir()
|
|
1649
|
+
atomic_replace_text(metadata_path, expected)
|
|
1650
|
+
print(metadata_path)
|
|
1651
|
+
return 0
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def plan_handoff(args: argparse.Namespace) -> int:
|
|
1655
|
+
workspace_root = args.workspace_root.resolve()
|
|
1656
|
+
source = load_lane_doc(workspace_root, args.source_owner_unit, args.source_lane_name)
|
|
1657
|
+
find_unit_spec(workspace_root, args.target_unit)
|
|
1658
|
+
if args.mode == "shared":
|
|
1659
|
+
access_path = shared_lane_access_file(
|
|
1660
|
+
workspace_root, args.source_owner_unit, args.source_lane_name
|
|
1661
|
+
)
|
|
1662
|
+
access = json.loads(access_path.read_text()) if access_path.exists() else None
|
|
1663
|
+
payload = {
|
|
1664
|
+
"mode": "shared",
|
|
1665
|
+
"source_owner_unit": args.source_owner_unit,
|
|
1666
|
+
"source_lane_name": args.source_lane_name,
|
|
1667
|
+
"target_unit": args.target_unit,
|
|
1668
|
+
"shared_access_present": bool(
|
|
1669
|
+
access and args.target_unit in access.get("shared_with", [])
|
|
1670
|
+
),
|
|
1671
|
+
"exec_rows": [
|
|
1672
|
+
{
|
|
1673
|
+
"acting_unit": args.target_unit,
|
|
1674
|
+
"owner_unit": args.source_owner_unit,
|
|
1675
|
+
"lane_name": args.source_lane_name,
|
|
1676
|
+
"repo": repo,
|
|
1677
|
+
"cwd": str(
|
|
1678
|
+
lane_dir(
|
|
1679
|
+
workspace_root,
|
|
1680
|
+
args.source_owner_unit,
|
|
1681
|
+
args.source_lane_name,
|
|
1682
|
+
)
|
|
1683
|
+
/ "repos"
|
|
1684
|
+
/ repo
|
|
1685
|
+
),
|
|
1686
|
+
"lease_scope": f"{args.source_owner_unit}/{args.source_lane_name}",
|
|
1687
|
+
}
|
|
1688
|
+
for repo in source.get("repos", [])
|
|
1689
|
+
],
|
|
1690
|
+
"invariant_assessment": {
|
|
1691
|
+
"unit_scoped": False,
|
|
1692
|
+
"reason": "target unit must execute inside another unit's lane root and lease scope",
|
|
1693
|
+
},
|
|
1694
|
+
}
|
|
1695
|
+
else:
|
|
1696
|
+
target_lane_name = args.target_lane_name or f"{args.source_lane_name}-relay"
|
|
1697
|
+
payload = {
|
|
1698
|
+
"mode": "continuation",
|
|
1699
|
+
"source_owner_unit": args.source_owner_unit,
|
|
1700
|
+
"source_lane_name": args.source_lane_name,
|
|
1701
|
+
"target_unit": args.target_unit,
|
|
1702
|
+
"target_lane_name": target_lane_name,
|
|
1703
|
+
"exec_rows": [
|
|
1704
|
+
{
|
|
1705
|
+
"acting_unit": args.target_unit,
|
|
1706
|
+
"owner_unit": args.target_unit,
|
|
1707
|
+
"lane_name": target_lane_name,
|
|
1708
|
+
"repo": repo,
|
|
1709
|
+
"cwd": str(
|
|
1710
|
+
lane_dir(
|
|
1711
|
+
workspace_root,
|
|
1712
|
+
args.target_unit,
|
|
1713
|
+
target_lane_name,
|
|
1714
|
+
)
|
|
1715
|
+
/ "repos"
|
|
1716
|
+
/ repo
|
|
1717
|
+
),
|
|
1718
|
+
"lease_scope": f"{args.target_unit}/{target_lane_name}",
|
|
1719
|
+
}
|
|
1720
|
+
for repo in source.get("repos", [])
|
|
1721
|
+
],
|
|
1722
|
+
"handoff_source": {
|
|
1723
|
+
"source_owner_unit": args.source_owner_unit,
|
|
1724
|
+
"source_lane_name": args.source_lane_name,
|
|
1725
|
+
},
|
|
1726
|
+
"invariant_assessment": {
|
|
1727
|
+
"unit_scoped": True,
|
|
1728
|
+
"reason": "target unit gets its own lane root, lease scope, and current-lane state while keeping source linkage",
|
|
1729
|
+
},
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if args.json:
|
|
1733
|
+
print(json.dumps(payload, indent=2))
|
|
1734
|
+
else:
|
|
1735
|
+
print(json.dumps(payload, indent=2))
|
|
1736
|
+
return 0
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def create_shared_scratchpad(args: argparse.Namespace) -> int:
|
|
1740
|
+
workspace_root = args.workspace_root.resolve()
|
|
1741
|
+
validate_lane_path_component(args.name, "name")
|
|
1742
|
+
scratchpad = SharedScratchpad(
|
|
1743
|
+
schema_version=SCRATCHPAD_SCHEMA_VERSION,
|
|
1744
|
+
name=args.name,
|
|
1745
|
+
kind=args.kind,
|
|
1746
|
+
purpose=args.purpose,
|
|
1747
|
+
participants=sorted(set(args.participant)),
|
|
1748
|
+
linked_refs=args.ref,
|
|
1749
|
+
lifecycle="draft",
|
|
1750
|
+
creation_source=args.source,
|
|
1751
|
+
docs_root=f"shared/scratchpads/{args.name}/docs",
|
|
1752
|
+
notes_root=f"shared/scratchpads/{args.name}/notes",
|
|
1753
|
+
context_root=f"shared/scratchpads/{args.name}/context",
|
|
1754
|
+
created_at=now_utc(),
|
|
1755
|
+
updated_at=now_utc(),
|
|
1756
|
+
)
|
|
1757
|
+
root = shared_scratchpad_dir(workspace_root, args.name)
|
|
1758
|
+
metadata_path = shared_scratchpad_file(workspace_root, args.name)
|
|
1759
|
+
expected = scratchpad.as_toml()
|
|
1760
|
+
if metadata_path.exists():
|
|
1761
|
+
if metadata_path.read_text() == expected:
|
|
1762
|
+
print(f"scratchpad already exists unchanged: {metadata_path}")
|
|
1763
|
+
return 0
|
|
1764
|
+
raise SystemExit(f"refusing to replace existing scratchpad: {metadata_path}")
|
|
1765
|
+
if root.exists():
|
|
1766
|
+
raise SystemExit(f"refusing to create scratchpad over existing path: {root}")
|
|
1767
|
+
with exclusive_lock(scratchpad_creation_lock_file(workspace_root, args.name)):
|
|
1768
|
+
if metadata_path.exists():
|
|
1769
|
+
if metadata_path.read_text() == expected:
|
|
1770
|
+
print(f"scratchpad already exists unchanged: {metadata_path}")
|
|
1771
|
+
return 0
|
|
1772
|
+
raise SystemExit(f"refusing to replace existing scratchpad: {metadata_path}")
|
|
1773
|
+
if root.exists():
|
|
1774
|
+
raise SystemExit(f"refusing to create scratchpad over existing path: {root}")
|
|
1775
|
+
root.mkdir(parents=True)
|
|
1776
|
+
(root / "docs").mkdir()
|
|
1777
|
+
(root / "notes").mkdir()
|
|
1778
|
+
(root / "context").mkdir()
|
|
1779
|
+
atomic_replace_text(metadata_path, expected)
|
|
1780
|
+
atomic_replace_text(root / "docs" / "README.md",
|
|
1781
|
+
f"# {args.name}\n\nPurpose: {args.purpose}\n\nParticipants: "
|
|
1782
|
+
+ (", ".join(scratchpad.participants) if scratchpad.participants else "unassigned")
|
|
1783
|
+
+ "\n"
|
|
1784
|
+
)
|
|
1785
|
+
print(metadata_path)
|
|
1786
|
+
return 0
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def list_lanes(args: argparse.Namespace) -> int:
|
|
1790
|
+
workspace_root = args.workspace_root.resolve()
|
|
1791
|
+
print("OWNER\tLANE\tTYPE\tREPOS\tPRS")
|
|
1792
|
+
for path in iter_lane_files(workspace_root, args.owner_unit):
|
|
1793
|
+
doc = tomllib.loads(path.read_text())
|
|
1794
|
+
refs = ",".join(item["ref"] for item in doc.get("pr_associations", [])) or "-"
|
|
1795
|
+
print(
|
|
1796
|
+
f"{doc['owner_unit']}\t{doc['lane_name']}\t{doc['lane_type']}\t{len(doc.get('repos', []))}\t{refs}"
|
|
1797
|
+
)
|
|
1798
|
+
return 0
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def show_lane(args: argparse.Namespace) -> int:
|
|
1802
|
+
print(lane_file(args.workspace_root.resolve(), args.owner_unit, args.lane_name).read_text())
|
|
1803
|
+
return 0
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def show_shared_scratchpad(args: argparse.Namespace) -> int:
|
|
1807
|
+
print(shared_scratchpad_file(args.workspace_root.resolve(), args.name).read_text())
|
|
1808
|
+
return 0
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def list_shared_scratchpads(args: argparse.Namespace) -> int:
|
|
1812
|
+
workspace_root = args.workspace_root.resolve()
|
|
1813
|
+
print("NAME\tKIND\tLIFECYCLE\tAGE_DAYS\tPARTICIPANTS\tPURPOSE")
|
|
1814
|
+
for path in iter_shared_scratchpad_files(workspace_root):
|
|
1815
|
+
doc = tomllib.loads(path.read_text())
|
|
1816
|
+
participants = ",".join(doc.get("participants", [])) or "-"
|
|
1817
|
+
print(
|
|
1818
|
+
f"{doc['name']}\t{doc['kind']}\t{doc['lifecycle']}\t{age_days(path)}\t{participants}\t{doc['purpose']}"
|
|
1819
|
+
)
|
|
1820
|
+
return 0
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def audit_shared_scratchpads(args: argparse.Namespace) -> int:
|
|
1824
|
+
workspace_root = args.workspace_root.resolve()
|
|
1825
|
+
print("NAME\tSTATUS\tAGE_DAYS\tISSUES")
|
|
1826
|
+
for path in iter_shared_scratchpad_files(workspace_root):
|
|
1827
|
+
doc = tomllib.loads(path.read_text())
|
|
1828
|
+
root = path.parent
|
|
1829
|
+
issues: list[str] = []
|
|
1830
|
+
days = age_days(path)
|
|
1831
|
+
docs_root = root / "docs"
|
|
1832
|
+
notes_root = root / "notes"
|
|
1833
|
+
context_root = root / "context"
|
|
1834
|
+
|
|
1835
|
+
if days >= args.stale_days and doc.get("lifecycle") not in {"done", "paused"}:
|
|
1836
|
+
issues.append("stale-active")
|
|
1837
|
+
if not doc.get("participants"):
|
|
1838
|
+
issues.append("no-participants")
|
|
1839
|
+
if not doc.get("linked_refs"):
|
|
1840
|
+
issues.append("no-refs")
|
|
1841
|
+
if not docs_root.exists():
|
|
1842
|
+
issues.append("missing-docs-root")
|
|
1843
|
+
if not notes_root.exists():
|
|
1844
|
+
issues.append("missing-notes-root")
|
|
1845
|
+
if not context_root.exists():
|
|
1846
|
+
issues.append("missing-context-root")
|
|
1847
|
+
if doc.get("kind") == "doc" and docs_root.exists() and not any(docs_root.iterdir()):
|
|
1848
|
+
issues.append("empty-docs")
|
|
1849
|
+
|
|
1850
|
+
status = "ok" if not issues else "needs-attention"
|
|
1851
|
+
print(f"{doc['name']}\t{status}\t{days}\t{','.join(issues) or '-'}")
|
|
1852
|
+
return 0
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def plan_promote_scratchpad(args: argparse.Namespace) -> int:
|
|
1856
|
+
workspace_root = args.workspace_root.resolve()
|
|
1857
|
+
doc = load_shared_scratchpad_doc(workspace_root, args.name)
|
|
1858
|
+
lane_name = args.lane or f"promote-{args.name}"
|
|
1859
|
+
print("gr2 prototype scratchpad-promotion plan")
|
|
1860
|
+
print(f"scratchpad: {doc['name']}")
|
|
1861
|
+
print(f"kind: {doc['kind']}")
|
|
1862
|
+
print(f"lifecycle: {doc['lifecycle']}")
|
|
1863
|
+
print(f"target repo: {args.target_repo}")
|
|
1864
|
+
print(f"target path: {args.target_path}")
|
|
1865
|
+
print(f"owner unit: {args.owner_unit}")
|
|
1866
|
+
print(f"suggested lane: {lane_name}")
|
|
1867
|
+
print("recommended:")
|
|
1868
|
+
print(f" 1. create or reuse a feature lane for {args.target_repo} under {args.owner_unit}")
|
|
1869
|
+
print(
|
|
1870
|
+
f" 2. copy content from shared/scratchpads/{doc['name']}/docs into {args.target_repo}:{args.target_path}"
|
|
1871
|
+
)
|
|
1872
|
+
print(f" 3. branch and commit in lane {lane_name}")
|
|
1873
|
+
print(" 4. open a PR once the artifact is ready for formal review")
|
|
1874
|
+
if not doc.get("linked_refs"):
|
|
1875
|
+
print(
|
|
1876
|
+
"warning: scratchpad has no linked refs; traceability should be added before promotion"
|
|
1877
|
+
)
|
|
1878
|
+
return 0
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def recommend_surface(args: argparse.Namespace) -> int:
|
|
1882
|
+
recommendation = "feature-lane"
|
|
1883
|
+
rationale: list[str] = []
|
|
1884
|
+
|
|
1885
|
+
if args.kind == "review" or args.formal_review:
|
|
1886
|
+
recommendation = "review-lane"
|
|
1887
|
+
rationale.append("formal review or PR inspection should stay isolated")
|
|
1888
|
+
elif args.kind in {"doc", "planning"} and args.collaborative:
|
|
1889
|
+
recommendation = "shared-scratchpad"
|
|
1890
|
+
rationale.append("shared drafting is lighter than a PR and should not invade private lanes")
|
|
1891
|
+
elif args.shared_draft:
|
|
1892
|
+
recommendation = "shared-scratchpad"
|
|
1893
|
+
rationale.append("explicit shared draft requested")
|
|
1894
|
+
elif args.kind == "code" and args.repos > 1:
|
|
1895
|
+
recommendation = "feature-lane"
|
|
1896
|
+
rationale.append("cross-repo implementation needs one named task context")
|
|
1897
|
+
elif args.kind == "code":
|
|
1898
|
+
recommendation = "feature-lane"
|
|
1899
|
+
rationale.append("private implementation should start in an isolated lane")
|
|
1900
|
+
else:
|
|
1901
|
+
recommendation = "feature-lane"
|
|
1902
|
+
rationale.append("default safe choice is an isolated lane")
|
|
1903
|
+
|
|
1904
|
+
print("gr2 prototype surface recommendation")
|
|
1905
|
+
print(f"recommended: {recommendation}")
|
|
1906
|
+
print(f"why: {'; '.join(rationale)}")
|
|
1907
|
+
print("rules:")
|
|
1908
|
+
print(" - use a review lane for formal PR inspection")
|
|
1909
|
+
print(" - use a shared scratchpad for collaborative drafting")
|
|
1910
|
+
print(" - use a feature lane for implementation work")
|
|
1911
|
+
return 0
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def next_step(args: argparse.Namespace) -> int:
|
|
1915
|
+
workspace_root = args.workspace_root.resolve()
|
|
1916
|
+
lane_doc = load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1917
|
+
print("gr2 prototype next-step")
|
|
1918
|
+
print(f"lane: {args.owner_unit}/{lane_doc['lane_name']}")
|
|
1919
|
+
print(f"type: {lane_doc['lane_type']}")
|
|
1920
|
+
print(f"repos: {', '.join(lane_doc['repos'])}")
|
|
1921
|
+
if lane_doc.get("pr_associations"):
|
|
1922
|
+
print("mode: review")
|
|
1923
|
+
print("recommended:")
|
|
1924
|
+
print(
|
|
1925
|
+
f" python3 gr2/prototypes/lane_workspace_prototype.py plan-exec {workspace_root} {args.owner_unit} {args.lane_name} 'cargo test'"
|
|
1926
|
+
)
|
|
1927
|
+
print(" inspect the review lane, then return to your feature or home lane")
|
|
1928
|
+
elif lane_doc["lane_type"] == "feature":
|
|
1929
|
+
print("mode: feature")
|
|
1930
|
+
print("recommended:")
|
|
1931
|
+
print(
|
|
1932
|
+
f" python3 gr2/prototypes/lane_workspace_prototype.py plan-exec {workspace_root} {args.owner_unit} {args.lane_name} 'cargo test'"
|
|
1933
|
+
)
|
|
1934
|
+
print(
|
|
1935
|
+
f" python3 gr2/prototypes/lane_workspace_prototype.py list-shared-scratchpads {workspace_root}"
|
|
1936
|
+
)
|
|
1937
|
+
else:
|
|
1938
|
+
print("mode: general")
|
|
1939
|
+
print("recommended:")
|
|
1940
|
+
print(
|
|
1941
|
+
f" python3 gr2/prototypes/lane_workspace_prototype.py show-lane {workspace_root} {args.owner_unit} {args.lane_name}"
|
|
1942
|
+
)
|
|
1943
|
+
return 0
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def plan_exec(args: argparse.Namespace) -> int:
|
|
1947
|
+
workspace_root = args.workspace_root.resolve()
|
|
1948
|
+
lane_doc = load_lane_doc(workspace_root, args.owner_unit, args.lane_name)
|
|
1949
|
+
leases = load_lane_leases(workspace_root, args.owner_unit, args.lane_name)
|
|
1950
|
+
active_conflicts, stale_conflicts = conflicting_leases(leases, "agent:exec-planner", "exec")
|
|
1951
|
+
if active_conflicts:
|
|
1952
|
+
payload = {
|
|
1953
|
+
"status": "blocked",
|
|
1954
|
+
"reason": "conflicting-active-lease",
|
|
1955
|
+
"lane": lane_doc["lane_name"],
|
|
1956
|
+
"owner_unit": lane_doc["owner_unit"],
|
|
1957
|
+
"requested_mode": "exec",
|
|
1958
|
+
"conflicting_leases": active_conflicts,
|
|
1959
|
+
}
|
|
1960
|
+
if args.json:
|
|
1961
|
+
print(json.dumps(payload, indent=2))
|
|
1962
|
+
else:
|
|
1963
|
+
print("gr2 lane-exec prototype")
|
|
1964
|
+
print("status=blocked reason=conflicting-active-lease")
|
|
1965
|
+
for lease in active_conflicts:
|
|
1966
|
+
print(
|
|
1967
|
+
f"conflict: actor={lease['actor']} mode={lease['mode']} acquired_at={lease['acquired_at']}"
|
|
1968
|
+
)
|
|
1969
|
+
return 1
|
|
1970
|
+
if stale_conflicts:
|
|
1971
|
+
payload = {
|
|
1972
|
+
"status": "blocked",
|
|
1973
|
+
"reason": "stale-conflicting-lease",
|
|
1974
|
+
"lane": lane_doc["lane_name"],
|
|
1975
|
+
"owner_unit": lane_doc["owner_unit"],
|
|
1976
|
+
"requested_mode": "exec",
|
|
1977
|
+
"conflicting_leases": stale_conflicts,
|
|
1978
|
+
"hint": "break stale leases with acquire-lane-lease --force or clean them up first",
|
|
1979
|
+
}
|
|
1980
|
+
if args.json:
|
|
1981
|
+
print(json.dumps(payload, indent=2))
|
|
1982
|
+
else:
|
|
1983
|
+
print("gr2 lane-exec prototype")
|
|
1984
|
+
print("status=blocked reason=stale-conflicting-lease")
|
|
1985
|
+
for lease in stale_conflicts:
|
|
1986
|
+
print(
|
|
1987
|
+
f"stale-conflict: actor={lease['actor']} mode={lease['mode']} expires_at={lease.get('expires_at', '-')}"
|
|
1988
|
+
)
|
|
1989
|
+
return 1
|
|
1990
|
+
|
|
1991
|
+
selected_repos = lane_doc["repos"]
|
|
1992
|
+
if args.repos:
|
|
1993
|
+
requested = parse_repo_list(args.repos)
|
|
1994
|
+
selected_repos = [repo for repo in selected_repos if repo in requested]
|
|
1995
|
+
|
|
1996
|
+
command_argv = shlex.split(args.command_text)
|
|
1997
|
+
rows = []
|
|
1998
|
+
for repo in selected_repos:
|
|
1999
|
+
rows.append(
|
|
2000
|
+
{
|
|
2001
|
+
"lane": lane_doc["lane_name"],
|
|
2002
|
+
"owner_unit": lane_doc["owner_unit"],
|
|
2003
|
+
"repo": repo,
|
|
2004
|
+
"branch": lane_doc["branch_map"].get(repo),
|
|
2005
|
+
"cwd": str(
|
|
2006
|
+
lane_dir(workspace_root, args.owner_unit, args.lane_name)
|
|
2007
|
+
/ "repos"
|
|
2008
|
+
/ repo
|
|
2009
|
+
),
|
|
2010
|
+
"command": command_argv,
|
|
2011
|
+
"shared_context_roots": lane_doc.get("context", {}).get("shared_roots", []),
|
|
2012
|
+
"private_context_roots": lane_doc.get("context", {}).get("private_roots", []),
|
|
2013
|
+
"fail_fast": lane_doc["exec_defaults"]["fail_fast"],
|
|
2014
|
+
}
|
|
2015
|
+
)
|
|
2016
|
+
|
|
2017
|
+
if args.json:
|
|
2018
|
+
print(json.dumps(rows, indent=2))
|
|
2019
|
+
else:
|
|
2020
|
+
print("gr2 lane-exec prototype")
|
|
2021
|
+
print(
|
|
2022
|
+
f"owner={lane_doc['owner_unit']} lane={lane_doc['lane_name']} type={lane_doc['lane_type']} fail_fast={lane_doc['exec_defaults']['fail_fast']}"
|
|
2023
|
+
)
|
|
2024
|
+
print("LANE\tREPO\tBRANCH\tCWD\tCOMMAND")
|
|
2025
|
+
for row in rows:
|
|
2026
|
+
print(
|
|
2027
|
+
f"{row['lane']}\t{row['repo']}\t{row['branch']}\t{row['cwd']}\t{' '.join(row['command'])}"
|
|
2028
|
+
)
|
|
2029
|
+
return 0
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def main() -> int:
|
|
2033
|
+
args = parse_args()
|
|
2034
|
+
if args.command == "create-lane":
|
|
2035
|
+
return create_lane(args)
|
|
2036
|
+
if args.command == "enter-lane":
|
|
2037
|
+
outcome = enter_lane(args)
|
|
2038
|
+
print(json.dumps(outcome.as_dict(), indent=2))
|
|
2039
|
+
return outcome.exit_code
|
|
2040
|
+
if args.command == "exit-lane":
|
|
2041
|
+
outcome = exit_lane(args)
|
|
2042
|
+
print(json.dumps(outcome.as_dict(), indent=2))
|
|
2043
|
+
return outcome.exit_code
|
|
2044
|
+
if args.command == "current-lane":
|
|
2045
|
+
return current_lane(args)
|
|
2046
|
+
if args.command == "lane-history":
|
|
2047
|
+
return lane_history(args)
|
|
2048
|
+
if args.command == "create-review-lane":
|
|
2049
|
+
return create_review_lane(args)
|
|
2050
|
+
if args.command == "share-lane":
|
|
2051
|
+
return share_lane(args)
|
|
2052
|
+
if args.command == "create-continuation-lane":
|
|
2053
|
+
return create_continuation_lane(args)
|
|
2054
|
+
if args.command == "plan-handoff":
|
|
2055
|
+
return plan_handoff(args)
|
|
2056
|
+
if args.command == "show-lane":
|
|
2057
|
+
return show_lane(args)
|
|
2058
|
+
if args.command == "list-lanes":
|
|
2059
|
+
return list_lanes(args)
|
|
2060
|
+
if args.command == "next-step":
|
|
2061
|
+
return next_step(args)
|
|
2062
|
+
if args.command == "plan-exec":
|
|
2063
|
+
return plan_exec(args)
|
|
2064
|
+
if args.command == "acquire-lane-lease":
|
|
2065
|
+
return acquire_lane_lease(args)
|
|
2066
|
+
if args.command == "release-lane-lease":
|
|
2067
|
+
return release_lane_lease(args)
|
|
2068
|
+
if args.command == "show-lane-leases":
|
|
2069
|
+
return show_lane_leases(args)
|
|
2070
|
+
if args.command == "create-shared-scratchpad":
|
|
2071
|
+
return create_shared_scratchpad(args)
|
|
2072
|
+
if args.command == "show-shared-scratchpad":
|
|
2073
|
+
return show_shared_scratchpad(args)
|
|
2074
|
+
if args.command == "list-shared-scratchpads":
|
|
2075
|
+
return list_shared_scratchpads(args)
|
|
2076
|
+
if args.command == "audit-shared-scratchpads":
|
|
2077
|
+
return audit_shared_scratchpads(args)
|
|
2078
|
+
if args.command == "plan-promote-scratchpad":
|
|
2079
|
+
return plan_promote_scratchpad(args)
|
|
2080
|
+
if args.command == "recommend-surface":
|
|
2081
|
+
return recommend_surface(args)
|
|
2082
|
+
if args.command == "check-review-requirements":
|
|
2083
|
+
return check_review_requirements(args)
|
|
2084
|
+
raise SystemExit(f"unknown command: {args.command}")
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
if __name__ == "__main__":
|
|
2088
|
+
sys.exit(main())
|