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.
Files changed (45) hide show
  1. stacktrace_cli/__init__.py +8 -1
  2. stacktrace_cli/cli.py +2 -0
  3. stacktrace_cli/cloud/__init__.py +39 -0
  4. stacktrace_cli/cloud/setup.sh +247 -0
  5. stacktrace_cli/daemon/activity_log.py +94 -0
  6. stacktrace_cli/daemon/bom.py +56 -0
  7. stacktrace_cli/daemon/cli.py +453 -19
  8. stacktrace_cli/daemon/client.py +230 -9
  9. stacktrace_cli/daemon/composition.py +112 -3
  10. stacktrace_cli/daemon/config.py +191 -0
  11. stacktrace_cli/daemon/db.py +625 -0
  12. stacktrace_cli/daemon/fleet.py +660 -0
  13. stacktrace_cli/daemon/identity.py +83 -0
  14. stacktrace_cli/daemon/jobs.py +181 -0
  15. stacktrace_cli/daemon/lifecycle.py +172 -0
  16. stacktrace_cli/daemon/observer.py +545 -92
  17. stacktrace_cli/daemon/paths.py +6 -1
  18. stacktrace_cli/daemon/protocol.py +125 -8
  19. stacktrace_cli/daemon/runtime.py +572 -100
  20. stacktrace_cli/daemon/scan.py +256 -0
  21. stacktrace_cli/daemon/server.py +141 -2
  22. stacktrace_cli/daemon/service.py +98 -121
  23. stacktrace_cli/daemon/store.py +789 -258
  24. stacktrace_cli/daemon/webhook.py +508 -0
  25. stacktrace_cli/detector/render.py +31 -4
  26. stacktrace_cli/monitor/site/app.js +1 -1
  27. stacktrace_cli/monitor/site/index.html +3 -4
  28. stacktrace_cli/remote/cli.py +28 -19
  29. stacktrace_cli/remote/client.py +29 -0
  30. stacktrace_cli/remote/detect_payload.py +147 -25
  31. stacktrace_cli/remote/identity.py +305 -0
  32. stacktrace_cli/remote/payload.py +7 -4
  33. stacktrace_cli/remote/redact.py +773 -105
  34. stacktrace_cli/remote/sync.py +330 -98
  35. stacktrace_cli/remote/sync_detect.py +134 -64
  36. stacktrace_cli/remote/upload_contract.py +10 -1
  37. stacktrace_cli/telemetry/events.py +12 -6
  38. stacktrace_cli/telemetry/posthog.py +34 -5
  39. stacktrace_cli/webhook/__init__.py +1 -0
  40. stacktrace_cli/webhook/cli.py +169 -0
  41. stacktrace_cli/webhook/config.py +193 -0
  42. {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/METADATA +30 -2
  43. {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/RECORD +45 -29
  44. {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/entry_points.txt +1 -0
  45. {stacktrace_cli-0.5.0.dist-info → stacktrace_cli-0.5.2.dist-info}/WHEEL +0 -0
@@ -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
- @daemon.command("run", hidden=True)
22
- def run_command() -> None:
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
- run_daemon(RuntimePaths.from_environment())
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
- """Show whether the local Stacktrace daemon is running."""
30
- daemon_is_running, _, _ = _runtime()
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
- if not daemon_is_running(paths.socket):
33
- raise click.ClickException("Stacktrace daemon is not running")
34
- click.echo("Stacktrace daemon is running.")
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
- required=True,
77
- help="Agent kind that owns the native session.",
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
- envvar="CLAUDE_CODE_SESSION_ID",
83
- required=True,
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 findings for one native agent session."""
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
- stored = FindingStore(paths.state).findings(SessionKey(agent_kind, session_id))
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
- click.echo(json.dumps({"findings": stored}, indent=2))
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("No Stacktrace findings for this session.")
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))