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,316 @@
|
|
|
1
|
+
"""The watcher: subscribe through a backend, poll for events, write the changelog, deliver to a sink.
|
|
2
|
+
|
|
3
|
+
Two backends share one interface. `DaemonBackend` talks to the shared per-user daemon and restarts it when it
|
|
4
|
+
disappears. `InProcessBackend` (`--no-daemon`) runs the same engine in a thread of this process, with a private
|
|
5
|
+
budget.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
import contextlib
|
|
13
|
+
import signal
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from types import FrameType
|
|
21
|
+
from typing import Any, Protocol, TypeVar
|
|
22
|
+
|
|
23
|
+
from glab_cli_extras.artifacts.job_logs import LogsMode
|
|
24
|
+
from glab_cli_extras.daemon.engine import Engine
|
|
25
|
+
from glab_cli_extras.daemon.lifecycle import ensure_daemon
|
|
26
|
+
from glab_cli_extras.daemon.transport import DaemonFiles, DaemonUnavailable, request
|
|
27
|
+
from glab_cli_extras.events import WATCH_ENDED, WATCH_STARTED, Event, utc_now_iso
|
|
28
|
+
from glab_cli_extras.gitlab.refs import ResourceRef
|
|
29
|
+
from glab_cli_extras.watch.changelog import Changelog
|
|
30
|
+
from glab_cli_extras.watch.sinks import Sink, StatusOutput
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
EXIT_OK = 0
|
|
35
|
+
EXIT_ERROR = 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BackendError(RuntimeError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Backend(Protocol):
|
|
43
|
+
def describe(self) -> str: ...
|
|
44
|
+
|
|
45
|
+
def subscribe(
|
|
46
|
+
self, client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
|
|
47
|
+
) -> tuple[str, int]: ...
|
|
48
|
+
|
|
49
|
+
def poll(self, client_id: str, cursors: dict[str, int]) -> dict[str, Any]: ...
|
|
50
|
+
|
|
51
|
+
def unsubscribe(self, client_id: str, subscription_id: str) -> None: ...
|
|
52
|
+
|
|
53
|
+
def close(self) -> None: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _subscribe_payload(
|
|
57
|
+
client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
options: dict[str, Any] = {"events": sorted(events), "logs": logs}
|
|
60
|
+
if baseline is not None:
|
|
61
|
+
options["baseline"] = baseline
|
|
62
|
+
return {"op": "subscribe", "client_id": client_id, "resource": ref.to_wire(), "options": options}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DaemonBackend:
|
|
66
|
+
def __init__(self, files: DaemonFiles | None = None) -> None:
|
|
67
|
+
self.files = files or DaemonFiles.default()
|
|
68
|
+
self.hello = ensure_daemon(self.files)
|
|
69
|
+
|
|
70
|
+
def describe(self) -> str:
|
|
71
|
+
return f"pid {self.hello.get('pid')}, endpoint {self.files.describe_endpoint()}, log {self.files.log}"
|
|
72
|
+
|
|
73
|
+
def _call(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
try:
|
|
75
|
+
response = request(self.files, payload)
|
|
76
|
+
except DaemonUnavailable:
|
|
77
|
+
# The daemon exited (idle, crash, or stopped by hand). Start a new one; subscriptions then come back
|
|
78
|
+
# as unknown and the watcher subscribes again.
|
|
79
|
+
self.hello = ensure_daemon(self.files)
|
|
80
|
+
response = request(self.files, payload)
|
|
81
|
+
if not response.get("ok"):
|
|
82
|
+
raise BackendError(str(response.get("error", "the daemon refused the request")))
|
|
83
|
+
return response
|
|
84
|
+
|
|
85
|
+
def subscribe(
|
|
86
|
+
self, client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
|
|
87
|
+
) -> tuple[str, int]:
|
|
88
|
+
response = self._call(_subscribe_payload(client_id, ref, events, logs, baseline))
|
|
89
|
+
return str(response["subscription_id"]), int(response["cursor"])
|
|
90
|
+
|
|
91
|
+
def poll(self, client_id: str, cursors: dict[str, int]) -> dict[str, Any]:
|
|
92
|
+
return self._call({"op": "poll", "client_id": client_id, "cursors": cursors})
|
|
93
|
+
|
|
94
|
+
def unsubscribe(self, client_id: str, subscription_id: str) -> None:
|
|
95
|
+
self._call({"op": "unsubscribe", "client_id": client_id, "subscription_id": subscription_id})
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class InProcessBackend:
|
|
102
|
+
"""The engine on its own event loop in a daemon thread. Calls hop onto that loop and wait for the result."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, engine: Engine) -> None:
|
|
105
|
+
self.engine = engine
|
|
106
|
+
self._loop = asyncio.new_event_loop()
|
|
107
|
+
self._stop: asyncio.Event | None = None
|
|
108
|
+
self._ready = threading.Event()
|
|
109
|
+
self._thread = threading.Thread(target=self._run, name="glab-watch-engine", daemon=True)
|
|
110
|
+
self._thread.start()
|
|
111
|
+
self._ready.wait()
|
|
112
|
+
|
|
113
|
+
def _run(self) -> None:
|
|
114
|
+
asyncio.set_event_loop(self._loop)
|
|
115
|
+
|
|
116
|
+
async def main() -> None:
|
|
117
|
+
self._stop = asyncio.Event()
|
|
118
|
+
self._ready.set()
|
|
119
|
+
await self.engine.run(self._stop)
|
|
120
|
+
|
|
121
|
+
self._loop.run_until_complete(main())
|
|
122
|
+
|
|
123
|
+
def _call(self, fn: Callable[[], T]) -> T:
|
|
124
|
+
future: concurrent.futures.Future[T] = concurrent.futures.Future()
|
|
125
|
+
|
|
126
|
+
def invoke() -> None:
|
|
127
|
+
try:
|
|
128
|
+
future.set_result(fn())
|
|
129
|
+
except BaseException as exc:
|
|
130
|
+
future.set_exception(exc)
|
|
131
|
+
|
|
132
|
+
self._loop.call_soon_threadsafe(invoke)
|
|
133
|
+
return future.result(timeout=30)
|
|
134
|
+
|
|
135
|
+
def describe(self) -> str:
|
|
136
|
+
return "in-process (--no-daemon), private budget"
|
|
137
|
+
|
|
138
|
+
def subscribe(
|
|
139
|
+
self, client_id: str, ref: ResourceRef, events: frozenset[str], logs: LogsMode, baseline: Any
|
|
140
|
+
) -> tuple[str, int]:
|
|
141
|
+
result = self._call(lambda: self.engine.subscribe(client_id, ref, events, logs, baseline))
|
|
142
|
+
return str(result["subscription_id"]), int(result["cursor"])
|
|
143
|
+
|
|
144
|
+
def poll(self, client_id: str, cursors: dict[str, int]) -> dict[str, Any]:
|
|
145
|
+
return self._call(lambda: self.engine.poll(client_id, cursors))
|
|
146
|
+
|
|
147
|
+
def unsubscribe(self, client_id: str, subscription_id: str) -> None:
|
|
148
|
+
self._call(lambda: self.engine.unsubscribe(client_id, subscription_id))
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
stop = self._stop
|
|
152
|
+
if stop is not None:
|
|
153
|
+
self._loop.call_soon_threadsafe(stop.set)
|
|
154
|
+
self._thread.join(timeout=10)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class WatchSettings:
|
|
159
|
+
ref: ResourceRef
|
|
160
|
+
events: frozenset[str]
|
|
161
|
+
logs: LogsMode = "failed"
|
|
162
|
+
until: str | None = None
|
|
163
|
+
timeout: float | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Watcher:
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
settings: WatchSettings,
|
|
170
|
+
backend: Backend,
|
|
171
|
+
sink: Sink,
|
|
172
|
+
changelog: Changelog,
|
|
173
|
+
status: StatusOutput,
|
|
174
|
+
*,
|
|
175
|
+
clock: Callable[[], float] = time.monotonic,
|
|
176
|
+
) -> None:
|
|
177
|
+
self.settings = settings
|
|
178
|
+
self.backend = backend
|
|
179
|
+
self.sink = sink
|
|
180
|
+
self.changelog = changelog
|
|
181
|
+
self.status = status
|
|
182
|
+
self.clock = clock
|
|
183
|
+
self.client_id = uuid.uuid4().hex
|
|
184
|
+
self._stop_reason: str | None = None
|
|
185
|
+
self._wakeup = threading.Event()
|
|
186
|
+
self._subscription: str | None = None
|
|
187
|
+
# The daemon's sequence number of the last event received. It restarts when the daemon restarts, so it is
|
|
188
|
+
# only the poll cursor. Emitted events carry the watcher's own `_seq`, which never goes back.
|
|
189
|
+
self._cursor = 0
|
|
190
|
+
self._seq = 0
|
|
191
|
+
self._snapshot_hash: str | None = None
|
|
192
|
+
|
|
193
|
+
def request_stop(self, reason: str = "stopped") -> None:
|
|
194
|
+
self._stop_reason = reason
|
|
195
|
+
self._wakeup.set()
|
|
196
|
+
|
|
197
|
+
def _install_signal_handlers(self) -> None:
|
|
198
|
+
if threading.current_thread() is not threading.main_thread():
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
def handler(signum: int, frame: FrameType | None) -> None:
|
|
202
|
+
self.request_stop(f"signal {signal.Signals(signum).name}")
|
|
203
|
+
|
|
204
|
+
signal.signal(signal.SIGTERM, handler)
|
|
205
|
+
signal.signal(signal.SIGINT, handler)
|
|
206
|
+
|
|
207
|
+
def _subscribe(self, resubscribe: bool) -> None:
|
|
208
|
+
baseline = None
|
|
209
|
+
if resubscribe:
|
|
210
|
+
stored = self.changelog.load_state()
|
|
211
|
+
baseline = stored.snapshot if stored else None
|
|
212
|
+
s = self.settings
|
|
213
|
+
self._subscription, self._cursor = self.backend.subscribe(self.client_id, s.ref, s.events, s.logs, baseline)
|
|
214
|
+
|
|
215
|
+
def _next_seq(self) -> int:
|
|
216
|
+
self._seq += 1
|
|
217
|
+
return self._seq
|
|
218
|
+
|
|
219
|
+
def _end_event(self, reason: str, **data: Any) -> Event:
|
|
220
|
+
return Event(WATCH_ENDED, self.settings.ref.key, self._next_seq(), utc_now_iso(), {"reason": reason, **data})
|
|
221
|
+
|
|
222
|
+
def _deliver(self, events: list[Event]) -> None:
|
|
223
|
+
if not events:
|
|
224
|
+
return
|
|
225
|
+
self.changelog.append(events)
|
|
226
|
+
self.sink.emit(events)
|
|
227
|
+
|
|
228
|
+
def run(self) -> int:
|
|
229
|
+
self._install_signal_handlers()
|
|
230
|
+
if self.settings.timeout is not None:
|
|
231
|
+
deadline: float | None = self.clock() + self.settings.timeout
|
|
232
|
+
else:
|
|
233
|
+
deadline = None
|
|
234
|
+
stored = self.changelog.load_state()
|
|
235
|
+
self._snapshot_hash = stored.snapshot_hash if stored else None
|
|
236
|
+
# Continue the changelog's numbering, so events.jsonl stays strictly increasing across watcher runs too.
|
|
237
|
+
self._seq = self.changelog.last_seq()
|
|
238
|
+
resubscribed = False
|
|
239
|
+
exit_code = EXIT_OK
|
|
240
|
+
try:
|
|
241
|
+
self._subscribe(resubscribe=False)
|
|
242
|
+
while True:
|
|
243
|
+
if self._stop_reason is not None:
|
|
244
|
+
self._finish(self._end_event(self._stop_reason))
|
|
245
|
+
return EXIT_OK
|
|
246
|
+
assert self._subscription is not None
|
|
247
|
+
self.sink.tick()
|
|
248
|
+
response = self.backend.poll(self.client_id, {self._subscription: self._cursor})
|
|
249
|
+
if self._subscription in response.get("unknown", []):
|
|
250
|
+
self.status.line("daemon restarted, subscribing again")
|
|
251
|
+
self._subscribe(resubscribe=True)
|
|
252
|
+
resubscribed = True
|
|
253
|
+
continue
|
|
254
|
+
snapshot = (response.get("snapshots") or {}).get(self._subscription)
|
|
255
|
+
if snapshot:
|
|
256
|
+
previous_hash = self._snapshot_hash
|
|
257
|
+
self._snapshot_hash = str(snapshot["hash"])
|
|
258
|
+
self.changelog.save_state(self._snapshot_hash, snapshot.get("data"))
|
|
259
|
+
else:
|
|
260
|
+
previous_hash = self._snapshot_hash
|
|
261
|
+
raw = [Event.from_json(e) for e in (response.get("events") or {}).get(self._subscription) or []]
|
|
262
|
+
for event in raw:
|
|
263
|
+
self._cursor = max(self._cursor, event.seq)
|
|
264
|
+
events = [e for e in raw if e.kind in self.settings.events]
|
|
265
|
+
if resubscribed and any(e.kind == WATCH_STARTED for e in events):
|
|
266
|
+
# After a daemon restart, the new watch_started repeats what we already know unless the
|
|
267
|
+
# snapshot moved; changes in between arrive as normal events, diffed against our stored snapshot.
|
|
268
|
+
events = [
|
|
269
|
+
e
|
|
270
|
+
for e in events
|
|
271
|
+
if not (e.kind == WATCH_STARTED and e.data.get("snapshot_hash") == previous_hash)
|
|
272
|
+
]
|
|
273
|
+
resubscribed = False
|
|
274
|
+
events = [replace(e, seq=self._next_seq()) for e in events]
|
|
275
|
+
ended = next((e for e in events if e.kind == WATCH_ENDED), None)
|
|
276
|
+
hit = next((e for e in events if self.settings.until and e.kind == self.settings.until), None)
|
|
277
|
+
self._deliver(events)
|
|
278
|
+
if ended is not None:
|
|
279
|
+
reason = str(ended.data.get("reason"))
|
|
280
|
+
self.status.line(f"ended: {reason}")
|
|
281
|
+
return EXIT_ERROR if reason == "not_found" else EXIT_OK
|
|
282
|
+
if hit is not None:
|
|
283
|
+
self._finish(self._end_event("until", event=hit.kind, seq=hit.seq))
|
|
284
|
+
return EXIT_OK
|
|
285
|
+
if not self.sink.alive():
|
|
286
|
+
self._finish(self._end_event("session_gone"))
|
|
287
|
+
return EXIT_OK
|
|
288
|
+
if deadline is not None and self.clock() >= deadline:
|
|
289
|
+
self._finish(self._end_event("timeout"))
|
|
290
|
+
return EXIT_OK
|
|
291
|
+
wait = int(response.get("next_poll_ms", 5000)) / 1000.0
|
|
292
|
+
if deadline is not None:
|
|
293
|
+
wait = max(0.0, min(wait, deadline - self.clock()))
|
|
294
|
+
flush_in = self.sink.seconds_until_flush()
|
|
295
|
+
if flush_in is not None:
|
|
296
|
+
wait = min(wait, flush_in)
|
|
297
|
+
self._wakeup.wait(wait)
|
|
298
|
+
except (BackendError, DaemonUnavailable) as exc:
|
|
299
|
+
self.status.line(f"error: {exc}")
|
|
300
|
+
exit_code = EXIT_ERROR
|
|
301
|
+
return exit_code
|
|
302
|
+
finally:
|
|
303
|
+
if self._subscription is not None:
|
|
304
|
+
with contextlib.suppress(BackendError, DaemonUnavailable, OSError, TimeoutError):
|
|
305
|
+
self.backend.unsubscribe(self.client_id, self._subscription)
|
|
306
|
+
self.sink.close()
|
|
307
|
+
|
|
308
|
+
def _finish(self, event: Event) -> None:
|
|
309
|
+
self._deliver([event])
|
|
310
|
+
self.status.line(f"ended: {event.data.get('reason')}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def default_backend(no_daemon: bool, cache_root: Path) -> Backend:
|
|
314
|
+
if no_daemon:
|
|
315
|
+
return InProcessBackend(Engine(cache_root=cache_root))
|
|
316
|
+
return DaemonBackend()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Where a watcher delivers events, and where its own status lines go.
|
|
2
|
+
|
|
3
|
+
Status lines (the startup header, the end line, errors) go to stderr, so stdout carries only events. Claude Code puts
|
|
4
|
+
a background Bash task's stderr into the task output next to its stdout, so an agent reads both. Status lines start
|
|
5
|
+
with `glab-watch: ` and never with `{`, so a reader of the merged output can still tell them from events.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Protocol, TextIO
|
|
13
|
+
|
|
14
|
+
from glab_cli_extras.events import Event
|
|
15
|
+
|
|
16
|
+
STATUS_PREFIX = "glab-watch: "
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StatusOutput:
|
|
20
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
21
|
+
self._stream = stream
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def stream(self) -> TextIO:
|
|
25
|
+
if self._stream is not None:
|
|
26
|
+
return self._stream
|
|
27
|
+
return sys.stderr
|
|
28
|
+
|
|
29
|
+
def line(self, text: str) -> None:
|
|
30
|
+
self.stream.write(f"{STATUS_PREFIX}{text}\n")
|
|
31
|
+
self.stream.flush()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Sink(Protocol):
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
def describe(self) -> str:
|
|
38
|
+
"""One line for the startup header: the stream mode and its target."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def emit(self, events: list[Event]) -> None: ...
|
|
42
|
+
|
|
43
|
+
def seconds_until_flush(self) -> float | None:
|
|
44
|
+
"""When a buffered batch is due, so the watcher wakes up for it; None when nothing is buffered."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def tick(self) -> None:
|
|
48
|
+
"""Deliver a buffered batch whose time has come."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def alive(self) -> bool:
|
|
52
|
+
"""False once the target is gone, which ends the watch."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def close(self) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StdoutSink:
|
|
59
|
+
name = "stdout"
|
|
60
|
+
|
|
61
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
62
|
+
self._stream = stream
|
|
63
|
+
|
|
64
|
+
def describe(self) -> str:
|
|
65
|
+
return "stdout (one JSON event per line)"
|
|
66
|
+
|
|
67
|
+
def emit(self, events: list[Event]) -> None:
|
|
68
|
+
stream = self._stream or sys.stdout
|
|
69
|
+
for event in events:
|
|
70
|
+
stream.write(json.dumps(event.to_json(), ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
71
|
+
stream.flush()
|
|
72
|
+
|
|
73
|
+
def seconds_until_flush(self) -> float | None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def tick(self) -> None:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def alive(self) -> bool:
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
pass
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Choosing the events stream, and the default changelog location that comes with it.
|
|
2
|
+
|
|
3
|
+
`auto` picks `claude-message` when the watcher runs under a Claude Code session that accepts messages, else `stdout`.
|
|
4
|
+
Under a Claude session the changelog defaults to `<session scratchpad>/glab-watch` in either mode, so the agent finds
|
|
5
|
+
it next to its other files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from cc_blackbox import ActiveSession, Priority
|
|
16
|
+
|
|
17
|
+
from glab_cli_extras.gitlab.refs import ResourceRef
|
|
18
|
+
from glab_cli_extras.watch.changelog import Changelog
|
|
19
|
+
from glab_cli_extras.watch.claude import ClaudeMessageSink, find_session
|
|
20
|
+
from glab_cli_extras.watch.sinks import Sink, StatusOutput, StdoutSink
|
|
21
|
+
|
|
22
|
+
STREAM_MODES = ("auto", "stdout", "claude-message")
|
|
23
|
+
MESSAGE_PRIORITIES = ("now", "next", "later")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StreamError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Stream:
|
|
32
|
+
sink: Sink
|
|
33
|
+
changelog: Changelog
|
|
34
|
+
fallback: str | None = None
|
|
35
|
+
"""Why `auto` picked stdout although the watcher runs under a Claude Code session; printed in the header."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def scratchpad_root(session: ActiveSession | None) -> Path | None:
|
|
39
|
+
if session is None or session.scratchpad_dir is None:
|
|
40
|
+
return None
|
|
41
|
+
return session.scratchpad_dir / "glab-watch"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def messaging_problem(session: ActiveSession | None) -> str | None:
|
|
45
|
+
"""Why the session cannot take messages from this watcher, or None when it can."""
|
|
46
|
+
if sys.platform == "win32":
|
|
47
|
+
return "the claude-message stream is not supported on Windows"
|
|
48
|
+
if session is None:
|
|
49
|
+
return "no Claude Code session found (CLAUDE_CODE_MESSAGING_SOCKET is unset, or the session record is gone)"
|
|
50
|
+
if session.can_receive_messages:
|
|
51
|
+
return None
|
|
52
|
+
reasons = [r for r in session.compatibility.reasons if r.startswith("messaging")]
|
|
53
|
+
if reasons:
|
|
54
|
+
return "the session's messaging is not compatible with this cc-blackbox version: " + " ".join(reasons)
|
|
55
|
+
if session.messaging_socket is None:
|
|
56
|
+
return "the session has messaging turned off"
|
|
57
|
+
return f"the session's messaging socket {session.messaging_socket} does not exist"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def open_stream(
|
|
61
|
+
mode: str,
|
|
62
|
+
ref: ResourceRef,
|
|
63
|
+
priority: str,
|
|
64
|
+
changelog_dir: Path | None,
|
|
65
|
+
default_root: Path,
|
|
66
|
+
status: StatusOutput,
|
|
67
|
+
) -> Stream:
|
|
68
|
+
session = find_session()
|
|
69
|
+
root = changelog_dir or scratchpad_root(session) or default_root
|
|
70
|
+
changelog = Changelog(root, ref)
|
|
71
|
+
problem = messaging_problem(session)
|
|
72
|
+
if mode == "claude-message" and problem is not None:
|
|
73
|
+
raise StreamError(f"--events-stream=claude-message is not possible: {problem}; use --events-stream=stdout")
|
|
74
|
+
if mode == "claude-message" or (mode == "auto" and problem is None):
|
|
75
|
+
assert session is not None
|
|
76
|
+
sink = ClaudeMessageSink(session, ref, changelog.events_path, cast(Priority, priority), status)
|
|
77
|
+
return Stream(sink, changelog)
|
|
78
|
+
# Under a Claude Code session, say why auto did not message it; outside one, stdout is simply the default.
|
|
79
|
+
fallback = problem if mode == "auto" and session is not None else None
|
|
80
|
+
return Stream(StdoutSink(), changelog, fallback)
|