@caddis/cli 0.4.69 → 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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +0 -38
- package/bundles/antigravity-plugin/scripts/caddis_coordinate.py +1083 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +5 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +10 -451
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +6 -7
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +35 -352
- package/bundles/antigravity-plugin/scripts/caddis_upstream_check.py +107 -0
- package/bundles/antigravity-plugin/scripts/schemas/coordinator.schema.json +95 -0
- package/bundles/antigravity-plugin/scripts/schemas/request.schema.json +39 -0
- package/bundles/antigravity-plugin/skills/_registry.md +1 -1
- package/bundles/antigravity-plugin/skills/brainstorming/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +64 -1
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +25 -39
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +14 -0
- package/bundles/antigravity-plugin/skills/usage-review/SKILL.md +9 -0
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +15 -375
- package/bundles/antigravity-plugin/warm_start_agy.py +3 -40
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +15 -375
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +1 -1
- package/bundles/codex/.codex/skills/workflow/brainstorming/SKILL.md +1 -1
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -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())
|