queue-watch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Read-only terminal dashboard for CI runs and merge queues."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,34 @@
1
+ """Adapters turn one external system into snapshot v1 (see queue_watch.model)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+
8
+ class Adapter:
9
+ """Base class. Subclasses take their options as keyword-only arguments.
10
+
11
+ A ``config_dir`` parameter, when present, receives the directory of the config
12
+ file; it is supplied by the loader and cannot be set from configuration.
13
+ """
14
+
15
+ name: ClassVar[str]
16
+ label: ClassVar[str]
17
+ interval: ClassVar[float] = 15.0
18
+
19
+ async def snapshot(self) -> dict:
20
+ raise NotImplementedError
21
+
22
+ async def children(self, record: dict) -> list[dict]:
23
+ """Detail records (jobs, steps) for the inspector; inline by default."""
24
+ return record.get("children") or []
25
+
26
+
27
+ def registry() -> dict[str, type[Adapter]]:
28
+ from .command import Command
29
+ from .flock_queue import FlockQueue
30
+ from .github_actions import GitHubActions
31
+ from .github_merge_queue import GitHubMergeQueue
32
+
33
+ adapters = (GitHubActions, GitHubMergeQueue, FlockQueue, Command)
34
+ return {adapter.name: adapter for adapter in adapters}
@@ -0,0 +1,54 @@
1
+ """Any program that prints one snapshot v1 JSON object on stdout.
2
+
3
+ This is the extension point for systems without a built-in adapter (GitLab,
4
+ Buildkite, Jenkins, Bors, in-house queues). ``command`` is an argument list run
5
+ without a shell. Locally it runs in ``cwd``, which defaults to the directory of
6
+ the config file. With ``host`` set it runs over SSH, in ``cwd`` on that host
7
+ when given. A nonzero exit, a timeout or output that breaks the contract is
8
+ shown as a source error, and the last good data stays on screen.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from ..process import check_host, over_ssh, run_json
17
+ from . import Adapter
18
+
19
+
20
+ class Command(Adapter):
21
+ name = "command"
22
+ label = "Command"
23
+ interval = 15.0
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ command: list[str],
29
+ host: str | None = None,
30
+ cwd: str | None = None,
31
+ timeout: float = 25,
32
+ config_dir: Path | None = None,
33
+ ):
34
+ if (
35
+ not isinstance(command, list)
36
+ or not command
37
+ or not all(isinstance(arg, str) and arg for arg in command)
38
+ ):
39
+ raise ValueError(
40
+ 'command must be a list of arguments, for example ["./status", "--json"]'
41
+ )
42
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
43
+ raise ValueError("timeout must be a positive number of seconds")
44
+ self.command, self.host, self.timeout = command, check_host(host), float(timeout)
45
+ if self.host is not None:
46
+ self.cwd = cwd
47
+ else:
48
+ base = config_dir or Path.cwd()
49
+ self.cwd = str(base / os.path.expanduser(cwd)) if cwd else str(base)
50
+
51
+ async def snapshot(self) -> dict:
52
+ if self.host is not None:
53
+ return await run_json(over_ssh(self.host, self.command, self.cwd), timeout=self.timeout)
54
+ return await run_json(self.command, cwd=self.cwd, timeout=self.timeout)
@@ -0,0 +1,322 @@
1
+ """A FIFO of flock-held ticket files guarding one integration lock, on Linux.
2
+
3
+ The pattern: each waiting merger holds an exclusive flock on its own ticket file
4
+ (sorted names give FIFO order) and the head of the queue holds the main lock. The
5
+ adapter reads lock holders from ``/proc/locks`` instead of acquiring a flock, so
6
+ watching never perturbs the queue. Processes are matched by command line: either
7
+ the executable itself is named ``process``, or an interpreter (``interpreters``
8
+ prefixes) runs a script named ``process``; the first positional argument after it
9
+ names the change. Wrapper processes (``uv run``, ``timeout``) are skipped.
10
+
11
+ ``history`` is an optional JSON Lines log with one object per finished attempt:
12
+ ``item`` and ``outcome`` are required; ``started_at`` and ``finished_at`` are
13
+ optional; every other key is shown in the inspector.
14
+
15
+ Paths may contain ``{git_dir}``, resolved from the Git checkout at ``root``. With
16
+ ``host`` set, the probe is sent to ``python3 -`` over SSH on stdin; nothing is
17
+ installed or written on the host.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import glob
24
+ import inspect
25
+ import json
26
+ import os
27
+ import subprocess
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+
31
+ from ..model import DEFAULT_VIEWS, SCHEMA
32
+ from ..process import check_host, over_ssh, run_json
33
+ from . import Adapter
34
+
35
+ OUTCOMES = {
36
+ "success": "success",
37
+ "merged": "success",
38
+ "failure": "failure",
39
+ "failed": "failure",
40
+ "refused": "failure",
41
+ "exception": "failure",
42
+ "error": "failure",
43
+ "cancelled": "cancelled",
44
+ }
45
+ HISTORY_KEYS = {"item", "outcome", "started_at", "finished_at"}
46
+
47
+
48
+ # Shipped to the queue host over stdin, so it must stay self-contained.
49
+ def flock_probe(options: dict, proc_root: str = "/proc") -> dict:
50
+ git_dir = ""
51
+ if options.get("root"):
52
+ git_dir = subprocess.run(
53
+ [
54
+ "git",
55
+ "-C",
56
+ os.path.expanduser(options["root"]),
57
+ "rev-parse",
58
+ "--absolute-git-dir",
59
+ ],
60
+ capture_output=True,
61
+ text=True,
62
+ check=True,
63
+ timeout=10,
64
+ ).stdout.strip()
65
+
66
+ def resolve(template):
67
+ if "{git_dir}" in template and not git_dir:
68
+ raise ValueError("{git_dir} needs the root option")
69
+ return os.path.expanduser(template.replace("{git_dir}", git_dir))
70
+
71
+ proc = Path(proc_root)
72
+ locks = {}
73
+ for line in (proc / "locks").read_text().splitlines():
74
+ fields = line.split()
75
+ if len(fields) < 6 or fields[1] != "FLOCK":
76
+ continue # Includes blocked lock requests (the -> rows).
77
+ device = fields[5].split(":")
78
+ if len(device) == 3:
79
+ locks[(int(device[0], 16), int(device[1], 16), int(device[2]))] = int(fields[4])
80
+
81
+ def holder(path):
82
+ try:
83
+ stat = os.stat(path)
84
+ except FileNotFoundError:
85
+ return None
86
+ return locks.get((os.major(stat.st_dev), os.minor(stat.st_dev), stat.st_ino))
87
+
88
+ def command(pid):
89
+ try:
90
+ raw = (proc / str(pid) / "cmdline").read_bytes()
91
+ except (FileNotFoundError, ProcessLookupError, PermissionError):
92
+ return []
93
+ return raw.decode("utf-8", errors="replace").rstrip("\0").split("\0")
94
+
95
+ name = options["process"]
96
+ interpreters = tuple(options.get("interpreters", ("python", "pypy")))
97
+ owner_flag = options.get("owner_flag")
98
+
99
+ def match(argv):
100
+ """Return (item, owner) when argv is the merger itself, not a wrapper."""
101
+ if not argv:
102
+ return None
103
+ if Path(argv[0]).name == name:
104
+ rest = argv[1:]
105
+ elif Path(argv[0]).name.startswith(interpreters):
106
+ for index, arg in enumerate(argv[1:], 1):
107
+ if Path(arg).name == name:
108
+ rest = argv[index + 1 :]
109
+ break
110
+ else:
111
+ return None
112
+ else:
113
+ return None
114
+ item = rest[0] if rest and not rest[0].startswith("-") else "unknown"
115
+ owner = None
116
+ if owner_flag and owner_flag in rest:
117
+ at = rest.index(owner_flag) + 1
118
+ owner = rest[at] if at < len(rest) else None
119
+ return item, owner
120
+
121
+ active = holder(resolve(options["lock"]))
122
+ rows, seen = [], set()
123
+ for ticket in sorted(glob.glob(resolve(options["tickets"]))):
124
+ pid = holder(ticket)
125
+ if pid is None:
126
+ continue # Abandoned tickets are not queue entries. Never delete them.
127
+ try:
128
+ modified = os.stat(ticket).st_mtime
129
+ except FileNotFoundError:
130
+ continue # A merger finished between observations.
131
+ item, owner = match(command(pid)) or ("unknown", None)
132
+ seen.add(pid)
133
+ rows.append(
134
+ {
135
+ "id": f"ticket:{Path(ticket).name}:{pid}",
136
+ "item": item,
137
+ "owner": owner,
138
+ "state": "integrating" if pid == active else "queued",
139
+ "position": len(rows) + 1,
140
+ "pid": pid,
141
+ "since": datetime.fromtimestamp(modified, UTC).isoformat(),
142
+ "ticket": Path(ticket).name,
143
+ }
144
+ )
145
+ # A holder without a ticket stays visible without an invented position.
146
+ if active is not None and active not in seen:
147
+ item, owner = match(command(active)) or ("unknown", None)
148
+ rows.insert(
149
+ 0,
150
+ {
151
+ "id": f"holder:{active}",
152
+ "item": item,
153
+ "owner": owner,
154
+ "state": "integrating",
155
+ "position": None,
156
+ "pid": active,
157
+ },
158
+ )
159
+ seen.add(active)
160
+ inaccessible = 0
161
+ for entry in proc.iterdir():
162
+ if not entry.name.isdigit() or int(entry.name) in seen:
163
+ continue
164
+ try:
165
+ if entry.stat().st_uid != os.getuid():
166
+ continue
167
+ raw = (entry / "cmdline").read_bytes()
168
+ except (FileNotFoundError, ProcessLookupError):
169
+ continue
170
+ except PermissionError:
171
+ inaccessible += 1
172
+ continue
173
+ info = match(raw.decode("utf-8", errors="replace").rstrip("\0").split("\0"))
174
+ if info is not None:
175
+ rows.append(
176
+ {
177
+ "id": f"process:{entry.name}",
178
+ "item": info[0],
179
+ "owner": info[1],
180
+ "state": "preparing",
181
+ "position": None,
182
+ "pid": int(entry.name),
183
+ }
184
+ )
185
+ limit = int(options.get("history_limit", 30))
186
+ recent, malformed = [], 0
187
+ if options.get("history"):
188
+ try:
189
+ with open(resolve(options["history"]), "rb") as stream:
190
+ size = stream.seek(0, 2)
191
+ offset = max(0, size - 262144)
192
+ stream.seek(offset)
193
+ if offset:
194
+ stream.readline() # Discard the potentially partial first record.
195
+ lines = stream.read().splitlines(keepends=True)
196
+ for line in lines:
197
+ if not line.endswith(b"\n"):
198
+ continue # A concurrent append is not a completed record.
199
+ try:
200
+ record = json.loads(line)
201
+ if not isinstance(record, dict) or "item" not in record:
202
+ raise ValueError("invalid history record")
203
+ recent.append(record)
204
+ except (ValueError, UnicodeDecodeError):
205
+ malformed += 1
206
+ except FileNotFoundError:
207
+ pass
208
+ warnings = []
209
+ if malformed:
210
+ warnings.append(f"Skipped {malformed} malformed history records")
211
+ if inaccessible:
212
+ warnings.append(f"Cannot inspect {inaccessible} same-user processes")
213
+ return {
214
+ "observed_at": datetime.now(UTC).isoformat(),
215
+ "queue": rows,
216
+ "history": list(reversed(recent[-limit:])),
217
+ "warnings": warnings,
218
+ "history_limit": limit,
219
+ }
220
+
221
+
222
+ PROBE_PRELUDE = (
223
+ "import glob, json, os, subprocess\n"
224
+ "from datetime import UTC, datetime\n"
225
+ "from pathlib import Path\n"
226
+ )
227
+
228
+
229
+ class FlockQueue(Adapter):
230
+ name = "flock-queue"
231
+ label = "Integration"
232
+ interval = 5.0
233
+
234
+ def __init__(
235
+ self,
236
+ *,
237
+ lock: str,
238
+ tickets: str,
239
+ process: str,
240
+ root: str | None = None,
241
+ host: str | None = None,
242
+ interpreters: list[str] | None = None,
243
+ owner_flag: str | None = None,
244
+ history: str | None = None,
245
+ history_limit: int = 30,
246
+ history_columns: list[dict] | None = None,
247
+ ):
248
+ self.host = check_host(host)
249
+ self.options = {
250
+ "root": root,
251
+ "lock": lock,
252
+ "tickets": tickets,
253
+ "process": process,
254
+ "owner_flag": owner_flag,
255
+ "history": history,
256
+ "history_limit": int(history_limit),
257
+ }
258
+ if interpreters is not None:
259
+ self.options["interpreters"] = list(interpreters)
260
+ self.history_columns = list(history_columns or [])
261
+
262
+ async def probe(self) -> dict:
263
+ if self.host is None:
264
+ return await asyncio.to_thread(flock_probe, self.options)
265
+ source = PROBE_PRELUDE + inspect.getsource(flock_probe)
266
+ source += f"\nprint(json.dumps(flock_probe(json.loads({json.dumps(self.options)!r}))))\n"
267
+ return await run_json(over_ssh(self.host, ["python3", "-"]), stdin=source)
268
+
269
+ async def snapshot(self) -> dict:
270
+ raw = await self.probe()
271
+ records = []
272
+ for row in raw["queue"]:
273
+ records.append(
274
+ {
275
+ "id": row["id"],
276
+ "kind": "queue_entry",
277
+ "state": "queued" if row["state"] == "queued" else "running",
278
+ "raw_state": row["state"],
279
+ "title": row["item"],
280
+ "owner": row.get("owner"),
281
+ "position": row.get("position"),
282
+ "created_at": row.get("since"),
283
+ "fields": {key: row[key] for key in ("pid", "ticket") if key in row},
284
+ }
285
+ )
286
+ for index, entry in enumerate(raw["history"]):
287
+ outcome = str(entry.get("outcome", "unknown"))
288
+ records.append(
289
+ {
290
+ "id": f"attempt:{index}:{entry.get('item')}:{entry.get('finished_at')}",
291
+ "kind": "attempt",
292
+ "state": OUTCOMES.get(outcome, "unknown"),
293
+ "raw_state": outcome,
294
+ "title": str(entry["item"]),
295
+ "started_at": timestamp(entry.get("started_at")),
296
+ "finished_at": timestamp(entry.get("finished_at")),
297
+ "fields": {
298
+ key: value for key, value in entry.items() if key not in HISTORY_KEYS
299
+ },
300
+ }
301
+ )
302
+ attempts = dict(DEFAULT_VIEWS["attempt"])
303
+ attempts["columns"] = attempts["columns"] + self.history_columns
304
+ return {
305
+ "schema": SCHEMA,
306
+ "observed_at": raw["observed_at"],
307
+ "records": records,
308
+ "views": [DEFAULT_VIEWS["queue_entry"], attempts],
309
+ "warnings": raw["warnings"],
310
+ "note": "FIFO positions are observed, not reserved. Preparing mergers hold "
311
+ f"no ticket yet. History is the last {raw['history_limit']} attempts "
312
+ "within a 256 KiB log tail.",
313
+ }
314
+
315
+
316
+ def timestamp(value) -> str | None:
317
+ """History logs are written by other tools; drop unparseable times."""
318
+ try:
319
+ parsed = datetime.fromisoformat(value)
320
+ except (TypeError, ValueError):
321
+ return None
322
+ return parsed.isoformat() if parsed.tzinfo else None
@@ -0,0 +1,164 @@
1
+ """GitHub Actions workflow runs through the authenticated ``gh`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from datetime import UTC, datetime
8
+
9
+ from .. import process
10
+ from ..model import SCHEMA
11
+ from . import Adapter
12
+
13
+ ACTIVE_STATUSES = ("queued", "in_progress", "waiting", "pending", "requested")
14
+ STATES = {
15
+ "queued": "queued",
16
+ "pending": "queued",
17
+ "requested": "queued",
18
+ "waiting": "blocked",
19
+ "action_required": "blocked",
20
+ "in_progress": "running",
21
+ "success": "success",
22
+ "failure": "failure",
23
+ "timed_out": "failure",
24
+ "startup_failure": "failure",
25
+ "cancelled": "cancelled",
26
+ "skipped": "neutral",
27
+ "neutral": "neutral",
28
+ "stale": "neutral",
29
+ }
30
+ PAGE = 100
31
+
32
+
33
+ def state_of(item: dict) -> tuple[str, str]:
34
+ raw = item.get("conclusion") or item.get("status") or "unknown"
35
+ return STATES.get(raw, "unknown"), raw
36
+
37
+
38
+ class GitHubActions(Adapter):
39
+ name = "github-actions"
40
+ label = "CI"
41
+ interval = 15.0
42
+
43
+ def __init__(self, *, repo: str):
44
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
45
+ raise ValueError(f"repo must be owner/repo, got {repo!r}")
46
+ self.repo = repo
47
+
48
+ async def snapshot(self) -> dict:
49
+ # Fetch each active state separately: an old queued run must not disappear
50
+ # behind a page of newer completed runs. GitHub caps each search at 1000.
51
+ async def fetch(status):
52
+ endpoint = f"repos/{self.repo}/actions/runs?per_page={PAGE}"
53
+ if status:
54
+ endpoint += "&status=" + status
55
+ return await process.run_json(["gh", "api", endpoint])
56
+
57
+ statuses = (*ACTIVE_STATUSES, None)
58
+ pages = await asyncio.gather(*(fetch(status) for status in statuses))
59
+ runs: dict[int, dict] = {}
60
+ warnings = []
61
+ for status, page in zip(statuses, pages, strict=True):
62
+ if status and page.get("total_count", 0) > PAGE:
63
+ warnings.append(f"{status}: showing first {PAGE} of {page['total_count']} runs")
64
+ for run in page["workflow_runs"]:
65
+ previous = runs.get(run["id"])
66
+ identity = (run.get("run_attempt") or 0, run.get("updated_at") or "")
67
+ if previous is None or identity >= (
68
+ previous.get("run_attempt") or 0,
69
+ previous.get("updated_at") or "",
70
+ ):
71
+ runs[run["id"]] = run
72
+ ordered = sorted(
73
+ runs.values(),
74
+ key=lambda run: (run.get("status") == "completed", -int(run["id"])),
75
+ )
76
+ return {
77
+ "schema": SCHEMA,
78
+ "observed_at": datetime.now(UTC).isoformat(),
79
+ "records": [self.record(run) for run in ordered],
80
+ "views": [
81
+ {
82
+ "kind": "run",
83
+ "label": "Runs",
84
+ "columns": [
85
+ {"key": "state", "label": "State", "width": 12, "format": "state"},
86
+ {"key": "title", "label": "Workflow / title", "width": 25},
87
+ {"key": "ref", "label": "Branch", "width": 18},
88
+ {"key": "sha", "label": "SHA", "width": 8, "format": "sha"},
89
+ {"key": "created_at", "label": "Age", "width": 9, "format": "age"},
90
+ ],
91
+ }
92
+ ],
93
+ "warnings": warnings,
94
+ "note": f"Up to {PAGE} runs per active state plus the {PAGE} most recent.",
95
+ }
96
+
97
+ @staticmethod
98
+ def record(run: dict) -> dict:
99
+ state, raw = state_of(run)
100
+ return {
101
+ "id": str(run["id"]),
102
+ "kind": "run",
103
+ "state": state,
104
+ "raw_state": raw,
105
+ "title": run.get("name") or run.get("display_title") or str(run["id"]),
106
+ "subtitle": run.get("display_title"),
107
+ "ref": run.get("head_branch"),
108
+ "sha": run.get("head_sha"),
109
+ "created_at": iso(run.get("created_at")),
110
+ "updated_at": iso(run.get("updated_at")),
111
+ "url": run.get("html_url"),
112
+ "fields": {"run": run["id"], "attempt": run.get("run_attempt")},
113
+ }
114
+
115
+ async def children(self, record: dict) -> list[dict]:
116
+ # Paginate so large platform matrices are not silently truncated.
117
+ pages = await process.run_json(
118
+ [
119
+ "gh",
120
+ "api",
121
+ "--paginate",
122
+ "--slurp",
123
+ f"repos/{self.repo}/actions/runs/{int(record['id'])}/jobs?per_page={PAGE}",
124
+ ]
125
+ )
126
+ jobs = []
127
+ for job in (job for page in pages for job in page["jobs"]):
128
+ state, raw = state_of(job)
129
+ steps = []
130
+ for step in job.get("steps") or []:
131
+ step_state, step_raw = state_of(step)
132
+ steps.append(
133
+ {
134
+ "id": f"{job['id']}:{step.get('number')}",
135
+ "kind": "step",
136
+ "state": step_state,
137
+ "raw_state": step_raw,
138
+ "title": step.get("name") or "step",
139
+ "started_at": iso(step.get("started_at")),
140
+ "finished_at": iso(step.get("completed_at")),
141
+ }
142
+ )
143
+ jobs.append(
144
+ {
145
+ "id": str(job["id"]),
146
+ "kind": "job",
147
+ "state": state,
148
+ "raw_state": raw,
149
+ "title": job.get("name") or str(job["id"]),
150
+ "started_at": iso(job.get("started_at")),
151
+ "finished_at": iso(job.get("completed_at")),
152
+ "url": job.get("html_url"),
153
+ "fields": {"runner": job.get("runner_name")},
154
+ "children": steps,
155
+ }
156
+ )
157
+ return jobs
158
+
159
+
160
+ def iso(value: str | None) -> str | None:
161
+ """GitHub returns ``Z`` suffixes; normalize to an explicit offset."""
162
+ if not value:
163
+ return None
164
+ return datetime.fromisoformat(value).isoformat()