glab-cli-extras 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.
Files changed (44) hide show
  1. glab_cli_extras/__init__.py +3 -0
  2. glab_cli_extras/artifacts/__init__.py +0 -0
  3. glab_cli_extras/artifacts/job_logs.py +95 -0
  4. glab_cli_extras/artifacts/threads.py +147 -0
  5. glab_cli_extras/cli/__init__.py +0 -0
  6. glab_cli_extras/cli/watch.py +426 -0
  7. glab_cli_extras/daemon/__init__.py +0 -0
  8. glab_cli_extras/daemon/__main__.py +10 -0
  9. glab_cli_extras/daemon/engine.py +491 -0
  10. glab_cli_extras/daemon/lifecycle.py +186 -0
  11. glab_cli_extras/daemon/locks.py +85 -0
  12. glab_cli_extras/daemon/scheduler.py +52 -0
  13. glab_cli_extras/daemon/server.py +79 -0
  14. glab_cli_extras/daemon/transport.py +185 -0
  15. glab_cli_extras/events.py +165 -0
  16. glab_cli_extras/gitlab/__init__.py +0 -0
  17. glab_cli_extras/gitlab/budget.py +135 -0
  18. glab_cli_extras/gitlab/client.py +225 -0
  19. glab_cli_extras/gitlab/glab.py +175 -0
  20. glab_cli_extras/gitlab/queries/__init__.py +11 -0
  21. glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
  22. glab_cli_extras/gitlab/queries/mr.graphql +42 -0
  23. glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
  24. glab_cli_extras/gitlab/refs.py +151 -0
  25. glab_cli_extras/paths.py +79 -0
  26. glab_cli_extras/py.typed +0 -0
  27. glab_cli_extras/resources/__init__.py +19 -0
  28. glab_cli_extras/resources/base.py +146 -0
  29. glab_cli_extras/resources/branch.py +265 -0
  30. glab_cli_extras/resources/mr.py +445 -0
  31. glab_cli_extras/resources/mr_list.py +150 -0
  32. glab_cli_extras/resources/pipeline.py +319 -0
  33. glab_cli_extras/serde.py +75 -0
  34. glab_cli_extras/watch/__init__.py +0 -0
  35. glab_cli_extras/watch/changelog.py +86 -0
  36. glab_cli_extras/watch/claude.py +193 -0
  37. glab_cli_extras/watch/client.py +316 -0
  38. glab_cli_extras/watch/sinks.py +83 -0
  39. glab_cli_extras/watch/streams.py +80 -0
  40. glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
  41. glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
  42. glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
  43. glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
  44. glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,319 @@
1
+ """Pipeline watching through GitLab's GraphQL ETag cache.
2
+
3
+ Each poll sends the whole pipeline-tree query with `X-GITLAB-GRAPHQL-RESOURCE-ETAG: pipelines/id/<id>` and
4
+ `If-None-Match`. GitLab answers an unchanged pipeline with a 304 from its cache before running the query, and touches
5
+ the key on every job transition of the pipeline and its downstream pipelines. So one request is both the probe and
6
+ the fetch. If the server sends no ETag, the poller falls back to plain polling at a slower interval.
7
+
8
+ A job that ends and needs its log (per `--logs`) holds back its `job_finished` event until the log is in the cache,
9
+ a few seconds later, so the event can carry the log path.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field, replace
16
+ from typing import Any, ClassVar
17
+
18
+ from glab_cli_extras.artifacts.job_logs import fetch_job_log, wants_log
19
+ from glab_cli_extras.events import RawEvent
20
+ from glab_cli_extras.gitlab import queries
21
+ from glab_cli_extras.gitlab.client import ApiError, NotFound
22
+ from glab_cli_extras.resources.base import Poller, gid_to_int, lower
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ FINAL_JOB_STATUSES = frozenset({"success", "failed", "canceled", "skipped"})
27
+ FINAL_PIPELINE_STATUSES = frozenset({"success", "failed", "canceled", "skipped"})
28
+ QUEUED_PIPELINE_STATUSES = frozenset({"created", "pending", "waiting_for_resource", "preparing", "scheduled"})
29
+ LOG_FETCH_DELAY = 5.0
30
+ """Wait after a job ends, so GitLab can archive the trace first."""
31
+ LOG_FETCH_ATTEMPTS = 3
32
+
33
+ INTERVAL_RUNNING = 5.0
34
+ INTERVAL_QUEUED = 30.0
35
+ INTERVAL_RUNNING_NO_ETAG = 15.0
36
+ INTERVAL_QUEUED_NO_ETAG = 60.0
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class JobState:
41
+ id: int
42
+ name: str
43
+ stage: str | None
44
+ status: str
45
+ allow_failure: bool
46
+ pipeline_id: int
47
+ project: str
48
+ started_at: str | None = None
49
+ finished_at: str | None = None
50
+ trace_size: int | None = None
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class DownstreamState:
55
+ id: int
56
+ iid: int | None
57
+ status: str
58
+ project: str
59
+ web_url: str | None = None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class PipelineSnapshot:
64
+ id: int
65
+ iid: int | None
66
+ project: str
67
+ status: str
68
+ ref: str | None
69
+ sha: str | None
70
+ web_url: str | None
71
+ jobs: tuple[JobState, ...] = ()
72
+ downstream: tuple[DownstreamState, ...] = ()
73
+
74
+
75
+ def _web_url(host: str, path: Any) -> str | None:
76
+ return f"https://{host}{path}" if path else None
77
+
78
+
79
+ def _trace_size(job: dict[str, Any]) -> int | None:
80
+ for artifact in (job.get("artifacts") or {}).get("nodes") or []:
81
+ if lower(artifact.get("fileType")) == "trace" and isinstance(artifact.get("size"), int):
82
+ return int(artifact["size"])
83
+ return None
84
+
85
+
86
+ def _parse_jobs(nodes: list[Any], pipeline_id: int, project: str) -> list[JobState]:
87
+ jobs = []
88
+ for node in nodes:
89
+ jobs.append(
90
+ JobState(
91
+ id=gid_to_int(node["id"]),
92
+ name=str(node.get("name") or ""),
93
+ stage=(node.get("stage") or {}).get("name"),
94
+ status=lower(node.get("status")),
95
+ allow_failure=bool(node.get("allowFailure")),
96
+ pipeline_id=pipeline_id,
97
+ project=project,
98
+ started_at=node.get("startedAt"),
99
+ finished_at=node.get("finishedAt"),
100
+ trace_size=_trace_size(node),
101
+ )
102
+ )
103
+ return jobs
104
+
105
+
106
+ def parse_pipeline(data: Any, host: str, project: str) -> PipelineSnapshot:
107
+ """Build a snapshot from the `PipelineTree` query result. Raises NotFound when the pipeline is missing."""
108
+ node = ((data or {}).get("project") or {}).get("pipeline")
109
+ if not node:
110
+ raise NotFound(f"pipeline not found in {project}", 404)
111
+ pipeline_id = gid_to_int(node["id"])
112
+ jobs = _parse_jobs((node.get("jobs") or {}).get("nodes") or [], pipeline_id, project)
113
+ downstream = []
114
+ for child in (node.get("downstream") or {}).get("nodes") or []:
115
+ child_id = gid_to_int(child["id"])
116
+ child_project = str((child.get("project") or {}).get("fullPath") or project)
117
+ downstream.append(
118
+ DownstreamState(
119
+ id=child_id,
120
+ iid=int(child["iid"]) if child.get("iid") else None,
121
+ status=lower(child.get("status")),
122
+ project=child_project,
123
+ web_url=_web_url(host, child.get("path")),
124
+ )
125
+ )
126
+ jobs.extend(_parse_jobs((child.get("jobs") or {}).get("nodes") or [], child_id, child_project))
127
+ return PipelineSnapshot(
128
+ id=pipeline_id,
129
+ iid=int(node["iid"]) if node.get("iid") else None,
130
+ project=project,
131
+ status=lower(node.get("status")),
132
+ ref=node.get("ref"),
133
+ sha=node.get("sha"),
134
+ web_url=_web_url(host, node.get("path")),
135
+ jobs=tuple(sorted(jobs, key=lambda j: j.id)),
136
+ downstream=tuple(sorted(downstream, key=lambda d: d.id)),
137
+ )
138
+
139
+
140
+ def _job_data(job: JobState) -> dict[str, Any]:
141
+ data: dict[str, Any] = {
142
+ "pipeline_id": job.pipeline_id,
143
+ "job_id": job.id,
144
+ "name": job.name,
145
+ "stage": job.stage,
146
+ "status": job.status,
147
+ }
148
+ if job.allow_failure:
149
+ data["allow_failure"] = True
150
+ return data
151
+
152
+
153
+ def diff_pipeline(prev: PipelineSnapshot, cur: PipelineSnapshot) -> list[RawEvent]:
154
+ """Events between two snapshots of one pipeline tree: job transitions first, then the pipeline's own status."""
155
+ events: list[RawEvent] = []
156
+ known_downstream = {d.id for d in prev.downstream}
157
+ for child in cur.downstream:
158
+ if child.id not in known_downstream:
159
+ events.append(
160
+ RawEvent(
161
+ "downstream_created",
162
+ {
163
+ "pipeline_id": cur.id,
164
+ "downstream_pipeline_id": child.id,
165
+ "project": child.project,
166
+ "status": child.status,
167
+ "web_url": child.web_url,
168
+ },
169
+ )
170
+ )
171
+ old_jobs = {j.id: j for j in prev.jobs}
172
+ for job in cur.jobs:
173
+ old = old_jobs.get(job.id)
174
+ old_status = old.status if old else None
175
+ if old_status == job.status:
176
+ continue
177
+ if job.status == "running" and old_status != "running":
178
+ events.append(RawEvent("job_started", _job_data(job)))
179
+ elif job.status in FINAL_JOB_STATUSES and job.status != "skipped" and old_status not in FINAL_JOB_STATUSES:
180
+ events.append(RawEvent("job_finished", _job_data(job)))
181
+ if prev.status != cur.status:
182
+ events.append(RawEvent("status_changed", {"pipeline_id": cur.id, "from": prev.status, "to": cur.status}))
183
+ if cur.status in FINAL_PIPELINE_STATUSES and prev.status not in FINAL_PIPELINE_STATUSES:
184
+ events.append(RawEvent("finished", {"pipeline_id": cur.id, "status": cur.status, "web_url": cur.web_url}))
185
+ return events
186
+
187
+
188
+ @dataclass
189
+ class _PendingLog:
190
+ job: JobState
191
+ event: RawEvent
192
+ due: float
193
+ attempts: int = 0
194
+
195
+
196
+ @dataclass
197
+ class _EtagState:
198
+ etag: str | None = None
199
+ supported: bool = True
200
+ pending_logs: dict[int, _PendingLog] = field(default_factory=dict)
201
+
202
+
203
+ class PipelinePoller(Poller[PipelineSnapshot]):
204
+ kind: ClassVar = "pipeline"
205
+ snapshot_type: ClassVar = PipelineSnapshot
206
+
207
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
208
+ super().__init__(*args, **kwargs)
209
+ self._state = _EtagState()
210
+
211
+ @property
212
+ def pipeline_final(self) -> bool:
213
+ return self.snapshot is not None and self.snapshot.status in FINAL_PIPELINE_STATUSES
214
+
215
+ async def _fetch(self) -> PipelineSnapshot | None:
216
+ assert self.ref.id is not None
217
+ result = await self.ctx.client.graphql(
218
+ queries.load("pipeline"),
219
+ {"project": self.ref.project, "id": f"gid://gitlab/Ci::Pipeline/{self.ref.id}"},
220
+ etag_resource=f"pipelines/id/{self.ref.id}" if self._state.supported else None,
221
+ if_none_match=self._state.etag,
222
+ kind="pipeline",
223
+ )
224
+ if result.not_modified:
225
+ return None
226
+ if self._state.supported and not result.etag:
227
+ log.info("%s: no ETag from GitLab, falling back to plain polling", self.ref.key)
228
+ self._state.supported = False
229
+ self._state.etag = result.etag
230
+ return parse_pipeline(result.data, self.ref.host, self.ref.project)
231
+
232
+ async def poll(self) -> list[RawEvent]:
233
+ events: list[RawEvent] = []
234
+ if not self.pipeline_final or self.snapshot is None:
235
+ cur = await self._fetch()
236
+ if cur is not None:
237
+ prev = self.snapshot
238
+ self.snapshot = cur
239
+ if prev is not None:
240
+ events.extend(self._hold_back_for_logs(diff_pipeline(prev, cur)))
241
+ events.extend(await self._fetch_due_logs())
242
+ if self.pipeline_final and not self._state.pending_logs and self.snapshot is not None:
243
+ self.terminal_reason = "finished"
244
+ self.terminal_data = {"status": self.snapshot.status}
245
+ return events
246
+
247
+ def _hold_back_for_logs(self, events: list[RawEvent]) -> list[RawEvent]:
248
+ if self.options.logs == "none" or self.snapshot is None:
249
+ return events
250
+ jobs = {j.id: j for j in self.snapshot.jobs}
251
+ released = []
252
+ now = self.ctx.clock()
253
+ for event in events:
254
+ job = jobs.get(int(event.data.get("job_id", 0))) if event.kind == "job_finished" else None
255
+ if job is not None and wants_log(self.options.logs, job.status):
256
+ self._state.pending_logs[job.id] = _PendingLog(job, event, now + LOG_FETCH_DELAY)
257
+ else:
258
+ released.append(event)
259
+ return released
260
+
261
+ async def _fetch_due_logs(self) -> list[RawEvent]:
262
+ now = self.ctx.clock()
263
+ released = []
264
+ for job_id, pending in sorted(self._state.pending_logs.items()):
265
+ if pending.due > now:
266
+ continue
267
+ pending.attempts += 1
268
+ try:
269
+ fetched = await fetch_job_log(
270
+ self.ctx.client,
271
+ self.ctx.cache_root,
272
+ pending.job.project,
273
+ pending.job.id,
274
+ pending.job.name,
275
+ pending.job.trace_size,
276
+ )
277
+ except (ApiError, OSError) as exc:
278
+ if pending.attempts < LOG_FETCH_ATTEMPTS:
279
+ pending.due = now + LOG_FETCH_DELAY * pending.attempts
280
+ continue
281
+ data = {**pending.event.data, "log_error": str(exc)}
282
+ released.append(replace(pending.event, data=data))
283
+ else:
284
+ data = {**pending.event.data, "log_truncated": fetched.truncated}
285
+ released.append(replace(pending.event, data=data, artifacts=(str(fetched.path),)))
286
+ del self._state.pending_logs[job_id]
287
+ return released
288
+
289
+ def next_delay(self) -> float:
290
+ if self._state.pending_logs:
291
+ now = self.ctx.clock()
292
+ soonest = min(p.due for p in self._state.pending_logs.values())
293
+ return max(0.5, min(soonest - now, INTERVAL_RUNNING))
294
+ queued = self.snapshot is not None and self.snapshot.status in QUEUED_PIPELINE_STATUSES
295
+ if self._state.supported:
296
+ return INTERVAL_QUEUED if queued else INTERVAL_RUNNING
297
+ return INTERVAL_QUEUED_NO_ETAG if queued else INTERVAL_RUNNING_NO_ETAG
298
+
299
+ def expected_cost(self) -> float:
300
+ # Most ETag probes are 304s, which the budget charges at 0.1.
301
+ return 0.3 if self._state.supported else 1.0
302
+
303
+ def summary(self) -> dict[str, Any]:
304
+ snap = self.snapshot
305
+ if snap is None:
306
+ return {}
307
+ counts: dict[str, int] = {}
308
+ for job in snap.jobs:
309
+ counts[job.status] = counts.get(job.status, 0) + 1
310
+ return {
311
+ "pipeline_id": snap.id,
312
+ "status": snap.status,
313
+ "ref": snap.ref,
314
+ "sha": snap.sha,
315
+ "web_url": snap.web_url,
316
+ "jobs": dict(sorted(counts.items())),
317
+ "downstream": [d.id for d in snap.downstream],
318
+ "failed_jobs": [j.name for j in snap.jobs if j.status == "failed" and not j.allow_failure],
319
+ }
@@ -0,0 +1,75 @@
1
+ """JSON conversion for the frozen snapshot dataclasses.
2
+
3
+ Snapshots travel from the daemon to the client (which stores the last one in `state.json`) and back after a daemon
4
+ restart, so the new daemon diffs against what the client saw last. Only the types snapshots use are supported:
5
+ dataclasses, `tuple[X, ...]`, `dict[str, X]`, `X | None` and JSON scalars.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import hashlib
12
+ import json
13
+ import types
14
+ import typing
15
+ from typing import Any, TypeVar, get_args, get_origin, get_type_hints
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def to_jsonable(value: Any) -> Any:
21
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
22
+ return {f.name: to_jsonable(getattr(value, f.name)) for f in dataclasses.fields(value)}
23
+ if isinstance(value, list | tuple):
24
+ return [to_jsonable(item) for item in value]
25
+ if isinstance(value, dict):
26
+ return {str(k): to_jsonable(v) for k, v in value.items()}
27
+ return value
28
+
29
+
30
+ def _convert(tp: Any, data: Any) -> Any:
31
+ if tp is Any:
32
+ return data
33
+ origin = get_origin(tp)
34
+ if origin in (typing.Union, types.UnionType):
35
+ options = [a for a in get_args(tp) if a is not type(None)]
36
+ if data is None:
37
+ return None
38
+ return _convert(options[0], data) if len(options) == 1 else data
39
+ if origin is tuple:
40
+ args = get_args(tp)
41
+ if not isinstance(data, list | tuple):
42
+ raise ValueError(f"expected a list for {tp}, got {type(data).__name__}")
43
+ if len(args) == 2 and args[1] is Ellipsis:
44
+ return tuple(_convert(args[0], item) for item in data)
45
+ return tuple(_convert(a, item) for a, item in zip(args, data, strict=True))
46
+ if origin is dict:
47
+ _, value_type = get_args(tp)
48
+ if not isinstance(data, dict):
49
+ raise ValueError(f"expected an object for {tp}")
50
+ return {str(k): _convert(value_type, v) for k, v in data.items()}
51
+ if isinstance(tp, type) and dataclasses.is_dataclass(tp):
52
+ if not isinstance(data, dict):
53
+ raise ValueError(f"expected an object for {tp.__name__}")
54
+ hints = get_type_hints(tp)
55
+ kwargs = {f.name: _convert(hints[f.name], data[f.name]) for f in dataclasses.fields(tp) if f.name in data}
56
+ return tp(**kwargs)
57
+ if tp is float and isinstance(data, int | float) and not isinstance(data, bool):
58
+ return float(data)
59
+ if tp in (int, str, bool, float) and not isinstance(data, tp):
60
+ raise ValueError(f"expected {tp.__name__}, got {type(data).__name__}")
61
+ return data
62
+
63
+
64
+ def from_jsonable(cls: type[T], data: Any) -> T:
65
+ """Rebuild a dataclass from `to_jsonable` output. Raises ValueError on a shape mismatch."""
66
+ try:
67
+ result = _convert(cls, data)
68
+ except (KeyError, TypeError) as exc:
69
+ raise ValueError(f"cannot load {cls.__name__}: {exc}") from exc
70
+ return typing.cast(T, result)
71
+
72
+
73
+ def stable_hash(value: Any) -> str:
74
+ canonical = json.dumps(to_jsonable(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
75
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
File without changes
@@ -0,0 +1,86 @@
1
+ """The client's per-resource changelog: `events.jsonl` (append-only) and `state.json` (the last snapshot).
2
+
3
+ The stored snapshot lets a watcher resubscribe after a daemon restart without a fake "something changed": the new
4
+ daemon diffs against it, and the client drops the new `watch_started` when the snapshot hash did not move.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from glab_cli_extras.events import Event, utc_now_iso
17
+ from glab_cli_extras.gitlab.refs import ResourceRef
18
+ from glab_cli_extras.paths import project_dir, sanitize_part
19
+
20
+
21
+ def resource_dir_name(ref: ResourceRef) -> str:
22
+ if ref.kind in ("mr", "pipeline"):
23
+ return f"{ref.kind}-{ref.id}"
24
+ if ref.kind == "branch":
25
+ return f"branch-{sanitize_part(ref.branch or '')}"
26
+ return ref.kind
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class StoredState:
31
+ snapshot_hash: str | None
32
+ snapshot: Any
33
+
34
+
35
+ class Changelog:
36
+ def __init__(self, root: Path, ref: ResourceRef) -> None:
37
+ self.ref = ref
38
+ self.dir = project_dir(root, ref.host, ref.project) / resource_dir_name(ref)
39
+ self.events_path = self.dir / "events.jsonl"
40
+ self.state_path = self.dir / "state.json"
41
+
42
+ def append(self, events: Iterable[Event]) -> None:
43
+ lines = [json.dumps(e.to_json(), ensure_ascii=False, separators=(",", ":")) + "\n" for e in events]
44
+ if not lines:
45
+ return
46
+ self.dir.mkdir(parents=True, exist_ok=True)
47
+ with open(self.events_path, "a", encoding="utf-8") as fh:
48
+ fh.writelines(lines)
49
+
50
+ def last_seq(self) -> int:
51
+ """The `seq` of the last event in events.jsonl, 0 when there is none."""
52
+ try:
53
+ with open(self.events_path, "rb") as fh:
54
+ fh.seek(0, os.SEEK_END)
55
+ size = fh.tell()
56
+ fh.seek(max(0, size - 256 * 1024))
57
+ tail = fh.read()
58
+ except OSError:
59
+ return 0
60
+ for line in reversed(tail.splitlines()):
61
+ try:
62
+ return int(json.loads(line)["seq"])
63
+ except (ValueError, KeyError, TypeError):
64
+ continue
65
+ return 0
66
+
67
+ def load_state(self) -> StoredState | None:
68
+ try:
69
+ raw = json.loads(self.state_path.read_text(encoding="utf-8"))
70
+ except (OSError, ValueError):
71
+ return None
72
+ if not isinstance(raw, dict) or raw.get("resource") != self.ref.key:
73
+ return None
74
+ return StoredState(raw.get("snapshot_hash"), raw.get("snapshot"))
75
+
76
+ def save_state(self, snapshot_hash: str, snapshot: Any) -> None:
77
+ self.dir.mkdir(parents=True, exist_ok=True)
78
+ payload = {
79
+ "resource": self.ref.key,
80
+ "snapshot_hash": snapshot_hash,
81
+ "snapshot": snapshot,
82
+ "written_at": utc_now_iso(),
83
+ }
84
+ tmp = self.state_path.with_suffix(".tmp")
85
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
86
+ os.replace(tmp, self.state_path)
@@ -0,0 +1,193 @@
1
+ """The `claude-message` stream: events go to the Claude Code session that started the watcher.
2
+
3
+ The watcher finds its session through the cc-blackbox SDK and sends user messages over the session's messaging
4
+ socket. Events received close together go out as one message: the first event of a burst waits up to 3 s for the
5
+ rest. Each message is a `<glab-cli-extras-watcher>` block, because the receiver shows the content to the model but
6
+ never the sender label. `seq` keeps every body unique, so the receiver's duplicate check never drops a real event.
7
+
8
+ Delivery is best effort: the receiver may rate-limit a message, or hold it until its user approves it. The changelog
9
+ always has every event.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from collections.abc import Callable
18
+ from html import escape
19
+ from pathlib import Path
20
+
21
+ from cc_blackbox import (
22
+ ActiveSession,
23
+ ClaudeBlackboxError,
24
+ MessageTooLarge,
25
+ MessagingUnavailable,
26
+ Priority,
27
+ RateLimitedSender,
28
+ SessionGone,
29
+ UnsupportedProtocol,
30
+ current_session,
31
+ )
32
+
33
+ from glab_cli_extras.events import Event
34
+ from glab_cli_extras.gitlab.refs import ResourceRef
35
+ from glab_cli_extras.watch.sinks import StatusOutput, StdoutSink
36
+
37
+ BURST_WAIT = 3.0
38
+ MAX_CONTENT_BYTES = 64 * 1024
39
+ LIVENESS_INTERVAL = 30.0
40
+ FLUSH_TIMEOUT = 10.0
41
+
42
+
43
+ def sender_name(ref: ResourceRef) -> str:
44
+ """The name Claude Code shows for the sender: `glab-watch mr group/project!123`."""
45
+ short = ref.short.split("/", 1)[1] if "/" in ref.short else ref.short
46
+ return f"glab-watch {ref.kind} {short}"
47
+
48
+
49
+ def _event_line(event: Event) -> str:
50
+ payload: dict[str, object] = {"kind": event.kind, "seq": event.seq, "data": event.data}
51
+ if event.artifacts:
52
+ payload["artifacts"] = list(event.artifacts)
53
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
54
+
55
+
56
+ def format_message(ref: ResourceRef, events: list[Event], changelog: Path) -> str:
57
+ """One message for a batch of events. Above 64 KB the body becomes a count per kind; the changelog has it all."""
58
+ seqs = [e.seq for e in events]
59
+ seq = str(seqs[0]) if min(seqs) == max(seqs) else f"{min(seqs)}-{max(seqs)}"
60
+ head = f'<glab-cli-extras-watcher resource="{escape(ref.key)}" seq="{seq}" changelog="{escape(str(changelog))}">'
61
+ tail = "</glab-cli-extras-watcher>"
62
+ body = "\n".join(_event_line(e) for e in events)
63
+ content = f"{head}\n{body}\n{tail}"
64
+ if len(content.encode("utf-8")) <= MAX_CONTENT_BYTES:
65
+ return content
66
+ counts: dict[str, int] = {}
67
+ for event in events:
68
+ counts[event.kind] = counts.get(event.kind, 0) + 1
69
+ summary = json.dumps(
70
+ {"truncated": True, "events": len(events), "kinds": counts, "read": str(changelog)}, separators=(",", ":")
71
+ )
72
+ return f"{head}\n{summary}\n{tail}"
73
+
74
+
75
+ class ClaudeMessageSink:
76
+ name = "claude-message"
77
+
78
+ def __init__(
79
+ self,
80
+ session: ActiveSession,
81
+ ref: ResourceRef,
82
+ changelog: Path,
83
+ priority: Priority,
84
+ status: StatusOutput,
85
+ *,
86
+ sender: RateLimitedSender | None = None,
87
+ clock: Callable[[], float] = time.monotonic,
88
+ session_alive: Callable[[], bool] | None = None,
89
+ ) -> None:
90
+ if session.messaging_socket is None:
91
+ raise MessagingUnavailable("the session has messaging turned off")
92
+ self.session = session
93
+ self.ref = ref
94
+ self.changelog = changelog
95
+ self.priority = priority
96
+ self.status = status
97
+ self.envelope_name = sender_name(ref)
98
+ self.sender = sender or RateLimitedSender(
99
+ f"glab-watch:{os.getpid()}", sender_name=self.envelope_name, priority=priority
100
+ )
101
+ self._socket = session.messaging_socket
102
+ self._clock = clock
103
+ self._session_alive = session_alive or _session_still_current
104
+ self._buffer: list[Event] = []
105
+ self._deadline: float | None = None
106
+ self._gone = False
107
+ self._fallback: StdoutSink | None = None
108
+ self._last_liveness = clock()
109
+
110
+ def describe(self) -> str:
111
+ who = self.session.name or self.session.address
112
+ return (
113
+ f"claude-message to session {who!r} via {self._socket} "
114
+ f"(priority {self.priority}, sender {self.envelope_name!r})"
115
+ )
116
+
117
+ def emit(self, events: list[Event]) -> None:
118
+ if not events:
119
+ return
120
+ if self._fallback is not None:
121
+ self._fallback.emit(events)
122
+ return
123
+ self._buffer.extend(events)
124
+ if self._deadline is None:
125
+ self._deadline = self._clock() + BURST_WAIT
126
+ if any(e.kind == "watch_ended" for e in events):
127
+ self._send_buffer()
128
+
129
+ def seconds_until_flush(self) -> float | None:
130
+ if self._deadline is None:
131
+ return None
132
+ return max(0.0, self._deadline - self._clock())
133
+
134
+ def tick(self) -> None:
135
+ if self._deadline is not None and self._clock() >= self._deadline:
136
+ self._send_buffer()
137
+
138
+ def _send_buffer(self) -> None:
139
+ events, self._buffer, self._deadline = self._buffer, [], None
140
+ if not events or self._gone:
141
+ return
142
+ if self._fallback is not None:
143
+ self._fallback.emit(events)
144
+ return
145
+ content = format_message(self.ref, events, self.changelog)
146
+ try:
147
+ self.sender.send(self._socket, content)
148
+ except SessionGone:
149
+ self._gone = True
150
+ except (UnsupportedProtocol, MessagingUnavailable) as exc:
151
+ # The session stopped taking messages from us, for example after a Claude Code update changed the
152
+ # protocol. Keep the events flowing on stdout rather than dropping them.
153
+ self.status.line(f"stream fallback: cannot message the session ({exc}); events now go to stdout")
154
+ self._fallback = StdoutSink()
155
+ self._fallback.emit(events)
156
+ except (MessageTooLarge, ClaudeBlackboxError, OSError) as exc:
157
+ self.status.line(f"could not message the session: {exc}; the events are in {self.changelog}")
158
+
159
+ def alive(self) -> bool:
160
+ if self._gone:
161
+ return False
162
+ now = self._clock()
163
+ if now - self._last_liveness >= LIVENESS_INTERVAL:
164
+ self._last_liveness = now
165
+ if not self._session_alive():
166
+ self._gone = True
167
+ return not self._gone
168
+
169
+ def close(self) -> None:
170
+ self._send_buffer()
171
+ deadline = self._clock() + FLUSH_TIMEOUT
172
+ while not self._gone and self.sender.pending_count(self._socket) and self._clock() < deadline:
173
+ time.sleep(min(1.0, self.sender.seconds_until_next_token(self._socket) + 0.05))
174
+ try:
175
+ self.sender.flush()
176
+ except SessionGone:
177
+ self._gone = True
178
+ except (ClaudeBlackboxError, OSError) as exc:
179
+ self.status.line(f"could not message the session: {exc}")
180
+ return
181
+
182
+
183
+ def _session_still_current() -> bool:
184
+ session = current_session()
185
+ return session is not None and session.can_receive_messages
186
+
187
+
188
+ def find_session() -> ActiveSession | None:
189
+ """The Claude Code session this process runs under, or None."""
190
+ try:
191
+ return current_session()
192
+ except (ClaudeBlackboxError, OSError):
193
+ return None