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.
- glab_cli_extras/__init__.py +3 -0
- glab_cli_extras/artifacts/__init__.py +0 -0
- glab_cli_extras/artifacts/job_logs.py +95 -0
- glab_cli_extras/artifacts/threads.py +147 -0
- glab_cli_extras/cli/__init__.py +0 -0
- glab_cli_extras/cli/watch.py +426 -0
- glab_cli_extras/daemon/__init__.py +0 -0
- glab_cli_extras/daemon/__main__.py +10 -0
- glab_cli_extras/daemon/engine.py +491 -0
- glab_cli_extras/daemon/lifecycle.py +186 -0
- glab_cli_extras/daemon/locks.py +85 -0
- glab_cli_extras/daemon/scheduler.py +52 -0
- glab_cli_extras/daemon/server.py +79 -0
- glab_cli_extras/daemon/transport.py +185 -0
- glab_cli_extras/events.py +165 -0
- glab_cli_extras/gitlab/__init__.py +0 -0
- glab_cli_extras/gitlab/budget.py +135 -0
- glab_cli_extras/gitlab/client.py +225 -0
- glab_cli_extras/gitlab/glab.py +175 -0
- glab_cli_extras/gitlab/queries/__init__.py +11 -0
- glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
- glab_cli_extras/gitlab/queries/mr.graphql +42 -0
- glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
- glab_cli_extras/gitlab/refs.py +151 -0
- glab_cli_extras/paths.py +79 -0
- glab_cli_extras/py.typed +0 -0
- glab_cli_extras/resources/__init__.py +19 -0
- glab_cli_extras/resources/base.py +146 -0
- glab_cli_extras/resources/branch.py +265 -0
- glab_cli_extras/resources/mr.py +445 -0
- glab_cli_extras/resources/mr_list.py +150 -0
- glab_cli_extras/resources/pipeline.py +319 -0
- glab_cli_extras/serde.py +75 -0
- glab_cli_extras/watch/__init__.py +0 -0
- glab_cli_extras/watch/changelog.py +86 -0
- glab_cli_extras/watch/claude.py +193 -0
- glab_cli_extras/watch/client.py +316 -0
- glab_cli_extras/watch/sinks.py +83 -0
- glab_cli_extras/watch/streams.py +80 -0
- glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
- glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
- glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
- glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|
glab_cli_extras/serde.py
ADDED
|
@@ -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
|