stacktrace-cli 0.4.1__py3-none-any.whl → 0.5.1__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/daemon/activity_log.py +94 -0
- stacktrace_cli/daemon/bom.py +56 -0
- stacktrace_cli/daemon/cli.py +321 -20
- stacktrace_cli/daemon/client.py +115 -2
- stacktrace_cli/daemon/composition.py +112 -3
- stacktrace_cli/daemon/config.py +182 -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/observer.py +545 -92
- stacktrace_cli/daemon/paths.py +9 -1
- stacktrace_cli/daemon/presentation.py +68 -0
- stacktrace_cli/daemon/protocol.py +106 -8
- stacktrace_cli/daemon/runtime.py +521 -70
- stacktrace_cli/daemon/scan.py +256 -0
- stacktrace_cli/daemon/server.py +63 -2
- stacktrace_cli/daemon/service.py +78 -116
- stacktrace_cli/daemon/store.py +770 -260
- stacktrace_cli/daemon/version.py +49 -0
- stacktrace_cli/detector/render.py +23 -0
- 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 +9 -2
- stacktrace_cli/telemetry/posthog.py +34 -5
- {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/METADATA +2 -2
- {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/RECORD +37 -26
- {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/entry_points.txt +0 -0
stacktrace_cli/__init__.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
"""The `stacktrace` command-line interface — detection and response for AI agents."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import logging as _logging
|
|
4
|
+
|
|
5
|
+
__version__ = "0.5.1"
|
|
6
|
+
|
|
7
|
+
# Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
|
|
8
|
+
# logged in any other process would reach Python's last-resort handler and
|
|
9
|
+
# print onto the user's terminal.
|
|
10
|
+
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The daemon's activity log: leveled, private and capped (ADR-0048).
|
|
2
|
+
|
|
3
|
+
Every module logs to its own `logging.getLogger(__name__)`. Only the daemon
|
|
4
|
+
attaches a file handler, to the `stacktrace_cli` package logger; everywhere
|
|
5
|
+
else the package's `NullHandler` keeps those lines off the terminal.
|
|
6
|
+
|
|
7
|
+
A line names kinds, counts, class names and event ids. It never carries a
|
|
8
|
+
finding's title or body, an exception message, or anything from a transcript,
|
|
9
|
+
and a peer-supplied session id is written with `%r`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from logging.handlers import RotatingFileHandler
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from stacktrace_cli.private_state import ensure_private_directory
|
|
23
|
+
|
|
24
|
+
LEVEL_VARIABLE = "STACKTRACE_DAEMON_LOG_LEVEL"
|
|
25
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
26
|
+
BACKUP_COUNT = 1
|
|
27
|
+
PACKAGE = "stacktrace_cli"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def private_opener(path: str, flags: int) -> int:
|
|
31
|
+
return os.open(path, flags, 0o600)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _PrivateRotatingFileHandler(RotatingFileHandler):
|
|
35
|
+
def __init__(self, path: Path, *, max_bytes: int, own_std_streams: bool) -> None:
|
|
36
|
+
self._own_std_streams = own_std_streams
|
|
37
|
+
super().__init__(path, maxBytes=max_bytes, backupCount=BACKUP_COUNT, encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
def _open(self): # type: ignore[override]
|
|
40
|
+
stream = open( # noqa: SIM115 - the handler owns and closes it
|
|
41
|
+
self.baseFilename,
|
|
42
|
+
self.mode,
|
|
43
|
+
encoding=self.encoding,
|
|
44
|
+
errors=self.errors,
|
|
45
|
+
opener=private_opener,
|
|
46
|
+
)
|
|
47
|
+
# The opener's mode applies only to a file it creates.
|
|
48
|
+
os.chmod(self.baseFilename, 0o600)
|
|
49
|
+
return stream
|
|
50
|
+
|
|
51
|
+
def doRollover(self) -> None:
|
|
52
|
+
super().doRollover()
|
|
53
|
+
# `ensure_daemon` pointed this process's stdout and stderr at the file
|
|
54
|
+
# just renamed away. Follow it, or a crash traceback lands in the
|
|
55
|
+
# backup and is deleted with it at the next rollover.
|
|
56
|
+
if self._own_std_streams and self.stream is not None:
|
|
57
|
+
sys.stdout.flush()
|
|
58
|
+
sys.stderr.flush()
|
|
59
|
+
os.dup2(self.stream.fileno(), 1)
|
|
60
|
+
os.dup2(self.stream.fileno(), 2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def level_from(environment: Mapping[str, str]) -> int:
|
|
64
|
+
name = environment.get(LEVEL_VARIABLE, "").strip().upper()
|
|
65
|
+
return logging.getLevelNamesMapping().get(name, logging.DEBUG)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configure(
|
|
69
|
+
path: Path,
|
|
70
|
+
*,
|
|
71
|
+
environ: Mapping[str, str] | None = None,
|
|
72
|
+
own_std_streams: bool = False,
|
|
73
|
+
max_bytes: int = MAX_BYTES,
|
|
74
|
+
) -> logging.Handler:
|
|
75
|
+
ensure_private_directory(path.parent)
|
|
76
|
+
handler = _PrivateRotatingFileHandler(
|
|
77
|
+
path, max_bytes=max_bytes, own_std_streams=own_std_streams
|
|
78
|
+
)
|
|
79
|
+
formatter = logging.Formatter(
|
|
80
|
+
"%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ"
|
|
81
|
+
)
|
|
82
|
+
formatter.converter = time.gmtime
|
|
83
|
+
handler.setFormatter(formatter)
|
|
84
|
+
logger = logging.getLogger(PACKAGE)
|
|
85
|
+
logger.setLevel(level_from(os.environ if environ is None else environ))
|
|
86
|
+
logger.addHandler(handler)
|
|
87
|
+
return handler
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def release(handler: logging.Handler) -> None:
|
|
91
|
+
logger = logging.getLogger(PACKAGE)
|
|
92
|
+
logger.removeHandler(handler)
|
|
93
|
+
logger.setLevel(logging.NOTSET)
|
|
94
|
+
handler.close()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The BOM job: `sync_endpoint()` on a timer.
|
|
2
|
+
|
|
3
|
+
A full snapshot of what is installed, not an append stream — so there is nothing
|
|
4
|
+
to cursor, and the spool already handles offline retry. It reads no part of the
|
|
5
|
+
finding queue, and it must upload on a machine with a broken detector, an empty
|
|
6
|
+
database, or no session ever scanned.
|
|
7
|
+
|
|
8
|
+
**It re-collects rather than uploading the daemon's warm `CompositionSnapshots`.**
|
|
9
|
+
`docs/specs/remote-sync.md`'s bar requires the payload be byte-identical to
|
|
10
|
+
OpenACA's for the same endpoint state, and the snapshot is built for a different
|
|
11
|
+
scope — `("claude-code", None)`, user scope with no project layering — by a
|
|
12
|
+
different path than `_collect()`. Reusing it would be a payload change wearing
|
|
13
|
+
the costume of a performance win.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from stacktrace_cli.remote.sync import SyncError, sync_endpoint
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class BomJobReport:
|
|
26
|
+
"""What one run did, for a caller that has to tell the two zeros apart.
|
|
27
|
+
|
|
28
|
+
A machine with nothing installed and a machine that could not reach the
|
|
29
|
+
Cloud both upload nothing. The scheduled job does not care — it is due
|
|
30
|
+
again either way — but `stacktrace daemon sync-bom` is asked by a person,
|
|
31
|
+
and answering "0" to both is answering neither.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
uploaded: int
|
|
35
|
+
failure: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_bom_job(*, report: Callable[[str], None]) -> BomJobReport:
|
|
39
|
+
"""One `sync endpoint`, with its progress sent somewhere a daemon can write.
|
|
40
|
+
|
|
41
|
+
`drain_the_spool` and `allow_offline_cache` semantics are exactly as the CLI
|
|
42
|
+
has them: a machine that is offline caches its collection and the next run
|
|
43
|
+
replays it. `SyncError` is an outcome here rather than an exit code — there
|
|
44
|
+
is no process to exit — so it is reported and the job is simply due again.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
results = sync_endpoint(
|
|
48
|
+
project=None,
|
|
49
|
+
quiet=True,
|
|
50
|
+
allow_offline_cache=True,
|
|
51
|
+
reporter=report,
|
|
52
|
+
)
|
|
53
|
+
except SyncError as error:
|
|
54
|
+
report(f"bom sync: {error}")
|
|
55
|
+
return BomJobReport(uploaded=0, failure=str(error))
|
|
56
|
+
return BomJobReport(uploaded=len(results))
|
stacktrace_cli/daemon/cli.py
CHANGED
|
@@ -4,11 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import sqlite3
|
|
7
8
|
import sys
|
|
9
|
+
from datetime import UTC, datetime
|
|
8
10
|
|
|
9
11
|
import click
|
|
10
12
|
|
|
13
|
+
from .config import DaemonConfig
|
|
11
14
|
from .paths import RuntimePaths
|
|
15
|
+
from .presentation import render_finding
|
|
12
16
|
from .store import FindingStore, SessionKey
|
|
13
17
|
|
|
14
18
|
|
|
@@ -18,19 +22,220 @@ def daemon() -> None:
|
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
@daemon.command("run", hidden=True)
|
|
21
|
-
|
|
25
|
+
# No `envvar=` on any of these. Click resolves an environment variable *into*
|
|
26
|
+
# the parameter, so a value that came from the environment would arrive here
|
|
27
|
+
# indistinguishable from one typed on the command line — and `daemon status`
|
|
28
|
+
# would report every forked daemon's settings as "(flag)", which is precisely
|
|
29
|
+
# the drift the source label exists to expose. `DaemonConfig.resolve` reads the
|
|
30
|
+
# environment itself, and knows which answer it gave.
|
|
31
|
+
@click.option(
|
|
32
|
+
"--scan-interval",
|
|
33
|
+
default=None,
|
|
34
|
+
help="How often to enumerate and analyse changed sessions. 0 disables scanning."
|
|
35
|
+
" Defaults to STACKTRACE_SCAN_INTERVAL, then 5s.",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--scan-since",
|
|
39
|
+
default=None,
|
|
40
|
+
help="Bootstrap horizon for the first scan only; later scans use the watermark."
|
|
41
|
+
" Defaults to STACKTRACE_SCAN_SINCE, then 3d.",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--agent-kind",
|
|
45
|
+
"agent_kinds",
|
|
46
|
+
multiple=True,
|
|
47
|
+
help="Agent kind to observe. Repeatable. Defaults to STACKTRACE_AGENT_KIND, then claude-code.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--fleet-interval",
|
|
51
|
+
default=None,
|
|
52
|
+
help="How often to drain the finding queue to Fleet as a fallback poll. 0 disables only"
|
|
53
|
+
" that fallback: a finding still uploads immediately when the scan commits it."
|
|
54
|
+
" Defaults to STACKTRACE_FLEET_INTERVAL, then 5m.",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--bom-interval",
|
|
58
|
+
default=None,
|
|
59
|
+
help="How often to upload endpoint composition. 0 disables it."
|
|
60
|
+
" Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
|
|
61
|
+
)
|
|
62
|
+
def run_command(
|
|
63
|
+
scan_interval: str | None,
|
|
64
|
+
scan_since: str | None,
|
|
65
|
+
agent_kinds: tuple[str, ...],
|
|
66
|
+
fleet_interval: str | None,
|
|
67
|
+
bom_interval: str | None,
|
|
68
|
+
) -> None:
|
|
22
69
|
_, _, run_daemon = _runtime()
|
|
23
|
-
|
|
70
|
+
paths = RuntimePaths.from_environment()
|
|
71
|
+
try:
|
|
72
|
+
config = DaemonConfig.resolve(
|
|
73
|
+
scan_interval=scan_interval,
|
|
74
|
+
scan_since=scan_since,
|
|
75
|
+
agent_kinds=agent_kinds,
|
|
76
|
+
fleet_interval=fleet_interval,
|
|
77
|
+
bom_interval=bom_interval,
|
|
78
|
+
)
|
|
79
|
+
_reject_unobservable_kinds(config)
|
|
80
|
+
except ValueError as error:
|
|
81
|
+
raise click.ClickException(str(error)) from error
|
|
82
|
+
# The real daemon process: its stdout and stderr are the log file
|
|
83
|
+
# (`ensure_daemon`), so they follow it across a rollover (ADR-0048).
|
|
84
|
+
run_daemon(paths, config, own_std_streams=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reject_unobservable_kinds(config: DaemonConfig) -> None:
|
|
88
|
+
"""A kind with no observer would scan nothing, silently.
|
|
89
|
+
|
|
90
|
+
Fails at startup rather than at the first tick: `cursor` and `codex` have
|
|
91
|
+
composition coverage but no runtime detection yet (`daemon-scanning.md`
|
|
92
|
+
§ *Deferred*), and a daemon quietly finding nothing for one of them is the
|
|
93
|
+
failure this refusal exists to make loud.
|
|
94
|
+
"""
|
|
95
|
+
from .runtime import OBSERVERS
|
|
96
|
+
|
|
97
|
+
unknown = [kind for kind in config.kinds if kind not in OBSERVERS]
|
|
98
|
+
if unknown:
|
|
99
|
+
known = ", ".join(sorted(OBSERVERS))
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"no session observer for {', '.join(unknown)}; this build observes {known}"
|
|
102
|
+
)
|
|
24
103
|
|
|
25
104
|
|
|
26
105
|
@daemon.command()
|
|
27
106
|
def status() -> None:
|
|
28
|
-
"""
|
|
107
|
+
"""Report whether this endpoint is working, without reading a log.
|
|
108
|
+
|
|
109
|
+
Every row answers a question someone asks when a machine looks quiet: a
|
|
110
|
+
watermark hours old means the scanner has stopped; a queue that grows with
|
|
111
|
+
a reader attached means the reader is not draining; a database that only
|
|
112
|
+
grows means retention is not pruning; and a configuration without its source
|
|
113
|
+
makes fleet-wide drift undebuggable (`docs/specs/daemon-scanning.md`).
|
|
114
|
+
"""
|
|
29
115
|
daemon_is_running, _, _ = _runtime()
|
|
116
|
+
from .runtime import last_error, read_run_state
|
|
117
|
+
|
|
30
118
|
paths = RuntimePaths.from_environment()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
119
|
+
running = daemon_is_running(paths.socket)
|
|
120
|
+
run_state = read_run_state(paths)
|
|
121
|
+
|
|
122
|
+
click.echo(f"running: {'yes' if running else 'no'}")
|
|
123
|
+
if isinstance(run_state, dict):
|
|
124
|
+
click.echo(f"pid: {run_state.get('pid')}")
|
|
125
|
+
click.echo(f"version: {run_state.get('version')}")
|
|
126
|
+
click.echo(f"started: {run_state.get('started_at')}")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
# One `_store()` call apiece, all against the same file: if the first
|
|
130
|
+
# (`_watermark_line`'s) open fails, every other one would fail
|
|
131
|
+
# identically, so one `try` around all five is enough, and none of
|
|
132
|
+
# them can print a partial line before that first failure.
|
|
133
|
+
click.echo(f"watermark: {_watermark_line(paths)}")
|
|
134
|
+
click.echo(f"queue depth: {_queue_line(paths)}")
|
|
135
|
+
click.echo(f"cursors: {_cursor_line(paths)}")
|
|
136
|
+
click.echo(f"parked: {_parked_line(paths)}")
|
|
137
|
+
click.echo(f"database: {_database_line(paths)}")
|
|
138
|
+
except (sqlite3.DatabaseError, ValueError) as exc:
|
|
139
|
+
# A corrupt file or a schema newer than this build reads raises here
|
|
140
|
+
# (`_check_version`'s own refusal) -- precisely the moment the daemon
|
|
141
|
+
# may have failed to start, so this diagnostic surface must report it
|
|
142
|
+
# rather than crash before reaching the log and configuration lines
|
|
143
|
+
# below, which need no store at all.
|
|
144
|
+
click.echo(f"database: unreadable — {exc}")
|
|
145
|
+
click.echo(f"last error: {last_error(paths.log) or 'none reported'}")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
config = _effective_config(run_state)
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
# No readable run-state means `resolve()`'s own environment fallback
|
|
151
|
+
# ran, and its validation is exactly what could be *why* the daemon
|
|
152
|
+
# never started -- the one diagnostic surface for that case must not
|
|
153
|
+
# itself crash on it. `from_document` above already never raises for
|
|
154
|
+
# this same reason; this matches it rather than adding a second rule.
|
|
155
|
+
click.echo(f"config: unresolvable — {exc}")
|
|
156
|
+
else:
|
|
157
|
+
click.echo("config:")
|
|
158
|
+
for name, entry in config.as_document().items():
|
|
159
|
+
click.echo(f" {name}: {entry['value']} ({entry['source']})")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _effective_config(run_state: dict[str, object] | None) -> DaemonConfig:
|
|
163
|
+
"""What the daemon resolved, when it is there to say so.
|
|
164
|
+
|
|
165
|
+
Falling back to this process's own environment is a worse answer and is
|
|
166
|
+
labelled as such by the sources it produces — it is still better than
|
|
167
|
+
printing nothing, because the common case for an operator running `status`
|
|
168
|
+
is a daemon that is not running at all.
|
|
169
|
+
"""
|
|
170
|
+
if isinstance(run_state, dict) and isinstance(run_state.get("config"), dict):
|
|
171
|
+
return DaemonConfig.from_document(run_state["config"]) # type: ignore[arg-type]
|
|
172
|
+
return DaemonConfig.resolve()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _store(paths: RuntimePaths) -> FindingStore | None:
|
|
176
|
+
if not paths.state.exists():
|
|
177
|
+
return None
|
|
178
|
+
# A reader, so it neither sweeps nor writes: `read_only` is SQLite's own
|
|
179
|
+
# `mode=ro`, which is what keeps an inspection command from racing the
|
|
180
|
+
# daemon's first `connect()` through schema creation (`db.connect`).
|
|
181
|
+
return FindingStore(paths.state, prune_on_open=False, read_only=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _watermark_line(paths: RuntimePaths) -> str:
|
|
185
|
+
store = _store(paths)
|
|
186
|
+
if store is None:
|
|
187
|
+
return "never scanned (no database yet)"
|
|
188
|
+
moment = store.scan_watermark()
|
|
189
|
+
if moment is None:
|
|
190
|
+
return "never scanned"
|
|
191
|
+
age = datetime.now(UTC) - moment
|
|
192
|
+
return f"{moment.isoformat()} ({int(age.total_seconds())}s ago)"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _queue_line(paths: RuntimePaths) -> str:
|
|
196
|
+
store = _store(paths)
|
|
197
|
+
return "0 (no database yet)" if store is None else f"{store.queue_depth()} rows"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cursor_line(paths: RuntimePaths) -> str:
|
|
201
|
+
"""Where the Fleet drain has got to, per agent kind.
|
|
202
|
+
|
|
203
|
+
No row at all is the answer for an unconfigured endpoint, and it is the
|
|
204
|
+
right one: retention prunes below the cursor floor, so a subscriber that
|
|
205
|
+
cannot run must hold no cursor rather than one at zero.
|
|
206
|
+
"""
|
|
207
|
+
store = _store(paths)
|
|
208
|
+
if store is None:
|
|
209
|
+
return "none (no database yet)"
|
|
210
|
+
cursors = store.cursors()
|
|
211
|
+
if not cursors:
|
|
212
|
+
return "none held (retention falls back to age alone)"
|
|
213
|
+
return ", ".join(f"{kind}@{seq}" for kind, seq in sorted(cursors.items()))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parked_line(paths: RuntimePaths) -> str:
|
|
217
|
+
"""Rows the far side rejected on their own merits, newest first.
|
|
218
|
+
|
|
219
|
+
Read from the table rather than from memory, so a rejection that happened
|
|
220
|
+
before the last restart is not silently forgotten.
|
|
221
|
+
"""
|
|
222
|
+
store = _store(paths)
|
|
223
|
+
if store is None:
|
|
224
|
+
return "0"
|
|
225
|
+
newest = store.newest_park()
|
|
226
|
+
if newest is None:
|
|
227
|
+
return f"{store.parked_count()}"
|
|
228
|
+
return (
|
|
229
|
+
f"{store.parked_count()} (newest: seq {newest['seq']} {newest['kind']} "
|
|
230
|
+
f"at {newest['parked_at']} — {newest['reason']})"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _database_line(paths: RuntimePaths) -> str:
|
|
235
|
+
store = _store(paths)
|
|
236
|
+
if store is None:
|
|
237
|
+
return f"absent ({paths.state})"
|
|
238
|
+
return f"{store.database_size()} bytes ({paths.state})"
|
|
34
239
|
|
|
35
240
|
|
|
36
241
|
@daemon.command()
|
|
@@ -59,6 +264,74 @@ def subscribe(agent_kind: str, session_id: str) -> None:
|
|
|
59
264
|
raise click.ClickException(str(error)) from error
|
|
60
265
|
|
|
61
266
|
|
|
267
|
+
@daemon.command()
|
|
268
|
+
def flush() -> None:
|
|
269
|
+
"""Upload what is queued now, without waiting for the next interval.
|
|
270
|
+
|
|
271
|
+
One drain pass, reported. A large backlog drains over several passes —
|
|
272
|
+
`_batches` splits by serialised size and the read is capped — so this
|
|
273
|
+
reports what one pass did rather than looping until the queue is empty.
|
|
274
|
+
|
|
275
|
+
There is no fallback to scanning. An endpoint with no daemon uploads by
|
|
276
|
+
re-collecting (`stacktrace remote sync detect`), and a command that
|
|
277
|
+
silently switched sources would make its output mean two different things.
|
|
278
|
+
"""
|
|
279
|
+
daemon_is_running, _, _ = _runtime()
|
|
280
|
+
from .client import ProtocolError, request_flush
|
|
281
|
+
|
|
282
|
+
paths = RuntimePaths.from_environment()
|
|
283
|
+
if not daemon_is_running(paths.socket):
|
|
284
|
+
raise click.ClickException(
|
|
285
|
+
"no daemon is running, so there is no queue to drain; this endpoint "
|
|
286
|
+
"uploads by scanning instead — run `stacktrace remote sync detect`"
|
|
287
|
+
)
|
|
288
|
+
try:
|
|
289
|
+
result = request_flush(paths.socket)
|
|
290
|
+
except (OSError, ProtocolError) as error:
|
|
291
|
+
raise click.ClickException(f"flush failed: {error}") from error
|
|
292
|
+
|
|
293
|
+
click.echo(f"uploaded: {result.uploaded}")
|
|
294
|
+
click.echo(f"parked: {result.parked}")
|
|
295
|
+
click.echo(f"kinds: {', '.join(result.kinds) or 'none'}")
|
|
296
|
+
if result.stopped is not None:
|
|
297
|
+
# Not an echo: `flush && <something that ends the machine>` has to see
|
|
298
|
+
# a non-zero exit when nothing left.
|
|
299
|
+
raise click.ClickException(f"stopped: {result.stopped}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@daemon.command("sync-bom")
|
|
303
|
+
def sync_bom() -> None:
|
|
304
|
+
"""Upload the endpoint composition now, instead of on `--bom-interval`.
|
|
305
|
+
|
|
306
|
+
The same `sync_endpoint()` the scheduled job runs, serialised with it: two
|
|
307
|
+
runs share the spool directory and the config file the asset id is written
|
|
308
|
+
to. Progress still goes to the daemon log, and is echoed here as well so a
|
|
309
|
+
local run does not also require tailing it.
|
|
310
|
+
|
|
311
|
+
`stacktrace remote sync endpoint` does the same upload without a daemon.
|
|
312
|
+
This one exists to exercise the daemon's own path — the reporter, the
|
|
313
|
+
serialisation, and the composition the resident process is holding.
|
|
314
|
+
"""
|
|
315
|
+
daemon_is_running, _, _ = _runtime()
|
|
316
|
+
from .client import ProtocolError, request_sync_bom
|
|
317
|
+
|
|
318
|
+
paths = RuntimePaths.from_environment()
|
|
319
|
+
if not daemon_is_running(paths.socket):
|
|
320
|
+
raise click.ClickException(
|
|
321
|
+
"no daemon is running; to upload without one, run `stacktrace remote sync endpoint`"
|
|
322
|
+
)
|
|
323
|
+
try:
|
|
324
|
+
result = request_sync_bom(paths.socket)
|
|
325
|
+
except (OSError, ProtocolError) as error:
|
|
326
|
+
raise click.ClickException(f"bom sync failed: {error}") from error
|
|
327
|
+
|
|
328
|
+
for line in result.report:
|
|
329
|
+
click.echo(line)
|
|
330
|
+
click.echo(f"uploaded: {result.uploaded}")
|
|
331
|
+
if result.failure is not None:
|
|
332
|
+
raise click.ClickException(result.failure)
|
|
333
|
+
|
|
334
|
+
|
|
62
335
|
def _runtime():
|
|
63
336
|
if os.name != "posix":
|
|
64
337
|
raise click.ClickException(
|
|
@@ -72,15 +345,14 @@ def _runtime():
|
|
|
72
345
|
@click.command()
|
|
73
346
|
@click.option(
|
|
74
347
|
"--agent-kind",
|
|
75
|
-
|
|
76
|
-
help="
|
|
348
|
+
default=None,
|
|
349
|
+
help="Only this agent kind. Every kind by default.",
|
|
77
350
|
)
|
|
78
351
|
@click.option(
|
|
79
352
|
"--session",
|
|
80
353
|
"session_id",
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
help="Native session ID. Defaults to CLAUDE_CODE_SESSION_ID.",
|
|
354
|
+
default=None,
|
|
355
|
+
help="Only this native session ID. Every session by default.",
|
|
84
356
|
)
|
|
85
357
|
@click.option(
|
|
86
358
|
"--format",
|
|
@@ -89,18 +361,47 @@ def _runtime():
|
|
|
89
361
|
default="text",
|
|
90
362
|
show_default=True,
|
|
91
363
|
)
|
|
92
|
-
def findings(agent_kind: str, session_id: str, output_format: str) -> None:
|
|
93
|
-
"""Show Stacktrace
|
|
364
|
+
def findings(agent_kind: str | None, session_id: str | None, output_format: str) -> None:
|
|
365
|
+
"""Show what Stacktrace has found on this machine.
|
|
366
|
+
|
|
367
|
+
Neither key is required, and `--session` deliberately does **not** read
|
|
368
|
+
`CLAUDE_CODE_SESSION_ID`: the unqualified command would then be silently
|
|
369
|
+
scoped to one session inside an agent session, which is the one place an
|
|
370
|
+
operator is most likely to run it and least likely to notice.
|
|
371
|
+
"""
|
|
94
372
|
paths = RuntimePaths.from_environment()
|
|
95
|
-
|
|
373
|
+
# `_store()`, not a bare `FindingStore(...)`: a reader must not initialize
|
|
374
|
+
# the database as a side effect of reporting that there is none -- besides
|
|
375
|
+
# the surprise, two processes racing that (this command and a daemon's own
|
|
376
|
+
# first open) could both see an unversioned file and each try to insert
|
|
377
|
+
# the same schema_version row.
|
|
378
|
+
try:
|
|
379
|
+
store = _store(paths)
|
|
380
|
+
except (sqlite3.DatabaseError, ValueError) as exc:
|
|
381
|
+
# A corrupt file or a schema newer than this build reads raises here
|
|
382
|
+
# (`_check_version`'s own refusal) -- an inspection command must
|
|
383
|
+
# report that rather than exit on a traceback.
|
|
384
|
+
raise click.ClickException(f"database unreadable — {exc}") from exc
|
|
385
|
+
stored = () if store is None else store.recorded(agent_kind=agent_kind, session_id=session_id)
|
|
96
386
|
if output_format == "json":
|
|
97
|
-
|
|
387
|
+
# Additive, not a new envelope: `findings --format json` shipped in
|
|
388
|
+
# v0.4.0 as a list of findings, and a reader of `finding["rule_id"]`
|
|
389
|
+
# keeps working. The row's own columns win over any same-named key in
|
|
390
|
+
# the payload, because they are what the store filed it under.
|
|
391
|
+
document = [
|
|
392
|
+
{**finding, "agent_kind": session.agent_kind, "session_id": session.session_id}
|
|
393
|
+
for session, finding in stored
|
|
394
|
+
]
|
|
395
|
+
click.echo(json.dumps({"findings": document}, indent=2))
|
|
98
396
|
return
|
|
99
397
|
if not stored:
|
|
100
|
-
click.echo("No Stacktrace findings for this session.")
|
|
101
|
-
return
|
|
102
|
-
for finding in stored:
|
|
103
398
|
click.echo(
|
|
104
|
-
|
|
105
|
-
f"{finding.get('rule_id', 'unknown-rule')}: {finding.get('title', 'Finding')}"
|
|
399
|
+
"No Stacktrace findings for this session." if session_id else "No Stacktrace findings."
|
|
106
400
|
)
|
|
401
|
+
return
|
|
402
|
+
for session, finding in stored:
|
|
403
|
+
# The session is named on every row, not grouped into a header: the
|
|
404
|
+
# list is read by eye and by grep, and a header is lost to both once
|
|
405
|
+
# the output is filtered.
|
|
406
|
+
click.echo(f"[{session.agent_kind}:{session.session_id}]")
|
|
407
|
+
click.echo(render_finding(finding))
|