stacktrace-cli 0.5.0__py3-none-any.whl → 0.5.2__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.
- stacktrace_cli/__init__.py +8 -1
- stacktrace_cli/cli.py +2 -0
- stacktrace_cli/cloud/__init__.py +39 -0
- stacktrace_cli/cloud/setup.sh +247 -0
- stacktrace_cli/daemon/activity_log.py +94 -0
- stacktrace_cli/daemon/bom.py +56 -0
- stacktrace_cli/daemon/cli.py +453 -19
- stacktrace_cli/daemon/client.py +230 -9
- stacktrace_cli/daemon/composition.py +112 -3
- stacktrace_cli/daemon/config.py +191 -0
- stacktrace_cli/daemon/db.py +625 -0
- stacktrace_cli/daemon/fleet.py +660 -0
- stacktrace_cli/daemon/identity.py +83 -0
- stacktrace_cli/daemon/jobs.py +181 -0
- stacktrace_cli/daemon/lifecycle.py +172 -0
- stacktrace_cli/daemon/observer.py +545 -92
- stacktrace_cli/daemon/paths.py +6 -1
- stacktrace_cli/daemon/protocol.py +125 -8
- stacktrace_cli/daemon/runtime.py +572 -100
- stacktrace_cli/daemon/scan.py +256 -0
- stacktrace_cli/daemon/server.py +141 -2
- stacktrace_cli/daemon/service.py +98 -121
- stacktrace_cli/daemon/store.py +789 -258
- stacktrace_cli/daemon/webhook.py +508 -0
- stacktrace_cli/detector/render.py +31 -4
- stacktrace_cli/monitor/site/app.js +1 -1
- stacktrace_cli/monitor/site/index.html +3 -4
- stacktrace_cli/remote/cli.py +28 -19
- stacktrace_cli/remote/client.py +29 -0
- stacktrace_cli/remote/detect_payload.py +147 -25
- stacktrace_cli/remote/identity.py +305 -0
- stacktrace_cli/remote/payload.py +7 -4
- stacktrace_cli/remote/redact.py +773 -105
- stacktrace_cli/remote/sync.py +330 -98
- stacktrace_cli/remote/sync_detect.py +134 -64
- stacktrace_cli/remote/upload_contract.py +10 -1
- stacktrace_cli/telemetry/events.py +12 -6
- stacktrace_cli/telemetry/posthog.py +34 -5
- stacktrace_cli/webhook/__init__.py +1 -0
- stacktrace_cli/webhook/cli.py +169 -0
- stacktrace_cli/webhook/config.py +193 -0
- {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/METADATA +30 -2
- {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/RECORD +45 -29
- {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/entry_points.txt +1 -0
- {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/WHEEL +0 -0
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -4,34 +4,369 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import sqlite3
|
|
7
8
|
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypeVar
|
|
8
14
|
|
|
9
15
|
import click
|
|
10
16
|
|
|
17
|
+
from .config import DaemonConfig
|
|
11
18
|
from .paths import RuntimePaths
|
|
12
19
|
from .presentation import render_finding
|
|
13
20
|
from .store import FindingStore, SessionKey
|
|
14
21
|
|
|
22
|
+
_T = TypeVar("_T")
|
|
23
|
+
|
|
15
24
|
|
|
16
25
|
@click.group()
|
|
17
26
|
def daemon() -> None:
|
|
18
27
|
"""Manage automatic session detection."""
|
|
19
28
|
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
#: Bounds how long `status`/`stop` retry a control request against a daemon
|
|
31
|
+
#: whose lock is held but that has not yet answered. The accept loop starts
|
|
32
|
+
#: before composition startup, but the lock is taken earlier than that
|
|
33
|
+
#: (version lookup, opening the store, binding the socket). A request in
|
|
34
|
+
#: that gap would otherwise see its control timeout expire with nobody yet
|
|
35
|
+
#: reading it. Comfortably larger than the gap so an ordinary start never
|
|
36
|
+
#: surfaces as unresponsive.
|
|
37
|
+
_STARTUP_GRACE_SECONDS = 10.0
|
|
38
|
+
|
|
39
|
+
#: How long `stop` waits for the singleton lock once shutdown has been
|
|
40
|
+
#: accepted. Its own budget, not shared with `_STARTUP_GRACE_SECONDS`:
|
|
41
|
+
#: retrying during startup must not eat the time the in-progress tick needs.
|
|
42
|
+
#: Accepting shutdown only sets the stop event. The tick — a scan, then
|
|
43
|
+
#: Fleet drain and BOM upload — runs to completion before the loop notices,
|
|
44
|
+
#: and the lock stays held through that work, the telemetry flush, and
|
|
45
|
+
#: `compositions.close()`. Fleet's HTTP client allows 30s per attempt and
|
|
46
|
+
#: three attempts (~95s for one call), and one tick can make several. A 10s
|
|
47
|
+
#: budget expired during an ordinary pass, so `stop` reported failure and
|
|
48
|
+
#: dropped the transition lock while the process was still shutting down;
|
|
49
|
+
#: the next `start` then saw `ALREADY_RUNNING`. 600s covers several retried
|
|
50
|
+
#: cloud calls plus the scan and the shutdown tail, and still gives up when
|
|
51
|
+
#: the process is stuck.
|
|
52
|
+
_STOP_TIMEOUT_SECONDS = 600.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _retry_while_starting(
|
|
56
|
+
lock_path: Path, call: Callable[[], _T], errors: tuple[type[Exception], ...]
|
|
57
|
+
) -> _T:
|
|
58
|
+
"""Retry a lifecycle control request while the daemon lock is held.
|
|
59
|
+
|
|
60
|
+
Treats an early failure as evidence of the startup gap described above,
|
|
61
|
+
not as proof the daemon is actually unresponsive -- as long as the lock
|
|
62
|
+
is still held, that gap closes on its own within a few seconds.
|
|
63
|
+
"""
|
|
64
|
+
from .lifecycle import daemon_lock_held
|
|
65
|
+
|
|
66
|
+
deadline = time.monotonic() + _STARTUP_GRACE_SECONDS
|
|
67
|
+
while True:
|
|
68
|
+
try:
|
|
69
|
+
return call()
|
|
70
|
+
except errors:
|
|
71
|
+
if not daemon_lock_held(lock_path) or time.monotonic() >= deadline:
|
|
72
|
+
raise
|
|
73
|
+
time.sleep(0.1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@daemon.command("run")
|
|
77
|
+
# No `envvar=` on any of these. Click resolves an environment variable *into*
|
|
78
|
+
# the parameter, so a value that came from the environment would arrive here
|
|
79
|
+
# indistinguishable from one typed on the command line — and `daemon status`
|
|
80
|
+
# would report every forked daemon's settings as "(flag)", which is precisely
|
|
81
|
+
# the drift the source label exists to expose. `DaemonConfig.resolve` reads the
|
|
82
|
+
# environment itself, and knows which answer it gave.
|
|
83
|
+
@click.option(
|
|
84
|
+
"--scan-interval",
|
|
85
|
+
default=None,
|
|
86
|
+
help="How often to enumerate and analyse changed sessions. 0 disables scanning."
|
|
87
|
+
" Defaults to STACKTRACE_SCAN_INTERVAL, then 5s.",
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--scan-since",
|
|
91
|
+
default=None,
|
|
92
|
+
help="Bootstrap horizon for the first scan only; later scans use the watermark."
|
|
93
|
+
" Defaults to STACKTRACE_SCAN_SINCE, then 3d.",
|
|
94
|
+
)
|
|
95
|
+
@click.option(
|
|
96
|
+
"--agent-kind",
|
|
97
|
+
"agent_kinds",
|
|
98
|
+
multiple=True,
|
|
99
|
+
help="Agent kind to observe. Repeatable. Defaults to STACKTRACE_AGENT_KIND, then claude-code.",
|
|
100
|
+
)
|
|
101
|
+
@click.option(
|
|
102
|
+
"--fleet-interval",
|
|
103
|
+
default=None,
|
|
104
|
+
help="How often to drain the finding queue to Fleet as a fallback poll. 0 disables only"
|
|
105
|
+
" that fallback: a finding still uploads immediately when the scan commits it."
|
|
106
|
+
" Defaults to STACKTRACE_FLEET_INTERVAL, then 5m.",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--webhook-interval",
|
|
110
|
+
default=None,
|
|
111
|
+
help="How often to retry configured webhook delivery. 0 disables only the fallback;"
|
|
112
|
+
" new findings still wake delivery. Defaults to STACKTRACE_WEBHOOK_INTERVAL, then 1m.",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--bom-interval",
|
|
116
|
+
default=None,
|
|
117
|
+
help="How often to upload endpoint composition. 0 disables it."
|
|
118
|
+
" Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
|
|
119
|
+
)
|
|
120
|
+
def run_command(
|
|
121
|
+
scan_interval: str | None,
|
|
122
|
+
scan_since: str | None,
|
|
123
|
+
agent_kinds: tuple[str, ...],
|
|
124
|
+
fleet_interval: str | None,
|
|
125
|
+
webhook_interval: str | None,
|
|
126
|
+
bom_interval: str | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Run the daemon in the foreground for a host supervisor."""
|
|
23
129
|
_, _, run_daemon = _runtime()
|
|
24
|
-
|
|
130
|
+
paths = RuntimePaths.from_environment()
|
|
131
|
+
try:
|
|
132
|
+
config = DaemonConfig.resolve(
|
|
133
|
+
scan_interval=scan_interval,
|
|
134
|
+
scan_since=scan_since,
|
|
135
|
+
agent_kinds=agent_kinds,
|
|
136
|
+
fleet_interval=fleet_interval,
|
|
137
|
+
webhook_interval=webhook_interval,
|
|
138
|
+
bom_interval=bom_interval,
|
|
139
|
+
)
|
|
140
|
+
_reject_unobservable_kinds(config)
|
|
141
|
+
except ValueError as error:
|
|
142
|
+
raise click.ClickException(str(error)) from error
|
|
143
|
+
from .lifecycle import DETACHED_ENV
|
|
144
|
+
|
|
145
|
+
status = run_daemon(
|
|
146
|
+
paths,
|
|
147
|
+
config,
|
|
148
|
+
own_std_streams=os.environ.get(DETACHED_ENV) == "1",
|
|
149
|
+
)
|
|
150
|
+
if status:
|
|
151
|
+
raise click.exceptions.Exit(status)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _reject_unobservable_kinds(config: DaemonConfig) -> None:
|
|
155
|
+
"""A kind with no observer would scan nothing, silently.
|
|
156
|
+
|
|
157
|
+
Fails at startup rather than at the first tick: `cursor` and `codex` have
|
|
158
|
+
composition coverage but no runtime detection yet (`daemon-scanning.md`
|
|
159
|
+
§ *Deferred*), and a daemon quietly finding nothing for one of them is the
|
|
160
|
+
failure this refusal exists to make loud.
|
|
161
|
+
"""
|
|
162
|
+
from .runtime import OBSERVERS
|
|
163
|
+
|
|
164
|
+
unknown = [kind for kind in config.kinds if kind not in OBSERVERS]
|
|
165
|
+
if unknown:
|
|
166
|
+
known = ", ".join(sorted(OBSERVERS))
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"no session observer for {', '.join(unknown)}; this build observes {known}"
|
|
169
|
+
)
|
|
25
170
|
|
|
26
171
|
|
|
27
172
|
@daemon.command()
|
|
28
173
|
def status() -> None:
|
|
29
|
-
"""
|
|
30
|
-
|
|
174
|
+
"""Report whether this endpoint is working, without reading a log.
|
|
175
|
+
|
|
176
|
+
Every row answers a question someone asks when a machine looks quiet: a
|
|
177
|
+
watermark hours old means the scanner has stopped; a queue that grows with
|
|
178
|
+
a reader attached means the reader is not draining; a database that only
|
|
179
|
+
grows means retention is not pruning; and a configuration without its source
|
|
180
|
+
makes fleet-wide drift undebuggable (`docs/specs/daemon-scanning.md`).
|
|
181
|
+
"""
|
|
182
|
+
from stacktrace_cli.build import version_label
|
|
183
|
+
|
|
184
|
+
from .client import ProtocolError, request_status
|
|
185
|
+
from .lifecycle import daemon_lock_held
|
|
186
|
+
from .runtime import last_error
|
|
187
|
+
|
|
31
188
|
paths = RuntimePaths.from_environment()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
189
|
+
live = None
|
|
190
|
+
try:
|
|
191
|
+
live = _retry_while_starting(
|
|
192
|
+
paths.lock,
|
|
193
|
+
lambda: request_status(paths.socket),
|
|
194
|
+
(ConnectionError, OSError, ProtocolError),
|
|
195
|
+
)
|
|
196
|
+
except (ConnectionError, OSError, ProtocolError):
|
|
197
|
+
pass
|
|
198
|
+
if live is None and daemon_lock_held(paths.lock):
|
|
199
|
+
click.echo("running: unresponsive")
|
|
200
|
+
else:
|
|
201
|
+
click.echo(f"running: {'yes' if live is not None else 'no'}")
|
|
202
|
+
if live is not None:
|
|
203
|
+
click.echo(f"version: {live.build}")
|
|
204
|
+
click.echo(f"installed: {version_label()}")
|
|
205
|
+
click.echo(f"started: {live.started_at}")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
# One `_store()` call apiece, all against the same file: if the first
|
|
209
|
+
# (`_watermark_line`'s) open fails, every other one would fail
|
|
210
|
+
# identically, so one `try` around all five is enough, and none of
|
|
211
|
+
# them can print a partial line before that first failure.
|
|
212
|
+
click.echo(f"watermark: {_watermark_line(paths)}")
|
|
213
|
+
click.echo(f"queue depth: {_queue_line(paths)}")
|
|
214
|
+
click.echo(f"cursors: {_cursor_line(paths)}")
|
|
215
|
+
click.echo(f"parked: {_parked_line(paths)}")
|
|
216
|
+
click.echo(f"database: {_database_line(paths)}")
|
|
217
|
+
except (sqlite3.DatabaseError, ValueError) as exc:
|
|
218
|
+
# A corrupt file or a schema newer than this build reads raises here
|
|
219
|
+
# (`_check_version`'s own refusal) -- precisely the moment the daemon
|
|
220
|
+
# may have failed to start, so this diagnostic surface must report it
|
|
221
|
+
# rather than crash before reaching the log and configuration lines
|
|
222
|
+
# below, which need no store at all.
|
|
223
|
+
click.echo(f"database: unreadable — {exc}")
|
|
224
|
+
click.echo(f"last error: {last_error(paths.log) or 'none reported'}")
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
config = _effective_config(live.config if live is not None else None)
|
|
228
|
+
except ValueError as exc:
|
|
229
|
+
# No live status means `resolve()`'s own environment fallback
|
|
230
|
+
# ran, and its validation is exactly what could be *why* the daemon
|
|
231
|
+
# never started -- the one diagnostic surface for that case must not
|
|
232
|
+
# itself crash on it. `from_document` above already never raises for
|
|
233
|
+
# this same reason; this matches it rather than adding a second rule.
|
|
234
|
+
click.echo(f"config: unresolvable — {exc}")
|
|
235
|
+
else:
|
|
236
|
+
click.echo("config:")
|
|
237
|
+
for name, entry in config.as_document().items():
|
|
238
|
+
click.echo(f" {name}: {entry['value']} ({entry['source']})")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _effective_config(document: dict[str, object] | None) -> DaemonConfig:
|
|
242
|
+
"""What the daemon resolved, when it is there to say so.
|
|
243
|
+
|
|
244
|
+
Falling back to this process's own environment is a worse answer and is
|
|
245
|
+
labelled as such by the sources it produces — it is still better than
|
|
246
|
+
printing nothing, because the common case for an operator running `status`
|
|
247
|
+
is a daemon that is not running at all.
|
|
248
|
+
"""
|
|
249
|
+
if document is not None:
|
|
250
|
+
return DaemonConfig.from_document(document)
|
|
251
|
+
return DaemonConfig.resolve()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _store(paths: RuntimePaths) -> FindingStore | None:
|
|
255
|
+
if not paths.state.exists():
|
|
256
|
+
return None
|
|
257
|
+
# A reader, so it neither sweeps nor writes: `read_only` is SQLite's own
|
|
258
|
+
# `mode=ro`, which is what keeps an inspection command from racing the
|
|
259
|
+
# daemon's first `connect()` through schema creation (`db.connect`).
|
|
260
|
+
return FindingStore(paths.state, prune_on_open=False, read_only=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _watermark_line(paths: RuntimePaths) -> str:
|
|
264
|
+
store = _store(paths)
|
|
265
|
+
if store is None:
|
|
266
|
+
return "never scanned (no database yet)"
|
|
267
|
+
moment = store.scan_watermark()
|
|
268
|
+
if moment is None:
|
|
269
|
+
return "never scanned"
|
|
270
|
+
age = datetime.now(UTC) - moment
|
|
271
|
+
return f"{moment.isoformat()} ({int(age.total_seconds())}s ago)"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _queue_line(paths: RuntimePaths) -> str:
|
|
275
|
+
store = _store(paths)
|
|
276
|
+
return "0 (no database yet)" if store is None else f"{store.queue_depth()} rows"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _cursor_line(paths: RuntimePaths) -> str:
|
|
280
|
+
"""Where the Fleet drain has got to, per agent kind.
|
|
281
|
+
|
|
282
|
+
No row at all is the answer for an unconfigured endpoint, and it is the
|
|
283
|
+
right one: retention prunes below the cursor floor, so a subscriber that
|
|
284
|
+
cannot run must hold no cursor rather than one at zero.
|
|
285
|
+
"""
|
|
286
|
+
store = _store(paths)
|
|
287
|
+
if store is None:
|
|
288
|
+
return "none (no database yet)"
|
|
289
|
+
cursors = store.cursors()
|
|
290
|
+
if not cursors:
|
|
291
|
+
return "none held (retention falls back to age alone)"
|
|
292
|
+
return ", ".join(f"{kind}@{seq}" for kind, seq in sorted(cursors.items()))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _parked_line(paths: RuntimePaths) -> str:
|
|
296
|
+
"""Rows the far side rejected on their own merits, newest first.
|
|
297
|
+
|
|
298
|
+
Read from the table rather than from memory, so a rejection that happened
|
|
299
|
+
before the last restart is not silently forgotten.
|
|
300
|
+
"""
|
|
301
|
+
store = _store(paths)
|
|
302
|
+
if store is None:
|
|
303
|
+
return "0"
|
|
304
|
+
newest = store.newest_park()
|
|
305
|
+
if newest is None:
|
|
306
|
+
return f"{store.parked_count()}"
|
|
307
|
+
return (
|
|
308
|
+
f"{store.parked_count()} (newest: seq {newest['seq']} {newest['kind']} "
|
|
309
|
+
f"at {newest['parked_at']} — {newest['reason']})"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _database_line(paths: RuntimePaths) -> str:
|
|
314
|
+
store = _store(paths)
|
|
315
|
+
if store is None:
|
|
316
|
+
return f"absent ({paths.state})"
|
|
317
|
+
return f"{store.database_size()} bytes ({paths.state})"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@daemon.command()
|
|
321
|
+
def start() -> None:
|
|
322
|
+
"""Start one detached daemon when no host service manager is available."""
|
|
323
|
+
_runtime()
|
|
324
|
+
from .lifecycle import StartOutcome, start_daemon, transition_lock
|
|
325
|
+
|
|
326
|
+
paths = RuntimePaths.from_environment()
|
|
327
|
+
try:
|
|
328
|
+
with transition_lock(paths.transition_lock):
|
|
329
|
+
outcome = start_daemon(paths)
|
|
330
|
+
except (OSError, RuntimeError) as error:
|
|
331
|
+
raise click.ClickException(f"could not start daemon: {error}") from error
|
|
332
|
+
if outcome is StartOutcome.ALREADY_RUNNING:
|
|
333
|
+
click.echo("Stacktrace daemon is already running.")
|
|
334
|
+
else:
|
|
335
|
+
click.echo("Stacktrace daemon started.")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@daemon.command()
|
|
339
|
+
def stop() -> None:
|
|
340
|
+
"""Ask the running daemon to shut down cleanly."""
|
|
341
|
+
from .client import ProtocolError, request_shutdown
|
|
342
|
+
from .lifecycle import daemon_lock_held, transition_lock
|
|
343
|
+
|
|
344
|
+
paths = RuntimePaths.from_environment()
|
|
345
|
+
with transition_lock(paths.transition_lock):
|
|
346
|
+
if not daemon_lock_held(paths.lock):
|
|
347
|
+
click.echo("Stacktrace daemon is not running.")
|
|
348
|
+
return
|
|
349
|
+
try:
|
|
350
|
+
_retry_while_starting(
|
|
351
|
+
paths.lock,
|
|
352
|
+
lambda: request_shutdown(paths.socket),
|
|
353
|
+
(ConnectionError, OSError, ProtocolError),
|
|
354
|
+
)
|
|
355
|
+
except (ConnectionError, OSError, ProtocolError) as error:
|
|
356
|
+
if not daemon_lock_held(paths.lock):
|
|
357
|
+
click.echo("Stacktrace daemon stopped.")
|
|
358
|
+
return
|
|
359
|
+
raise click.ClickException(
|
|
360
|
+
"daemon is running but unresponsive; restart it through its host "
|
|
361
|
+
"supervisor or use OS process tools"
|
|
362
|
+
) from error
|
|
363
|
+
deadline = time.monotonic() + _STOP_TIMEOUT_SECONDS
|
|
364
|
+
while time.monotonic() < deadline:
|
|
365
|
+
if not daemon_lock_held(paths.lock):
|
|
366
|
+
click.echo("Stacktrace daemon stopped.")
|
|
367
|
+
return
|
|
368
|
+
time.sleep(0.1)
|
|
369
|
+
raise click.ClickException(f"daemon did not stop; see {paths.log}")
|
|
35
370
|
|
|
36
371
|
|
|
37
372
|
@daemon.command()
|
|
@@ -60,6 +395,74 @@ def subscribe(agent_kind: str, session_id: str) -> None:
|
|
|
60
395
|
raise click.ClickException(str(error)) from error
|
|
61
396
|
|
|
62
397
|
|
|
398
|
+
@daemon.command()
|
|
399
|
+
def flush() -> None:
|
|
400
|
+
"""Upload what is queued now, without waiting for the next interval.
|
|
401
|
+
|
|
402
|
+
One drain pass, reported. A large backlog drains over several passes —
|
|
403
|
+
`_batches` splits by serialised size and the read is capped — so this
|
|
404
|
+
reports what one pass did rather than looping until the queue is empty.
|
|
405
|
+
|
|
406
|
+
There is no fallback to scanning. An endpoint with no daemon uploads by
|
|
407
|
+
re-collecting (`stacktrace remote sync detect`), and a command that
|
|
408
|
+
silently switched sources would make its output mean two different things.
|
|
409
|
+
"""
|
|
410
|
+
daemon_is_running, _, _ = _runtime()
|
|
411
|
+
from .client import ProtocolError, request_flush
|
|
412
|
+
|
|
413
|
+
paths = RuntimePaths.from_environment()
|
|
414
|
+
if not daemon_is_running(paths.socket):
|
|
415
|
+
raise click.ClickException(
|
|
416
|
+
"no daemon is running, so there is no queue to drain; this endpoint "
|
|
417
|
+
"uploads by scanning instead — run `stacktrace remote sync detect`"
|
|
418
|
+
)
|
|
419
|
+
try:
|
|
420
|
+
result = request_flush(paths.socket)
|
|
421
|
+
except (OSError, ProtocolError) as error:
|
|
422
|
+
raise click.ClickException(f"flush failed: {error}") from error
|
|
423
|
+
|
|
424
|
+
click.echo(f"uploaded: {result.uploaded}")
|
|
425
|
+
click.echo(f"parked: {result.parked}")
|
|
426
|
+
click.echo(f"kinds: {', '.join(result.kinds) or 'none'}")
|
|
427
|
+
if result.stopped is not None:
|
|
428
|
+
# Not an echo: `flush && <something that ends the machine>` has to see
|
|
429
|
+
# a non-zero exit when nothing left.
|
|
430
|
+
raise click.ClickException(f"stopped: {result.stopped}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@daemon.command("sync-bom")
|
|
434
|
+
def sync_bom() -> None:
|
|
435
|
+
"""Upload the endpoint composition now, instead of on `--bom-interval`.
|
|
436
|
+
|
|
437
|
+
The same `sync_endpoint()` the scheduled job runs, serialised with it: two
|
|
438
|
+
runs share the spool directory and the config file the asset id is written
|
|
439
|
+
to. Progress still goes to the daemon log, and is echoed here as well so a
|
|
440
|
+
local run does not also require tailing it.
|
|
441
|
+
|
|
442
|
+
`stacktrace remote sync endpoint` does the same upload without a daemon.
|
|
443
|
+
This one exists to exercise the daemon's own path — the reporter, the
|
|
444
|
+
serialisation, and the composition the resident process is holding.
|
|
445
|
+
"""
|
|
446
|
+
daemon_is_running, _, _ = _runtime()
|
|
447
|
+
from .client import ProtocolError, request_sync_bom
|
|
448
|
+
|
|
449
|
+
paths = RuntimePaths.from_environment()
|
|
450
|
+
if not daemon_is_running(paths.socket):
|
|
451
|
+
raise click.ClickException(
|
|
452
|
+
"no daemon is running; to upload without one, run `stacktrace remote sync endpoint`"
|
|
453
|
+
)
|
|
454
|
+
try:
|
|
455
|
+
result = request_sync_bom(paths.socket)
|
|
456
|
+
except (OSError, ProtocolError) as error:
|
|
457
|
+
raise click.ClickException(f"bom sync failed: {error}") from error
|
|
458
|
+
|
|
459
|
+
for line in result.report:
|
|
460
|
+
click.echo(line)
|
|
461
|
+
click.echo(f"uploaded: {result.uploaded}")
|
|
462
|
+
if result.failure is not None:
|
|
463
|
+
raise click.ClickException(result.failure)
|
|
464
|
+
|
|
465
|
+
|
|
63
466
|
def _runtime():
|
|
64
467
|
if os.name != "posix":
|
|
65
468
|
raise click.ClickException(
|
|
@@ -73,15 +476,14 @@ def _runtime():
|
|
|
73
476
|
@click.command()
|
|
74
477
|
@click.option(
|
|
75
478
|
"--agent-kind",
|
|
76
|
-
|
|
77
|
-
help="
|
|
479
|
+
default=None,
|
|
480
|
+
help="Only this agent kind. Every kind by default.",
|
|
78
481
|
)
|
|
79
482
|
@click.option(
|
|
80
483
|
"--session",
|
|
81
484
|
"session_id",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
help="Native session ID. Defaults to CLAUDE_CODE_SESSION_ID.",
|
|
485
|
+
default=None,
|
|
486
|
+
help="Only this native session ID. Every session by default.",
|
|
85
487
|
)
|
|
86
488
|
@click.option(
|
|
87
489
|
"--format",
|
|
@@ -90,15 +492,47 @@ def _runtime():
|
|
|
90
492
|
default="text",
|
|
91
493
|
show_default=True,
|
|
92
494
|
)
|
|
93
|
-
def findings(agent_kind: str, session_id: str, output_format: str) -> None:
|
|
94
|
-
"""Show Stacktrace
|
|
495
|
+
def findings(agent_kind: str | None, session_id: str | None, output_format: str) -> None:
|
|
496
|
+
"""Show what Stacktrace has found on this machine.
|
|
497
|
+
|
|
498
|
+
Neither key is required, and `--session` deliberately does **not** read
|
|
499
|
+
`CLAUDE_CODE_SESSION_ID`: the unqualified command would then be silently
|
|
500
|
+
scoped to one session inside an agent session, which is the one place an
|
|
501
|
+
operator is most likely to run it and least likely to notice.
|
|
502
|
+
"""
|
|
95
503
|
paths = RuntimePaths.from_environment()
|
|
96
|
-
|
|
504
|
+
# `_store()`, not a bare `FindingStore(...)`: a reader must not initialize
|
|
505
|
+
# the database as a side effect of reporting that there is none -- besides
|
|
506
|
+
# the surprise, two processes racing that (this command and a daemon's own
|
|
507
|
+
# first open) could both see an unversioned file and each try to insert
|
|
508
|
+
# the same schema_version row.
|
|
509
|
+
try:
|
|
510
|
+
store = _store(paths)
|
|
511
|
+
except (sqlite3.DatabaseError, ValueError) as exc:
|
|
512
|
+
# A corrupt file or a schema newer than this build reads raises here
|
|
513
|
+
# (`_check_version`'s own refusal) -- an inspection command must
|
|
514
|
+
# report that rather than exit on a traceback.
|
|
515
|
+
raise click.ClickException(f"database unreadable — {exc}") from exc
|
|
516
|
+
stored = () if store is None else store.recorded(agent_kind=agent_kind, session_id=session_id)
|
|
97
517
|
if output_format == "json":
|
|
98
|
-
|
|
518
|
+
# Additive, not a new envelope: `findings --format json` shipped in
|
|
519
|
+
# v0.4.0 as a list of findings, and a reader of `finding["rule_id"]`
|
|
520
|
+
# keeps working. The row's own columns win over any same-named key in
|
|
521
|
+
# the payload, because they are what the store filed it under.
|
|
522
|
+
document = [
|
|
523
|
+
{**finding, "agent_kind": session.agent_kind, "session_id": session.session_id}
|
|
524
|
+
for session, finding in stored
|
|
525
|
+
]
|
|
526
|
+
click.echo(json.dumps({"findings": document}, indent=2))
|
|
99
527
|
return
|
|
100
528
|
if not stored:
|
|
101
|
-
click.echo(
|
|
529
|
+
click.echo(
|
|
530
|
+
"No Stacktrace findings for this session." if session_id else "No Stacktrace findings."
|
|
531
|
+
)
|
|
102
532
|
return
|
|
103
|
-
for finding in stored:
|
|
533
|
+
for session, finding in stored:
|
|
534
|
+
# The session is named on every row, not grouped into a header: the
|
|
535
|
+
# list is read by eye and by grep, and a header is lost to both once
|
|
536
|
+
# the output is filtered.
|
|
537
|
+
click.echo(f"[{session.agent_kind}:{session.session_id}]")
|
|
104
538
|
click.echo(render_finding(finding))
|