@caddis/cli 0.4.70 → 0.4.71

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.
@@ -0,0 +1,1083 @@
1
+ """Deterministic coordinator core for the caddis hub (spec sections 7, 7a, 8).
2
+
3
+ Why this exists: one coordinator per repository applies owner requests (approve, hold, merge) to a
4
+ queue. A model must never write the queue files by hand, so every read and write goes through
5
+ this script. It needs one stable key per repository, so that a clone, a fork and a worktree of
6
+ the same project share one hub folder: that key is the repo id.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import copy
13
+ import datetime
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+
23
+ try:
24
+ from caddis_file_lock import atomic_write, locked_path, retry_denied
25
+ except ModuleNotFoundError as exc:
26
+ if exc.name != "caddis_file_lock":
27
+ raise
28
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
29
+ from caddis_file_lock import atomic_write, locked_path, retry_denied
30
+
31
+
32
+ _TIMEOUT = 10
33
+ _SHA = re.compile(r"[0-9a-fA-F]{40}") # a full git commit SHA
34
+ SCHEMA = 1
35
+ _READ_SCHEMAS = (SCHEMA - 1, SCHEMA) # readers accept N and N-1 (spec section 9)
36
+ _STATE_CACHE: dict[Path, tuple[tuple[int, int], dict]] = {} # path -> ((mtime_ns, size), last good parse)
37
+ _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
38
+ # https://host/owner/name(.git), ssh://user@host(:port)/owner/name(.git), user@host:owner/name(.git)
39
+ _URL = re.compile(
40
+ r"^(?:[a-z][a-z0-9+.-]*://(?:[^@/]+@)?[^/]+/|[^@/:]+@[^:/]+:)"
41
+ r"(?P<owner>[^/]+)/(?P<name>[^/]+?)(?:\.git)?/?$",
42
+ re.IGNORECASE,
43
+ )
44
+
45
+
46
+ def _run(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
47
+ # utf-8 explicitly: the Windows default code page garbles non-ASCII paths and titles.
48
+ return subprocess.run(args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace",
49
+ timeout=_TIMEOUT, creationflags=_NO_WINDOW)
50
+
51
+
52
+ def repo_id(remote_name_with_owner: str | None, is_fork_of: str | None, git_root: Path) -> str:
53
+ """Return the hub key: `owner__name` (upstream for a fork), else `local__<folder>__<hash6>`."""
54
+ upstream = is_fork_of or remote_name_with_owner
55
+ if upstream:
56
+ return upstream.strip().lower().replace("/", "__")
57
+ digest = hashlib.sha256(os.path.normcase(str(git_root)).encode("utf-8")).hexdigest()[:6]
58
+ return f"local__{git_root.name}__{digest}"
59
+
60
+
61
+ def _owner_name_from_url(url: str) -> str | None:
62
+ """Return `owner/name` from an https, ssh or scp-style git remote URL, else None."""
63
+ match = _URL.match(url.strip())
64
+ if not match:
65
+ return None
66
+ return f"{match['owner']}/{match['name']}"
67
+
68
+
69
+ def _gh_name_with_owner(path: Path) -> tuple[str | None, str | None]:
70
+ """Return (nameWithOwner, parent nameWithOwner or None) from gh; (None, None) on any failure."""
71
+ try:
72
+ result = _run(["gh", "repo", "view", "--json", "nameWithOwner,parent"], cwd=path)
73
+ if result.returncode != 0:
74
+ return None, None
75
+ data = json.loads(result.stdout)
76
+ name = data.get("nameWithOwner") or None
77
+ if name is None:
78
+ return None, None
79
+ parent = data.get("parent") or {}
80
+ return name, parent.get("nameWithOwner") or None
81
+ except (OSError, subprocess.SubprocessError, ValueError, AttributeError):
82
+ return None, None
83
+
84
+
85
+ def repo_id_for(path: Path) -> str:
86
+ """Return the repo id for a checkout or any of its worktrees."""
87
+ common = _run(["git", "-C", str(path), "rev-parse", "--path-format=absolute", "--git-common-dir"])
88
+ if common.returncode != 0:
89
+ raise ValueError(f"not a git repository: {path}")
90
+ git_root = Path(common.stdout.strip()).parent
91
+ name, parent = _gh_name_with_owner(git_root)
92
+ if name is None:
93
+ origin = _run(["git", "-C", str(git_root), "remote", "get-url", "origin"])
94
+ if origin.returncode == 0:
95
+ name = _owner_name_from_url(origin.stdout)
96
+ return repo_id(name, parent, git_root)
97
+
98
+
99
+ # --- state file and single-coordinator lock (C1.2) ---------------------------------------------------------------
100
+
101
+
102
+ def _now() -> str:
103
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
104
+
105
+
106
+ def hub_home() -> Path:
107
+ """Return `$CADDIS_HUB_HOME`, else `~/.caddis/hub`."""
108
+ override = os.environ.get("CADDIS_HUB_HOME")
109
+ return Path(override) if override else Path.home() / ".caddis" / "hub"
110
+
111
+
112
+ _RESERVED = frozenset(["con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)),
113
+ *(f"lpt{i}" for i in range(1, 10))]) # Windows device names, also with any extension
114
+
115
+
116
+ def _repo_dir(rid: str) -> Path:
117
+ # The repo id names a folder: refuse anything that could leave `<hub>/repos`, or that Windows would not
118
+ # store as given (a trailing dot or space is dropped; a device name opens the device, not a folder).
119
+ if (not rid or rid == "." or ".." in rid or any(c in rid for c in "/\\:") or rid[-1] in ". "
120
+ or rid.split(".", 1)[0].lower() in _RESERVED):
121
+ raise ValueError(f"bad repo id: {rid!r}")
122
+ return hub_home() / "repos" / rid
123
+
124
+
125
+ def state_path(rid: str) -> Path:
126
+ return _repo_dir(rid) / "coordinator.json"
127
+
128
+
129
+ def _lock_path(rid: str) -> Path:
130
+ return _repo_dir(rid) / "coordinator.lock.json"
131
+
132
+
133
+ def read_state(rid: str) -> dict | None:
134
+ """Return the last good parse of `coordinator.json`; None when it is missing or was never good.
135
+
136
+ A transient Windows sharing error is retried first (`retry_denied`). A partial file (a writer died mid-write)
137
+ or a sharing error that remains returns the cached last good parse, never an exception. The cache is keyed by
138
+ path and checked against the file's mtime and size, and lives in this process only: a one-shot process (each
139
+ CLI call) has no last good parse and gets None, so the hub must keep its own last good state.
140
+ """
141
+ path = state_path(rid)
142
+ cached = _STATE_CACHE.get(path)
143
+ try:
144
+ info = retry_denied(path.stat)
145
+ except FileNotFoundError:
146
+ return None
147
+ except OSError:
148
+ return copy.deepcopy(cached[1]) if cached else None
149
+ key = (info.st_mtime_ns, info.st_size)
150
+ if cached and cached[0] == key:
151
+ return copy.deepcopy(cached[1])
152
+ try:
153
+ data = json.loads(retry_denied(path.read_bytes).decode("utf-8"))
154
+ if not isinstance(data, dict) or data.get("schema") not in _READ_SCHEMAS:
155
+ raise ValueError("not a coordinator state")
156
+ except FileNotFoundError:
157
+ return None
158
+ except (OSError, ValueError): # sharing error, partial file, bad UTF-8 (UnicodeDecodeError is a ValueError)
159
+ return copy.deepcopy(cached[1]) if cached else None
160
+ _STATE_CACHE[path] = (key, data)
161
+ return copy.deepcopy(data)
162
+
163
+
164
+ def _read_state_strict(path: Path) -> dict:
165
+ """Read the state file now, for read/modify/write under its lock: no cache, raises on any failure.
166
+
167
+ `read_state` falls back to the last good parse, which is right for display but would write an old snapshot
168
+ over newer state here (a lost update).
169
+ """
170
+ data = json.loads(retry_denied(path.read_bytes).decode("utf-8"))
171
+ if not isinstance(data, dict) or data.get("schema") not in _READ_SCHEMAS:
172
+ raise ValueError(f"not a coordinator state: {path}")
173
+ return data
174
+
175
+
176
+ def _write_state_unlocked(path: Path, state: dict) -> None:
177
+ atomic_write(path, json.dumps(state, indent=2) + "\n")
178
+
179
+
180
+ def write_state(rid: str, state: dict) -> None:
181
+ """Validate `schema == 1`, stamp `heartbeat_at`, and replace `coordinator.json` atomically under its lock."""
182
+ if not isinstance(state, dict) or state.get("schema") != SCHEMA:
183
+ raise ValueError(f"coordinator state must have schema {SCHEMA}")
184
+ state = {**state, "heartbeat_at": _now()}
185
+ with locked_path(state_path(rid)) as path:
186
+ _write_state_unlocked(path, state)
187
+
188
+
189
+ def heartbeat(rid: str) -> None:
190
+ """Rewrite `heartbeat_at` only. Raises (OSError, ValueError) when the file cannot be read and parsed now."""
191
+ with locked_path(state_path(rid)) as path:
192
+ state = _read_state_strict(path)
193
+ state["heartbeat_at"] = _now()
194
+ _write_state_unlocked(path, state)
195
+
196
+
197
+ def new_state(rid: str, session: dict) -> dict:
198
+ """Return an empty `coordinator.json` document for this repo and coordinator session (spec section 8)."""
199
+ return {
200
+ "schema": SCHEMA,
201
+ "repo": rid,
202
+ "coordinator_session": {"name": session.get("name"), "sessionId": session.get("sessionId"),
203
+ "pid": session.get("pid")},
204
+ "heartbeat_at": _now(),
205
+ "queue": [],
206
+ "merging_now": None,
207
+ "deploy_checks": [],
208
+ "ledger": [],
209
+ "processed_requests": [],
210
+ }
211
+
212
+
213
+ # Copied from scripts/caddis_roadmap.py (_win_open_process, _pid_alive). Not imported: importing that module
214
+ # loads four more modules and changes sys.path (controller ruling R4).
215
+ def _win_open_process(pid: int) -> tuple[bool | None, int]:
216
+ """Windows: (still running, 0) when OpenProcess works; (None, GetLastError()) when it does not."""
217
+ import ctypes
218
+ from ctypes import wintypes
219
+
220
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
221
+ k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
222
+ k.OpenProcess.restype = wintypes.HANDLE
223
+ k.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
224
+ k.GetExitCodeProcess.restype = wintypes.BOOL
225
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
226
+ handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
227
+ if not handle:
228
+ return None, ctypes.get_last_error()
229
+ code = wintypes.DWORD()
230
+ ok = k.GetExitCodeProcess(handle, ctypes.byref(code))
231
+ k.CloseHandle(handle)
232
+ return bool(ok) and code.value == 259, 0 # STILL_ACTIVE
233
+
234
+
235
+ def _pid_alive(pid: int) -> bool:
236
+ if pid <= 0:
237
+ return False
238
+ if os.name == "nt":
239
+ running, error = _win_open_process(pid)
240
+ if running is None:
241
+ return error == 5 # ERROR_ACCESS_DENIED: the process exists, this user may not open it
242
+ return running
243
+ try:
244
+ os.kill(pid, 0)
245
+ except ProcessLookupError:
246
+ return False
247
+ except PermissionError:
248
+ return True
249
+ return True
250
+
251
+
252
+ # Copied from scripts/caddis_roadmap.py (PID_START_TOLERANCE_S, _filetime_utc, _win_process_start,
253
+ # _proc_process_start, process_start_time), for the same reason as _pid_alive above.
254
+ PID_START_TOLERANCE_S = 5.0 # `started_at` has whole seconds; the holder's own process started before it
255
+
256
+
257
+ def _filetime_utc(value: int) -> datetime.datetime:
258
+ return datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(microseconds=value // 10)
259
+
260
+
261
+ def _win_process_start(pid: int) -> datetime.datetime | None:
262
+ import ctypes
263
+ from ctypes import wintypes
264
+
265
+ k = ctypes.WinDLL("kernel32", use_last_error=True)
266
+ k.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
267
+ k.OpenProcess.restype = wintypes.HANDLE
268
+ k.GetProcessTimes.argtypes = (wintypes.HANDLE,) + (ctypes.POINTER(wintypes.FILETIME),) * 4
269
+ k.GetProcessTimes.restype = wintypes.BOOL
270
+ k.CloseHandle.argtypes = (wintypes.HANDLE,)
271
+ handle = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
272
+ if not handle:
273
+ return None
274
+ try:
275
+ times = [wintypes.FILETIME() for _ in range(4)]
276
+ if not k.GetProcessTimes(handle, *(ctypes.byref(x) for x in times)):
277
+ return None
278
+ created = times[0]
279
+ return _filetime_utc((created.dwHighDateTime << 32) | created.dwLowDateTime)
280
+ finally:
281
+ k.CloseHandle(handle)
282
+
283
+
284
+ def _proc_process_start(pid: int) -> datetime.datetime | None:
285
+ """POSIX with /proc: field 22 of /proc/<pid>/stat (clock ticks after boot) plus `btime` of /proc/stat."""
286
+ try:
287
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
288
+ ticks = int(stat.rsplit(")", 1)[1].split()[19])
289
+ btime = next(int(line.split()[1]) for line in Path("/proc/stat").read_text(encoding="utf-8").splitlines()
290
+ if line.startswith("btime "))
291
+ hz = os.sysconf("SC_CLK_TCK")
292
+ except (OSError, ValueError, IndexError, StopIteration, AttributeError):
293
+ return None
294
+ return datetime.datetime.fromtimestamp(btime + ticks / hz, datetime.timezone.utc)
295
+
296
+
297
+ def process_start_time(pid: int) -> datetime.datetime | None:
298
+ """When the process now holding `pid` started, or None when it cannot be read (psutil when installed, else
299
+ GetProcessTimes on Windows, else /proc)."""
300
+ try:
301
+ import psutil # optional
302
+ return datetime.datetime.fromtimestamp(psutil.Process(pid).create_time(), datetime.timezone.utc)
303
+ except ImportError:
304
+ pass
305
+ except Exception: # no such process, access denied: fall through to the platform reader
306
+ pass
307
+ try:
308
+ return _win_process_start(pid) if os.name == "nt" else _proc_process_start(pid)
309
+ except Exception:
310
+ return None
311
+
312
+
313
+ def _parse_utc(text: object) -> datetime.datetime | None:
314
+ if not isinstance(text, str):
315
+ return None
316
+ try:
317
+ return datetime.datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)
318
+ except ValueError:
319
+ return None
320
+
321
+
322
+ def _holder_alive(holder: dict) -> bool:
323
+ """Alive: the pid runs, and that process started at or before `started_at` + tolerance.
324
+
325
+ When the start time or `started_at` cannot be read, a running pid counts as alive (refuse, fail closed).
326
+ """
327
+ if not _pid_alive(holder["pid"]):
328
+ return False
329
+ started = _parse_utc(holder.get("started_at"))
330
+ born = process_start_time(holder["pid"]) if started is not None else None
331
+ return born is None or born <= started + datetime.timedelta(seconds=PID_START_TOLERANCE_S)
332
+
333
+
334
+ def _read_holder(path: Path) -> dict | None:
335
+ """Return the lock holder, or None when there is no lock or its content is not a holder (treated as free).
336
+
337
+ A transient sharing error is retried. Any other OSError is raised: a file that cannot be read may name a live
338
+ holder, so the caller must refuse (fail closed), never treat it as free."""
339
+ try:
340
+ data = json.loads(retry_denied(path.read_bytes).decode("utf-8"))
341
+ except FileNotFoundError:
342
+ return None
343
+ except ValueError: # bad JSON or UTF-8: atomic_write never leaves this, so no live holder wrote it
344
+ return None
345
+ return data if isinstance(data, dict) and isinstance(data.get("pid"), int) else None
346
+
347
+
348
+ def acquire_lock(rid: str, pid: int, session_id: str, name: str) -> tuple[bool, dict | None]:
349
+ """Take the single-coordinator lock: (True, None), or (False, holder) while another live pid holds it.
350
+
351
+ A holder whose pid is dead, or whose pid now belongs to a process that started after the lock was taken
352
+ (pid reuse, often after a reboot), is replaced. The same pid taking it again refreshes it. A lock file that
353
+ cannot be read is refused, with a holder whose pid is None (fail closed).
354
+ """
355
+ with locked_path(_lock_path(rid)) as path:
356
+ try:
357
+ holder = _read_holder(path)
358
+ except OSError as exc:
359
+ return False, {"pid": None, "sessionId": None, "name": None,
360
+ "error": f"lock file unreadable: {' '.join(str(exc).split()) or type(exc).__name__}"}
361
+ if holder is not None and holder["pid"] != pid and _holder_alive(holder):
362
+ return False, holder
363
+ atomic_write(path, json.dumps({"pid": pid, "sessionId": session_id, "name": name,
364
+ "started_at": _now()}) + "\n")
365
+ return True, None
366
+
367
+
368
+ def release_lock(rid: str, pid: int) -> None:
369
+ """Remove the lock if `pid` holds it; do nothing otherwise."""
370
+ with locked_path(_lock_path(rid)) as path:
371
+ holder = _read_holder(path)
372
+ if holder is not None and holder["pid"] == pid:
373
+ retry_denied(lambda: path.unlink(missing_ok=True))
374
+
375
+
376
+ # --- queue operations (C1.3) --------------------------------------------------------------------------------------
377
+
378
+ QUEUE_STATES = ("waiting", "ci", "ready", "merging", "blocked", "held") # spec section 8
379
+
380
+
381
+ def _mutate_state(rid: str, change: Callable[[dict], bool | None]) -> None:
382
+ """Read `coordinator.json` strictly under its lock, apply `change(state)`, write it back.
383
+
384
+ Raises ValueError when the repo has no state file: these operations need a coordinator that has started.
385
+ When `change` raises, or returns False (no change), nothing is written.
386
+ """
387
+ if not state_path(rid).exists(): # checked first so an unknown repo id leaves no folder or lock file behind
388
+ raise ValueError(f"no coordinator state for repo {rid!r}")
389
+ with locked_path(state_path(rid)) as path:
390
+ try:
391
+ state = _read_state_strict(path)
392
+ except FileNotFoundError:
393
+ raise ValueError(f"no coordinator state for repo {rid!r}") from None
394
+ if change(state) is False:
395
+ return
396
+ _write_state_unlocked(path, state)
397
+
398
+
399
+ def _find(queue: list[dict], pr: int) -> dict | None:
400
+ return next((item for item in queue if item.get("pr") == pr), None)
401
+
402
+
403
+ def _names_pr(merging_now: object, pr: int) -> bool:
404
+ """`merging_now` is a PR number or an object with `pr` (schema); True when it names `pr`."""
405
+ if isinstance(merging_now, dict):
406
+ merging_now = merging_now.get("pr")
407
+ return not isinstance(merging_now, bool) and merging_now == pr
408
+
409
+
410
+ def _mutate_item(rid: str, pr: int, change: Callable[[dict], None]) -> None:
411
+ """`_mutate_state` for a change to one queue item (`change(state)`), keeping `merging_now` in step with it.
412
+
413
+ The item entering `merging` sets `merging_now` to its PR. The item leaving `merging` (another state, a new
414
+ head, or removal) clears `merging_now` when it names this PR."""
415
+ def wrapped(state: dict) -> None:
416
+ before = _find(state["queue"], pr)
417
+ was = before is not None and before.get("state") == "merging"
418
+ change(state)
419
+ after = _find(state["queue"], pr)
420
+ if after is not None and after.get("state") == "merging":
421
+ state["merging_now"] = pr
422
+ elif was and _names_pr(state.get("merging_now"), pr):
423
+ state["merging_now"] = None
424
+ _mutate_state(rid, wrapped)
425
+
426
+
427
+ def _set_head(item: dict, head_sha: str) -> None:
428
+ """Record a new head. It has not been ready yet and is untested: clear the ready time and demote `ready` or
429
+ `merging` to `ci` (ruling R5). The same head changes nothing."""
430
+ if item.get("head_sha") == head_sha:
431
+ return
432
+ item["head_sha"] = head_sha
433
+ item["entered_ready_at"] = None
434
+ if item.get("state") in ("ready", "merging"):
435
+ item["state"] = "ci"
436
+
437
+
438
+ def queue_add(rid: str, pr: int, head_sha: str, title: str) -> None:
439
+ """Add a PR as `waiting`. Adding a PR already in the queue updates its title and head (a new head demotes a
440
+ `ready` or `merging` item to `ci`), nothing else."""
441
+ def change(state: dict) -> None:
442
+ queue = state["queue"]
443
+ item = _find(queue, pr)
444
+ if item is None:
445
+ queue.append({"pr": pr, "head_sha": head_sha, "title": title, "state": "waiting", "why_waiting": "",
446
+ "waits_on": [], "hotfix": False, "held": False, "entered_ready_at": None})
447
+ return
448
+ item["title"] = title
449
+ _set_head(item, head_sha)
450
+ _mutate_item(rid, pr, change)
451
+
452
+
453
+ def queue_set(rid: str, pr: int, *, state: str | None = None, head_sha: str | None = None,
454
+ why_waiting: str | None = None, waits_on: list[dict] | None = None) -> None:
455
+ """Change the given fields of one queue item. Raises ValueError for an unknown state or a PR not in the queue.
456
+
457
+ `entered_ready_at` is stamped the first time the item is set `ready`, and kept when it leaves and re-enters
458
+ `ready` with the same head. A new head clears it and demotes `ready`/`merging` to `ci` (ruling R5); a `state`
459
+ given in the same call is applied after that. `held` is not touched here (ruling R1).
460
+
461
+ Entering `merging` sets `merging_now` to this PR, and leaving it clears `merging_now`. Raises ValueError when
462
+ another queued PR is merging now (one merge at a time); nothing is written then.
463
+ """
464
+ if state is not None and state not in QUEUE_STATES:
465
+ raise ValueError(f"unknown queue state: {state!r}")
466
+
467
+ def change(doc: dict) -> None:
468
+ queue = doc["queue"]
469
+ item = _find(queue, pr)
470
+ if item is None:
471
+ raise ValueError(f"PR {pr} is not in the queue")
472
+ if state == "merging":
473
+ other = next((i.get("pr") for i in queue if i.get("pr") != pr and (
474
+ i.get("state") == "merging" or _names_pr(doc.get("merging_now"), i.get("pr")))), None)
475
+ if other is not None:
476
+ raise ValueError(f"PR {other} is merging now: one merge at a time")
477
+ if head_sha is not None:
478
+ _set_head(item, head_sha)
479
+ if why_waiting is not None:
480
+ item["why_waiting"] = why_waiting
481
+ if waits_on is not None:
482
+ item["waits_on"] = [dict(w) for w in waits_on]
483
+ if state is not None:
484
+ item["state"] = state
485
+ if state == "ready" and not item.get("entered_ready_at"):
486
+ item["entered_ready_at"] = _now()
487
+ _mutate_item(rid, pr, change)
488
+
489
+
490
+ def queue_remove(rid: str, pr: int) -> None:
491
+ """Remove a PR from the queue; do nothing when it is not there."""
492
+ def change(state: dict) -> None:
493
+ state["queue"] = [item for item in state["queue"] if item.get("pr") != pr]
494
+ _mutate_item(rid, pr, change)
495
+
496
+
497
+ def queue_order(state: dict) -> list[dict]:
498
+ """Return the queue in merge order (spec D5). Pure: the state is not changed.
499
+
500
+ The `merging` item comes first. Then hotfix items, then the rest. Inside each group, ready items that are not
501
+ held go first by `entered_ready_at` ascending; held and non-ready items follow in add order.
502
+ """
503
+ queue = list(state.get("queue") or [])
504
+
505
+ def ready(item: dict) -> bool:
506
+ return item.get("state") == "ready" and not item.get("held")
507
+
508
+ def rank(index_item: tuple[int, dict]) -> tuple:
509
+ index, item = index_item
510
+ if item.get("state") == "merging":
511
+ return (0, 0, 0, "", index)
512
+ group = 1 if item.get("hotfix") else 2
513
+ if ready(item):
514
+ at = item.get("entered_ready_at")
515
+ return (group, 0, 0 if at else 1, at or "", index) # a ready item without a time sorts last of the ready
516
+ return (group, 1, 0, "", index)
517
+
518
+ return [item for _, item in sorted(enumerate(queue), key=rank)]
519
+
520
+
521
+ def queue_next(state: dict) -> dict | None:
522
+ """Return the item to merge next (pure): the `merging` item; else the first `ready`, not held item in
523
+ `queue_order`; else None."""
524
+ for item in queue_order(state):
525
+ if item.get("state") == "merging" or (item.get("state") == "ready" and not item.get("held")):
526
+ return item
527
+ return None
528
+
529
+
530
+ # --- owner requests and intake (C1.4) -----------------------------------------------------------------------------
531
+
532
+ REQUEST_V = 1
533
+ _REQUEST_VS = (REQUEST_V - 1, REQUEST_V) # readers accept N and N-1
534
+ _REQUEST_KEYS = frozenset(("v", "id", "created_at", "expires_at", "repo", "pr", "head_sha", "action", "args"))
535
+ # Inside one batch only the last request per (pr, family) is applied.
536
+ _FAMILY = {"hotfix": "hotfix", "unhotfix": "hotfix", "hold": "hold", "release": "hold",
537
+ "approve": "approve", "decline": "approve"}
538
+ PROCESSED_KEEP = datetime.timedelta(hours=24)
539
+ MAX_REQUEST_LIFE = datetime.timedelta(hours=1)
540
+ MAX_CLOCK_SKEW = datetime.timedelta(minutes=5) # a request created later than now + this is refused
541
+
542
+
543
+ def requests_path(rid: str) -> Path:
544
+ return _repo_dir(rid) / "requests.jsonl"
545
+
546
+
547
+ def _cursor_path(rid: str) -> Path:
548
+ return _repo_dir(rid) / "intake.cursor"
549
+
550
+
551
+ def _fmt(when: datetime.datetime) -> str:
552
+ return when.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
553
+
554
+
555
+ def append_request(rid: str, req: dict) -> None:
556
+ """Append one request as one JSON line under the requests file's lock.
557
+
558
+ Under the lock, a tail with no newline can only come from a writer that died. It is closed with a newline
559
+ first, so it becomes one bad line (rejected by intake) and never joins this request.
560
+ """
561
+ if not isinstance(req, dict):
562
+ raise ValueError("a request is a JSON object")
563
+ line = (json.dumps(req, separators=(",", ":"), ensure_ascii=True) + "\n").encode("utf-8")
564
+ with locked_path(requests_path(rid)) as path:
565
+ def append() -> None:
566
+ with open(path, "a+b") as fh:
567
+ fh.seek(0, os.SEEK_END)
568
+ prefix = b""
569
+ if fh.tell() > 0:
570
+ fh.seek(-1, os.SEEK_END)
571
+ if fh.read(1) != b"\n":
572
+ prefix = b"\n"
573
+ fh.write(prefix + line) # append mode: always at the end
574
+ fh.flush()
575
+ os.fsync(fh.fileno())
576
+ retry_denied(append)
577
+
578
+
579
+ def _read_cursor(path: Path) -> int:
580
+ try:
581
+ value = int(retry_denied(lambda: path.read_text(encoding="utf-8")).strip())
582
+ except (OSError, ValueError): # missing or unreadable: start from 0; processed ids prevent replay
583
+ return 0
584
+ return max(value, 0)
585
+
586
+
587
+ def _check_request(req: object, rid: str) -> str | None:
588
+ """Return the rejection reason for a malformed request, or None when it is well formed."""
589
+ if not isinstance(req, dict) or set(req) != _REQUEST_KEYS:
590
+ return "bad request"
591
+ if not isinstance(req["action"], str) or req["action"] not in _FAMILY:
592
+ return "unknown action"
593
+ if (isinstance(req["v"], bool) or req["v"] not in _REQUEST_VS or req["repo"] != rid
594
+ or not isinstance(req["id"], str) or not req["id"]
595
+ or not isinstance(req["args"], dict)):
596
+ return "bad request"
597
+ if _parse_utc(req["created_at"]) is None or _parse_utc(req["expires_at"]) is None:
598
+ return "bad request"
599
+ pr = req["pr"]
600
+ if pr is None: # ruling R2: only approve/decline may name no PR, and then `args.what` says what is decided
601
+ what = req["args"].get("what")
602
+ if _FAMILY[req["action"]] != "approve" or not isinstance(what, str) or not what.strip():
603
+ return "bad request"
604
+ return None
605
+ if isinstance(pr, bool) or not isinstance(pr, int) or pr <= 0:
606
+ return "bad request"
607
+ if not isinstance(req["head_sha"], str) or not _SHA.fullmatch(req["head_sha"]):
608
+ return "bad request"
609
+ return None
610
+
611
+
612
+ def _window_ok(req: dict, now: datetime.datetime) -> bool:
613
+ """Spec rule 4 gives expires_at = created_at + 30 min. A longer window could outlive the 24 h dedup of
614
+ processed ids and replay after a lost cursor, so a window over 1 hour, one that ends before it starts, or one
615
+ created more than `MAX_CLOCK_SKEW` in the future is refused. Checked after expiry: an expired request has no
616
+ effect, so it cannot replay."""
617
+ created, expires = _parse_utc(req["created_at"]), _parse_utc(req["expires_at"])
618
+ return created <= expires <= created + MAX_REQUEST_LIFE and created <= now + MAX_CLOCK_SKEW
619
+
620
+
621
+ def _new_lines(rid: str, offset: int, stop: int | None = None) -> tuple[list[tuple[int, bytes]], int]:
622
+ """Return the complete lines after `offset` as (byte offset, line), and the new cursor (just after the last
623
+ newline). A tail with no newline is left for the next cycle. Bytes at or after `stop` are not read."""
624
+ try:
625
+ data = retry_denied(requests_path(rid).read_bytes)
626
+ except FileNotFoundError:
627
+ return [], offset
628
+ if offset > len(data): # the file was replaced by a shorter one: read it again from the start
629
+ offset = 0
630
+ if stop is not None:
631
+ data = data[:stop]
632
+ end = data.rfind(b"\n", offset)
633
+ if end < 0:
634
+ return [], offset
635
+ lines, start = [], offset
636
+ for raw in data[offset:end].split(b"\n"):
637
+ lines.append((start, raw))
638
+ start += len(raw) + 1
639
+ return lines, end + 1
640
+
641
+
642
+ def _parse_line(start: int, raw: bytes) -> tuple[str, object]:
643
+ """Return (key, parsed request or None). A line with no usable id is named by its byte offset, which stays
644
+ the same when the file is read again."""
645
+ try:
646
+ req = json.loads(raw.decode("utf-8"))
647
+ except (ValueError, RecursionError): # bad JSON, bad UTF-8, or nesting too deep
648
+ req = None
649
+ given = req.get("id") if isinstance(req, dict) else None
650
+ return (given if isinstance(given, str) and given else f"line@{start}"), req
651
+
652
+
653
+ def _live_heads(rid: str, lines: list[tuple[int, bytes]], head_of: Callable[[int], str | None],
654
+ now: datetime.datetime) -> dict:
655
+ """Look up the live head once per PR named by a well-formed, unexpired, not yet processed line. Called with
656
+ no lock held, so the processed ids come from a plain read; a stale read only costs an extra lookup.
657
+
658
+ Any lookup failure gives None: an unknown head (ruling R3)."""
659
+ done = {p.get("id") for p in (read_state(rid) or {}).get("processed_requests") or [] if isinstance(p, dict)}
660
+ prs: list[int] = []
661
+ for start, raw in lines:
662
+ if not raw.strip():
663
+ continue
664
+ key, req = _parse_line(start, raw)
665
+ if (key not in done and _check_request(req, rid) is None and now <= _parse_utc(req["expires_at"])
666
+ and _window_ok(req, now) and req["pr"] is not None and req["pr"] not in prs):
667
+ prs.append(req["pr"])
668
+ heads: dict[int, str | None] = {}
669
+ for pr in prs:
670
+ try:
671
+ heads[pr] = head_of(pr)
672
+ except Exception:
673
+ heads[pr] = None
674
+ return heads
675
+
676
+
677
+ def intake(rid: str, now: datetime.datetime, head_of: Callable[[int], str | None]) -> list[dict]:
678
+ """Apply new owner requests to the queue and ledger; return this cycle's results (spec section 7 rule 5).
679
+
680
+ Reads the complete lines after `intake.cursor`. A request id already in `processed_requests` is skipped with
681
+ no result, so a lost cursor never replays a request. State and cursor are written in one locked section,
682
+ state first: a crash between the two re-reads lines whose ids are already processed. `head_of(pr)` gives the
683
+ live head SHA, or None when it is unknown; then the request is rejected (fail closed, ruling R3).
684
+ """
685
+ if not state_path(rid).exists(): # checked first so an unknown repo id leaves no folder or lock file behind
686
+ raise ValueError(f"no coordinator state for repo {rid!r}")
687
+ at = _fmt(now)
688
+ cursor_path = _cursor_path(rid)
689
+ # head_of may call gh and take seconds. Other writers wait at most 5 s for the state lock, so the live heads
690
+ # are looked up first, with no lock held. A head that moves in the gap is the same race as one that moves
691
+ # just after the check. Lines that arrive in the gap are left for the next cycle (`stop`).
692
+ seen_lines, stop = _new_lines(rid, _read_cursor(cursor_path))
693
+ heads = _live_heads(rid, seen_lines, head_of, now)
694
+ with locked_path(state_path(rid)) as path:
695
+ try:
696
+ state = _read_state_strict(path)
697
+ except FileNotFoundError:
698
+ raise ValueError(f"no coordinator state for repo {rid!r}") from None
699
+ offset = _read_cursor(cursor_path)
700
+ lines, new_offset = _new_lines(rid, offset, stop)
701
+
702
+ processed = [p for p in state.get("processed_requests") or [] if isinstance(p, dict)]
703
+ seen = {p.get("id") for p in processed}
704
+ results: dict[str, dict | None] = {} # id -> result, in line order; None until the winner is applied
705
+ winners: dict[tuple, dict] = {}
706
+ for start, raw in lines:
707
+ if not raw.strip():
708
+ continue
709
+ key, req = _parse_line(start, raw)
710
+ if key in seen or key in results:
711
+ continue
712
+ reason = _check_request(req, rid)
713
+ if reason is not None:
714
+ results[key] = {"id": key, "result": "rejected", "reason": reason, "at": at}
715
+ elif now > _parse_utc(req["expires_at"]):
716
+ results[key] = {"id": key, "result": "expired", "reason": "expired", "at": at}
717
+ elif not _window_ok(req, now):
718
+ results[key] = {"id": key, "result": "rejected", "reason": "bad request", "at": at}
719
+ else:
720
+ family = _FAMILY[req["action"]]
721
+ # Two non-PR decisions supersede each other only when they decide the same thing.
722
+ group = (req["pr"], family) if req["pr"] is not None else (None, family, req["args"]["what"].strip())
723
+ earlier = winners.get(group)
724
+ if earlier is not None:
725
+ results[earlier["id"]] = {"id": earlier["id"], "result": "rejected", "reason": "superseded",
726
+ "at": at}
727
+ winners[group] = req
728
+ results[key] = None
729
+
730
+ for req in winners.values():
731
+ results[req["id"]] = _apply(state, req, heads, at)
732
+ out = list(results.values())
733
+
734
+ cutoff = now - PROCESSED_KEEP
735
+ kept = [p for p in processed if (_parse_utc(p.get("at")) or cutoff) > cutoff]
736
+ if out or len(kept) != len(state.get("processed_requests") or []):
737
+ state["processed_requests"] = kept + out
738
+ _write_state_unlocked(path, state)
739
+ if new_offset != offset:
740
+ atomic_write(cursor_path, f"{new_offset}\n")
741
+ return out
742
+
743
+
744
+ def _apply(state: dict, req: dict, heads: dict[int, str | None], at: str) -> dict:
745
+ """Check one winning request against the queue and the live head, then apply it. Returns its result.
746
+
747
+ `heads` comes from `_live_heads`; a PR missing from it has an unknown head."""
748
+ def result(kind: str, reason: str) -> dict:
749
+ return {"id": req["id"], "result": kind, "reason": reason, "at": at}
750
+
751
+ action, pr = req["action"], req["pr"]
752
+ item = None
753
+ if pr is not None:
754
+ item = _find(state["queue"], pr)
755
+ if item is None:
756
+ return result("rejected", "not in queue")
757
+ live = heads.get(pr)
758
+ if not isinstance(live, str) or not live:
759
+ return result("rejected", "head unknown")
760
+ if live.lower() != req["head_sha"].lower():
761
+ return result("rejected", "head moved")
762
+ if action in ("hotfix", "unhotfix"):
763
+ item["hotfix"] = action == "hotfix"
764
+ elif action in ("hold", "release"):
765
+ item["held"] = action == "hold"
766
+ else:
767
+ what = req["args"].get("what")
768
+ what = what.strip() if isinstance(what, str) and what.strip() else f"PR #{pr}"
769
+ # The owner's click in the hub is the approval (spec 7a): that is its reason and its window.
770
+ state.setdefault("ledger", []).append(_ledger_entry(what=what, decision=action, by="owner",
771
+ reason="owner click", window="hub",
772
+ request_id=req["id"], at=at))
773
+ return result("done", "")
774
+
775
+
776
+ # --- merge record, deploy checks, ledger (C1.6) -------------------------------------------------------------------
777
+
778
+ DEPLOY_HEALTH = ("pending", "ok", "failed")
779
+ LEDGER_DECISIONS = ("approve", "decline")
780
+ LEDGER_BY = ("owner", "coordinator") # spec D15, D16: routine gates by the coordinator, the owner-only list by the owner
781
+
782
+
783
+ def queue_merged(rid: str, pr: int, merge_sha: str) -> bool:
784
+ """Record a merge: remove the PR from the queue, clear `merging_now` when it names this PR, and append a
785
+ `pending` deploy check. Returns True when recorded now.
786
+
787
+ Idempotent: when the PR is not in the queue but a deploy check with this `pr` and `merge_sha` exists, the
788
+ merge was already recorded (a retry after a successful write): returns False and writes nothing. Raises
789
+ ValueError for a merge SHA that is not 40 hex characters, or a PR neither queued nor recorded with this SHA
790
+ (nothing written). The SHA is stored lower-cased."""
791
+ if not isinstance(merge_sha, str) or not _SHA.fullmatch(merge_sha):
792
+ raise ValueError(f"a merge SHA is 40 hex characters: {merge_sha!r}")
793
+ merge_sha = merge_sha.lower()
794
+
795
+ def change(state: dict) -> bool:
796
+ if _find(state["queue"], pr) is None:
797
+ if any(isinstance(c, dict) and c.get("pr") == pr and c.get("merge_sha") == merge_sha
798
+ for c in state.get("deploy_checks") or []):
799
+ return False
800
+ raise ValueError(f"PR {pr} is not in the queue")
801
+ state["queue"] = [item for item in state["queue"] if item.get("pr") != pr]
802
+ if _names_pr(state.get("merging_now"), pr):
803
+ state["merging_now"] = None
804
+ state.setdefault("deploy_checks", []).append({"pr": pr, "merge_sha": merge_sha, "deploy_run": None,
805
+ "health": "pending", "at": _now()})
806
+ return True
807
+
808
+ outcome: list[bool] = []
809
+
810
+ def change_and_keep(state: dict) -> bool:
811
+ outcome.append(change(state))
812
+ return outcome[-1]
813
+ _mutate_state(rid, change_and_keep)
814
+ return outcome[-1]
815
+
816
+
817
+ def deploy_check_set(rid: str, pr: int, *, deploy_run: int | None = None, health: str | None = None) -> None:
818
+ """Set the deploy run and/or health of the newest deploy check for a PR. Raises ValueError for a health not in
819
+ `DEPLOY_HEALTH`, a run that is not an integer, or a PR with no deploy check."""
820
+ if health is not None and health not in DEPLOY_HEALTH:
821
+ raise ValueError(f"unknown deploy health: {health!r}")
822
+ if deploy_run is not None and (isinstance(deploy_run, bool) or not isinstance(deploy_run, int)):
823
+ raise ValueError(f"a deploy run is an integer: {deploy_run!r}")
824
+
825
+ def change(state: dict) -> None:
826
+ check = next((c for c in reversed(state.get("deploy_checks") or [])
827
+ if isinstance(c, dict) and c.get("pr") == pr), None)
828
+ if check is None:
829
+ raise ValueError(f"no deploy check for PR {pr}")
830
+ if deploy_run is not None:
831
+ check["deploy_run"] = deploy_run
832
+ if health is not None:
833
+ check["health"] = health
834
+ _mutate_state(rid, change)
835
+
836
+
837
+ def _ledger_entry(*, what: str, decision: str, by: str, reason: str, window: str | None, request_id: str | None,
838
+ at: str) -> dict:
839
+ """Validate and build one ledger entry (keys in schema order). Raises ValueError."""
840
+ def text(name: str, value: object, optional: bool) -> str | None:
841
+ if value is None and optional:
842
+ return None
843
+ if not isinstance(value, str) or not value.strip():
844
+ raise ValueError(f"ledger {name} must be a non-empty string")
845
+ return value.strip()
846
+
847
+ if decision not in LEDGER_DECISIONS:
848
+ raise ValueError(f"unknown ledger decision: {decision!r}")
849
+ if by not in LEDGER_BY:
850
+ raise ValueError(f"unknown approver: {by!r}")
851
+ return {"what": text("what", what, False), "decision": decision, "approved_by": by,
852
+ "reason": text("reason", reason, False), "window": text("window", window, True),
853
+ "request_id": text("request_id", request_id, True), "at": at}
854
+
855
+
856
+ def ledger_record(rid: str, *, what: str, decision: str, by: str, reason: str, window: str | None = None,
857
+ request_id: str | None = None) -> None:
858
+ """Append a decision to the ledger: a routine gate by the coordinator, or an owner decision given in a session
859
+ window (D15, D16). `window` names the session window it was decided in. Raises ValueError (nothing written)."""
860
+ entry = _ledger_entry(what=what, decision=decision, by=by, reason=reason, window=window, request_id=request_id,
861
+ at=_now())
862
+ _mutate_state(rid, lambda state: state.setdefault("ledger", []).append(entry))
863
+
864
+
865
+ # --- command line (C1.5) ------------------------------------------------------------------------------------------
866
+
867
+ EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_LOCK_REFUSED = 0, 1, 2, 3
868
+
869
+
870
+ def _gh_head_of(rid: str) -> Callable[[int], str | None]:
871
+ """Return `head_of(pr)` for intake: the live head SHA from gh, or None (ruling R3).
872
+
873
+ None for a `local__` id (no GitHub repository) and on any gh failure. gh reads `GH_TOKEN` from the inherited
874
+ environment when it is set; this script never chooses an account."""
875
+ def head_of(pr: int) -> str | None:
876
+ if rid.startswith("local__") or "__" not in rid:
877
+ return None
878
+ owner, name = rid.split("__", 1) # GitHub owners have no underscore, so the first `__` splits them
879
+ try:
880
+ result = _run(["gh", "pr", "view", str(pr), "-R", f"{owner}/{name}", "--json", "headRefOid",
881
+ "-q", ".headRefOid"])
882
+ except (OSError, subprocess.SubprocessError):
883
+ return None
884
+ sha = result.stdout.strip() if result.returncode == 0 else ""
885
+ return sha or None
886
+ return head_of
887
+
888
+
889
+ def _claim_state(rid: str, session: dict) -> None:
890
+ """After the lock is taken: create `coordinator.json`, or record the new coordinator session in it."""
891
+ with locked_path(state_path(rid)) as path:
892
+ try:
893
+ state = _read_state_strict(path)
894
+ except FileNotFoundError:
895
+ state = new_state(rid, session)
896
+ state["coordinator_session"] = {"name": session["name"], "sessionId": session["sessionId"],
897
+ "pid": session["pid"]}
898
+ state["heartbeat_at"] = _now()
899
+ _write_state_unlocked(path, state)
900
+
901
+
902
+ def _waits_on(text: str) -> list[dict]:
903
+ try:
904
+ value = json.loads(text)
905
+ except ValueError:
906
+ value = None
907
+ if not isinstance(value, list) or not all(isinstance(w, dict) for w in value):
908
+ raise argparse.ArgumentTypeError('takes a JSON list of objects, for example [{"repo": "acme__lib", "pr": 3}]')
909
+ return value
910
+
911
+
912
+ class _UsageError(Exception):
913
+ pass
914
+
915
+
916
+ class _Parser(argparse.ArgumentParser):
917
+ def error(self, message: str) -> None: # raise instead of sys.exit, so main() returns 2
918
+ raise _UsageError(f"{self.prog}: {message}")
919
+
920
+
921
+ def _parser() -> argparse.ArgumentParser:
922
+ top = _Parser(prog="caddis_coordinate.py", description="Deterministic coordinator core for the caddis hub.")
923
+ sub = top.add_subparsers(dest="command", required=True, parser_class=_Parser)
924
+
925
+ def command(name: str, parent=sub, repo: bool = True) -> argparse.ArgumentParser:
926
+ p = parent.add_parser(name)
927
+ p.add_argument("--json", action="store_true", help="print JSON on stdout")
928
+ if repo:
929
+ p.add_argument("--repo", help="repo id (default: the repo id of the current folder)")
930
+ return p
931
+
932
+ p = command("repo-id", repo=False)
933
+ p.add_argument("--path", default=".", help="a folder inside the checkout (default: current folder)")
934
+
935
+ lock = sub.add_parser("lock").add_subparsers(dest="lock_command", required=True, parser_class=_Parser)
936
+ p = command("acquire", lock)
937
+ p.add_argument("--pid", type=int, required=True)
938
+ p.add_argument("--session-id", required=True)
939
+ p.add_argument("--name", required=True)
940
+ p = command("release", lock)
941
+ p.add_argument("--pid", type=int, required=True)
942
+
943
+ command("heartbeat")
944
+ command("intake")
945
+ command("state")
946
+
947
+ queue = sub.add_parser("queue").add_subparsers(dest="queue_command", required=True, parser_class=_Parser)
948
+ p = command("add", queue)
949
+ p.add_argument("--pr", type=int, required=True)
950
+ p.add_argument("--head-sha", required=True)
951
+ p.add_argument("--title", required=True)
952
+ p = command("set", queue)
953
+ p.add_argument("--pr", type=int, required=True)
954
+ p.add_argument("--state", choices=QUEUE_STATES)
955
+ p.add_argument("--head-sha")
956
+ p.add_argument("--why-waiting")
957
+ p.add_argument("--waits-on", type=_waits_on)
958
+ p = command("remove", queue)
959
+ p.add_argument("--pr", type=int, required=True)
960
+ p = command("ready", queue)
961
+ p.add_argument("--pr", type=int, required=True)
962
+ command("next", queue)
963
+ p = command("merged", queue)
964
+ p.add_argument("--pr", type=int, required=True)
965
+ p.add_argument("--merge-sha", required=True)
966
+
967
+ deploy = sub.add_parser("deploy-check").add_subparsers(dest="deploy_command", required=True, parser_class=_Parser)
968
+ p = command("set", deploy)
969
+ p.add_argument("--pr", type=int, required=True)
970
+ p.add_argument("--run", type=int, help="the deploy run id")
971
+ p.add_argument("--health", choices=DEPLOY_HEALTH)
972
+
973
+ ledger = sub.add_parser("ledger").add_subparsers(dest="ledger_command", required=True, parser_class=_Parser)
974
+ p = command("record", ledger)
975
+ p.add_argument("--what", required=True)
976
+ p.add_argument("--decision", choices=LEDGER_DECISIONS, required=True)
977
+ p.add_argument("--by", choices=LEDGER_BY, required=True)
978
+ p.add_argument("--reason", required=True)
979
+ p.add_argument("--window", help="the session window the decision was made in")
980
+ p.add_argument("--request-id")
981
+ return top
982
+
983
+
984
+ def _emit(args: argparse.Namespace, value: object, text: str) -> None:
985
+ print(json.dumps(value, indent=2) if args.json else text)
986
+
987
+
988
+ def _dispatch(args: argparse.Namespace) -> int:
989
+ if args.command == "repo-id":
990
+ rid = repo_id_for(Path(args.path))
991
+ _emit(args, {"repo": rid}, rid)
992
+ return EXIT_OK
993
+ rid = args.repo or repo_id_for(Path.cwd())
994
+ _repo_dir(rid) # refuse a bad id before anything is written
995
+
996
+ if args.command == "lock":
997
+ if args.lock_command == "acquire":
998
+ ok, holder = acquire_lock(rid, args.pid, args.session_id, args.name)
999
+ if not ok:
1000
+ _emit(args, {"acquired": False, "holder": holder}, f"refused: {holder['error']}" if holder.get("error")
1001
+ else f"refused: held by pid {holder.get('pid')} ({holder.get('name')})")
1002
+ return EXIT_LOCK_REFUSED
1003
+ _claim_state(rid, {"name": args.name, "sessionId": args.session_id, "pid": args.pid})
1004
+ _emit(args, {"acquired": True, "holder": None}, "acquired")
1005
+ return EXIT_OK
1006
+ release_lock(rid, args.pid)
1007
+ _emit(args, {"released": True}, "released")
1008
+ return EXIT_OK
1009
+ if args.command == "heartbeat":
1010
+ if not state_path(rid).exists(): # checked first so an unknown repo id leaves no folder or lock file behind
1011
+ raise ValueError(f"no coordinator state for repo {rid!r}")
1012
+ heartbeat(rid)
1013
+ _emit(args, {"ok": True}, "ok")
1014
+ return EXIT_OK
1015
+ if args.command == "intake":
1016
+ results = intake(rid, datetime.datetime.now(datetime.timezone.utc), _gh_head_of(rid))
1017
+ lines = [f"{r['id']}: {r['result']} {r['reason']}".rstrip() for r in results]
1018
+ _emit(args, results, "\n".join(lines) or "no new requests")
1019
+ return EXIT_OK
1020
+ if args.command == "state":
1021
+ state = read_state(rid)
1022
+ if state is None:
1023
+ raise ValueError(f"no coordinator state for repo {rid!r}")
1024
+ _emit(args, state, json.dumps(state, indent=2))
1025
+ return EXIT_OK
1026
+ if args.command == "queue" and args.queue_command == "next":
1027
+ state = read_state(rid)
1028
+ if state is None:
1029
+ raise ValueError(f"no coordinator state for repo {rid!r}")
1030
+ item = queue_next(state)
1031
+ _emit(args, {"next": item}, f"PR #{item['pr']}" if item else "none")
1032
+ return EXIT_OK
1033
+ if args.command == "deploy-check":
1034
+ deploy_check_set(rid, args.pr, deploy_run=args.run, health=args.health)
1035
+ elif args.command == "ledger":
1036
+ ledger_record(rid, what=args.what, decision=args.decision, by=args.by, reason=args.reason,
1037
+ window=args.window, request_id=args.request_id)
1038
+ elif args.queue_command == "add":
1039
+ queue_add(rid, args.pr, args.head_sha, args.title)
1040
+ elif args.queue_command == "set":
1041
+ queue_set(rid, args.pr, state=args.state, head_sha=args.head_sha, why_waiting=args.why_waiting,
1042
+ waits_on=args.waits_on)
1043
+ elif args.queue_command == "ready":
1044
+ queue_set(rid, args.pr, state="ready")
1045
+ elif args.queue_command == "merged":
1046
+ if not queue_merged(rid, args.pr, args.merge_sha):
1047
+ _emit(args, {"ok": True, "result": "already recorded"}, "already recorded")
1048
+ return EXIT_OK
1049
+ else:
1050
+ queue_remove(rid, args.pr)
1051
+ _emit(args, {"ok": True}, "ok")
1052
+ return EXIT_OK
1053
+
1054
+
1055
+ def main(argv: list[str] | None = None) -> int:
1056
+ """Run one subcommand. Exit 0 on success, 1 on an error (one line on stderr), 2 on a usage error, 3 when the
1057
+ lock is refused."""
1058
+ try:
1059
+ parser = _parser()
1060
+ args = parser.parse_args(argv)
1061
+ if args.command == "deploy-check" and args.run is None and args.health is None:
1062
+ parser.error("deploy-check set needs --run or --health")
1063
+ if args.command == "lock" and args.lock_command == "acquire" and args.pid <= 0:
1064
+ parser.error("lock acquire --pid must be a positive process id")
1065
+ except _UsageError as exc:
1066
+ print(f"usage error: {exc}", file=sys.stderr)
1067
+ return EXIT_USAGE
1068
+ except SystemExit as exc: # -h prints help and exits 0
1069
+ return EXIT_OK if exc.code in (0, None) else EXIT_USAGE
1070
+ try:
1071
+ return _dispatch(args)
1072
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
1073
+ message = " ".join(str(exc).split()) or type(exc).__name__
1074
+ print(f"error: {message}", file=sys.stderr)
1075
+ return EXIT_ERROR
1076
+ except Exception as exc: # last resort, e.g. a hand-edited state file: one line, no traceback
1077
+ message = " ".join(str(exc).split())
1078
+ print(f"error: unexpected {type(exc).__name__}" + (f": {message}" if message else ""), file=sys.stderr)
1079
+ return EXIT_ERROR
1080
+
1081
+
1082
+ if __name__ == "__main__":
1083
+ sys.exit(main())