codereview-loop 0.13.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.
- codereview/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""MCP stdio transport (Epic L2): the wire layer only.
|
|
2
|
+
|
|
3
|
+
Newline-delimited JSON-RPC 2.0 over a pair of binary streams —
|
|
4
|
+
the Model Context Protocol's stdio transport. This module
|
|
5
|
+
knows nothing about CodeReview, the gate, or the tools served
|
|
6
|
+
on top of it (that layer is codereview.mcp_tools); it frames
|
|
7
|
+
messages, dispatches them to a caller-supplied handler, and
|
|
8
|
+
writes the responses.
|
|
9
|
+
|
|
10
|
+
Fail-closed contract (a bad frame can never kill the host
|
|
11
|
+
loop or take the server down with it):
|
|
12
|
+
|
|
13
|
+
- an unparseable line is answered with a JSON-RPC parse error
|
|
14
|
+
(id null) and the loop continues with the next line;
|
|
15
|
+
- a frame larger than the size cap is discarded through its
|
|
16
|
+
terminating newline, answered with an invalid-request error,
|
|
17
|
+
and the loop continues — the stream stays in sync;
|
|
18
|
+
- a structurally invalid request (not an object, no method,
|
|
19
|
+
a batch array — MCP does not batch) is answered with an
|
|
20
|
+
invalid-request error;
|
|
21
|
+
- an exception escaping the handler becomes an internal-error
|
|
22
|
+
response for that request; for a notification it is only
|
|
23
|
+
recorded (notifications never get responses).
|
|
24
|
+
|
|
25
|
+
Every protocol-level failure is appended to the server's
|
|
26
|
+
`errors` ledger and mirrored to the diagnostic log (stderr in
|
|
27
|
+
production — stdout is the protocol), so a malformed or
|
|
28
|
+
oversized request is always recorded somewhere inspectable,
|
|
29
|
+
never silently swallowed. Stdlib only, like the rest of the
|
|
30
|
+
package.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
# JSON-RPC 2.0 error codes used by this layer.
|
|
38
|
+
PARSE_ERROR = -32700
|
|
39
|
+
INVALID_REQUEST = -32600
|
|
40
|
+
METHOD_NOT_FOUND = -32601
|
|
41
|
+
INVALID_PARAMS = -32602
|
|
42
|
+
INTERNAL_ERROR = -32603
|
|
43
|
+
|
|
44
|
+
# One frame's ceiling. A gate request carries a diff; the
|
|
45
|
+
# review path's own per-batch budget is 80k chars, and even a
|
|
46
|
+
# monorepo-scale hand-off diff fits far under this — 16 MiB is
|
|
47
|
+
# generous headroom, not an invitation.
|
|
48
|
+
MAX_FRAME_BYTES = 16 * 1024 * 1024
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JsonRpcError(Exception):
|
|
52
|
+
"""A protocol-level error: answered to the client with
|
|
53
|
+
`code` and `message`, never raised out of the serve loop."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, code, message, data=None):
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.code = code
|
|
58
|
+
self.message = message
|
|
59
|
+
self.data = data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sanitised_id(req_id):
|
|
63
|
+
"""A request id is echoed verbatim — except a non-finite
|
|
64
|
+
float. Python's json accepts NaN/Infinity by default, so
|
|
65
|
+
a frame can carry one as its id; echoing it would emit
|
|
66
|
+
invalid JSON (P3-10). It is answered as null."""
|
|
67
|
+
if isinstance(req_id, float) and not math.isfinite(req_id):
|
|
68
|
+
return None
|
|
69
|
+
return req_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def result_response(req_id, result):
|
|
73
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def error_response(req_id, code, message, data=None):
|
|
77
|
+
error = {"code": code, "message": message}
|
|
78
|
+
if data is not None:
|
|
79
|
+
error["data"] = data
|
|
80
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": error}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StdioServer:
|
|
84
|
+
"""Serve newline-delimited JSON-RPC over binary streams.
|
|
85
|
+
|
|
86
|
+
`dispatch(method, params, notification)` returns the result
|
|
87
|
+
for a request (its return value is ignored for a
|
|
88
|
+
notification) and raises JsonRpcError for protocol-level
|
|
89
|
+
refusals. Anything else it raises becomes an internal
|
|
90
|
+
error. `max_frame_bytes` is injectable so tests can trip
|
|
91
|
+
the cap without moving megabytes.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, dispatch, stdin=None, stdout=None,
|
|
95
|
+
log=None, max_frame_bytes=MAX_FRAME_BYTES):
|
|
96
|
+
self._dispatch = dispatch
|
|
97
|
+
self._stdin = stdin if stdin is not None \
|
|
98
|
+
else sys.stdin.buffer
|
|
99
|
+
self._stdout = stdout if stdout is not None \
|
|
100
|
+
else sys.stdout.buffer
|
|
101
|
+
self._log = log if log is not None else (
|
|
102
|
+
lambda text: print(text, file=sys.stderr))
|
|
103
|
+
self._max_frame_bytes = max_frame_bytes
|
|
104
|
+
# The failure ledger: one entry per protocol-level
|
|
105
|
+
# failure, in arrival order — the recorded half of the
|
|
106
|
+
# fail-closed contract.
|
|
107
|
+
self.errors = []
|
|
108
|
+
|
|
109
|
+
# -- framing ------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _read_frame(self):
|
|
112
|
+
"""Read one newline-terminated frame. Returns
|
|
113
|
+
(raw_bytes, oversized): raw is None at EOF. An oversized
|
|
114
|
+
frame's bytes are consumed through its newline (and
|
|
115
|
+
dropped) so the next frame starts in sync."""
|
|
116
|
+
chunk = self._stdin.readline(self._max_frame_bytes + 2)
|
|
117
|
+
if chunk == b"":
|
|
118
|
+
return None, False
|
|
119
|
+
if len(chunk) <= self._max_frame_bytes + 1 \
|
|
120
|
+
and chunk.endswith(b"\n"):
|
|
121
|
+
return chunk, False
|
|
122
|
+
if len(chunk) <= self._max_frame_bytes + 1:
|
|
123
|
+
# Final line without a newline (EOF right after):
|
|
124
|
+
# still a frame.
|
|
125
|
+
return chunk, False
|
|
126
|
+
# Oversized: discard through the terminating newline.
|
|
127
|
+
while not chunk.endswith(b"\n"):
|
|
128
|
+
chunk = self._stdin.readline(self._max_frame_bytes + 2)
|
|
129
|
+
if chunk == b"":
|
|
130
|
+
break
|
|
131
|
+
return b"", True
|
|
132
|
+
|
|
133
|
+
def _send(self, message):
|
|
134
|
+
try:
|
|
135
|
+
data = (json.dumps(message, ensure_ascii=False,
|
|
136
|
+
allow_nan=False)
|
|
137
|
+
+ "\n").encode("utf-8")
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
# allow_nan=False refuses a non-finite float
|
|
140
|
+
# anywhere in the payload (P3-10): the wire must
|
|
141
|
+
# never carry NaN/Infinity literals — they are
|
|
142
|
+
# not JSON. Answer with a plain internal error
|
|
143
|
+
# instead of killing the loop.
|
|
144
|
+
data = (json.dumps(error_response(
|
|
145
|
+
None, INTERNAL_ERROR,
|
|
146
|
+
"response was not JSON-serialisable"))
|
|
147
|
+
+ "\n").encode("utf-8")
|
|
148
|
+
self._stdout.write(data)
|
|
149
|
+
self._stdout.flush()
|
|
150
|
+
|
|
151
|
+
def _record(self, kind, detail):
|
|
152
|
+
entry = {"kind": kind, "detail": str(detail)[:500]}
|
|
153
|
+
self.errors.append(entry)
|
|
154
|
+
self._log(f"codereview mcp: {kind}: {entry['detail']}")
|
|
155
|
+
|
|
156
|
+
# -- the loop -------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def serve_forever(self):
|
|
159
|
+
"""Run until EOF on stdin. Never raises for a bad
|
|
160
|
+
frame, a bad request, or a failing handler."""
|
|
161
|
+
while True:
|
|
162
|
+
raw, oversized = self._read_frame()
|
|
163
|
+
if raw is None:
|
|
164
|
+
return
|
|
165
|
+
if oversized:
|
|
166
|
+
self._record(
|
|
167
|
+
"oversized_frame",
|
|
168
|
+
f"frame exceeds {self._max_frame_bytes} bytes")
|
|
169
|
+
self._send(error_response(
|
|
170
|
+
None, INVALID_REQUEST,
|
|
171
|
+
"request frame exceeds the size limit "
|
|
172
|
+
f"({self._max_frame_bytes} bytes)"))
|
|
173
|
+
continue
|
|
174
|
+
if not raw.strip():
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
message = json.loads(raw.decode("utf-8"))
|
|
178
|
+
except (UnicodeDecodeError, ValueError,
|
|
179
|
+
RecursionError) as exc:
|
|
180
|
+
# RecursionError: a frame nested deeper than
|
|
181
|
+
# the interpreter's limit is a parse failure
|
|
182
|
+
# like any other (P3-05) — it must not kill
|
|
183
|
+
# the loop or skip the ledger.
|
|
184
|
+
self._record("parse_error", exc)
|
|
185
|
+
self._send(error_response(
|
|
186
|
+
None, PARSE_ERROR, f"parse error: {exc}"))
|
|
187
|
+
continue
|
|
188
|
+
self._handle(message)
|
|
189
|
+
|
|
190
|
+
def _handle(self, message):
|
|
191
|
+
if not isinstance(message, dict):
|
|
192
|
+
self._record("invalid_request",
|
|
193
|
+
"request is not a JSON object")
|
|
194
|
+
self._send(error_response(
|
|
195
|
+
None, INVALID_REQUEST,
|
|
196
|
+
"request must be a JSON object"))
|
|
197
|
+
return
|
|
198
|
+
req_id = _sanitised_id(message.get("id"))
|
|
199
|
+
if message.get("jsonrpc") != "2.0":
|
|
200
|
+
# P3-10: the version field was never read — a
|
|
201
|
+
# "1.0" frame, or one with no version at all, was
|
|
202
|
+
# served as if it were 2.0. Fail closed with
|
|
203
|
+
# -32600; a notification-shaped frame (no id)
|
|
204
|
+
# keeps notification semantics — ledger only,
|
|
205
|
+
# never a response.
|
|
206
|
+
self._record("invalid_request",
|
|
207
|
+
"jsonrpc field is not \"2.0\"")
|
|
208
|
+
if "id" in message:
|
|
209
|
+
self._send(error_response(
|
|
210
|
+
req_id, INVALID_REQUEST,
|
|
211
|
+
"jsonrpc field must be \"2.0\""))
|
|
212
|
+
return
|
|
213
|
+
if "id" in message:
|
|
214
|
+
# P5-05: a request id must be a string or an integer
|
|
215
|
+
# (JSON-RPC 2.0). Bool (an int subclass — excluded
|
|
216
|
+
# explicitly), float, null, object and array ids are
|
|
217
|
+
# invalid requests, answered -32600 with a null id:
|
|
218
|
+
# the offending id cannot be echoed as an identity.
|
|
219
|
+
# An ABSENT id is a notification and never reaches
|
|
220
|
+
# this check.
|
|
221
|
+
raw_id = message["id"]
|
|
222
|
+
# One carve-out, inherited from P3-10: a NON-FINITE
|
|
223
|
+
# float (NaN/Infinity — not producible by a
|
|
224
|
+
# conforming JSON encoder, and unemittable in a
|
|
225
|
+
# strict-JSON response) is sanitised to a null id
|
|
226
|
+
# by _sanitised_id and served, rather than refused.
|
|
227
|
+
nonfinite = isinstance(raw_id, float) and not \
|
|
228
|
+
math.isfinite(raw_id)
|
|
229
|
+
if not nonfinite and (isinstance(raw_id, bool)
|
|
230
|
+
or not isinstance(
|
|
231
|
+
raw_id, (str, int))):
|
|
232
|
+
self._record("invalid_request",
|
|
233
|
+
"request id is not a string or "
|
|
234
|
+
"an integer")
|
|
235
|
+
self._send(error_response(
|
|
236
|
+
None, INVALID_REQUEST,
|
|
237
|
+
"request id must be a string or an integer"))
|
|
238
|
+
return
|
|
239
|
+
method = message.get("method")
|
|
240
|
+
if not isinstance(method, str) or not method:
|
|
241
|
+
self._record("invalid_request",
|
|
242
|
+
"request has no method")
|
|
243
|
+
self._send(error_response(
|
|
244
|
+
req_id, INVALID_REQUEST,
|
|
245
|
+
"request has no method"))
|
|
246
|
+
return
|
|
247
|
+
notification = "id" not in message
|
|
248
|
+
try:
|
|
249
|
+
result = self._dispatch(
|
|
250
|
+
method, message.get("params"), notification)
|
|
251
|
+
except JsonRpcError as exc:
|
|
252
|
+
if notification:
|
|
253
|
+
self._record("notification_error",
|
|
254
|
+
f"{method}: {exc.message}")
|
|
255
|
+
return
|
|
256
|
+
self._record("request_error",
|
|
257
|
+
f"{method}: {exc.message}")
|
|
258
|
+
self._send(error_response(
|
|
259
|
+
req_id, exc.code, exc.message, exc.data))
|
|
260
|
+
return
|
|
261
|
+
except Exception as exc: # noqa: BLE001 — fail closed
|
|
262
|
+
if notification:
|
|
263
|
+
self._record("notification_error",
|
|
264
|
+
f"{method}: {exc}")
|
|
265
|
+
return
|
|
266
|
+
self._record("internal_error", f"{method}: {exc}")
|
|
267
|
+
self._send(error_response(
|
|
268
|
+
req_id, INTERNAL_ERROR,
|
|
269
|
+
f"internal error: {exc}"))
|
|
270
|
+
return
|
|
271
|
+
if not notification:
|
|
272
|
+
self._send(result_response(req_id, result))
|
codereview/notify.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview attention notifications (Epic L4).
|
|
3
|
+
|
|
4
|
+
A long gate run should call the operator back instead of
|
|
5
|
+
waiting to be checked. When a seat enters a blocked state
|
|
6
|
+
(the Epic K2 classes — quota, auth, input, stalled) or the
|
|
7
|
+
gate reaches a verdict, the run fires ONE local command the
|
|
8
|
+
operator configured: a terminal bell, a desktop toast, a
|
|
9
|
+
herdr-style attention hook. No hosted service, no
|
|
10
|
+
CodeReview-managed endpoint — the command is the operator's
|
|
11
|
+
own, on their own machine.
|
|
12
|
+
|
|
13
|
+
Configuration surface: the USER SETUP only — a `[notify]`
|
|
14
|
+
table in the saved config (see userconfig.py):
|
|
15
|
+
|
|
16
|
+
[notify]
|
|
17
|
+
command = ["my-notify-script"]
|
|
18
|
+
|
|
19
|
+
Notifications are operator preference, not project contract:
|
|
20
|
+
the project policy (.codereview.toml) has no notify key and
|
|
21
|
+
cannot set one (policy.load_policy rejects the table as
|
|
22
|
+
unknown). A run with no [notify] command configured behaves
|
|
23
|
+
exactly as before this epic — no invocations, and the record
|
|
24
|
+
carries no `notifications` section.
|
|
25
|
+
|
|
26
|
+
Argv contract: the configured argv is executed directly
|
|
27
|
+
(no shell) with two arguments appended — the event name and
|
|
28
|
+
a one-line summary:
|
|
29
|
+
|
|
30
|
+
<command...> seat_blocked "seat cx blocked:quota"
|
|
31
|
+
<command...> verdict "gate blocked: 3 finding(s)"
|
|
32
|
+
|
|
33
|
+
Bounds and recording: each invocation is bounded by
|
|
34
|
+
NOTIFY_TIMEOUT_S; the outcome (status ok / failed / timeout
|
|
35
|
+
/ error, exit code, duration) is appended to the run
|
|
36
|
+
record's `notifications` section. A failing, missing, or
|
|
37
|
+
hanging notifier is recorded and never delays the run beyond
|
|
38
|
+
the bound, never changes a finding or the verdict, and never
|
|
39
|
+
raises into the review. The command runs in its own process
|
|
40
|
+
group, and the group is killed when the invocation ends —
|
|
41
|
+
on timeout (L10/NT9: a timed-out notifier leaves no
|
|
42
|
+
grandchildren behind) and on normal exit (a notifier that
|
|
43
|
+
backgrounds a helper and returns leaves nothing running;
|
|
44
|
+
a helper that must outlive the notification has to detach
|
|
45
|
+
into its own session, the documented setsid boundary).
|
|
46
|
+
Duplicate suppression is per run: a seat that ends blocked
|
|
47
|
+
in several jobs notifies once per (seat, class); the
|
|
48
|
+
verdict notifies once.
|
|
49
|
+
|
|
50
|
+
Deliberately distinct from Epic L3's lifecycle hooks: L3 is
|
|
51
|
+
project-level deterministic automation declared in the
|
|
52
|
+
project policy and executed inside the evidence sandbox; L4
|
|
53
|
+
is a personal local command declared in the user setup and
|
|
54
|
+
executed as the operator, inheriting the operator's
|
|
55
|
+
environment — it is a doorbell, not a build step.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import os
|
|
61
|
+
import signal
|
|
62
|
+
import subprocess
|
|
63
|
+
import time
|
|
64
|
+
|
|
65
|
+
# The per-invocation bound: long enough for a toast/bell
|
|
66
|
+
# command to start and finish, short enough that a wedged
|
|
67
|
+
# notifier can never hold a gate run hostage. Two events in
|
|
68
|
+
# a run (a blocked seat + the verdict) cost at most twice
|
|
69
|
+
# this in the pathological all-hanging case.
|
|
70
|
+
NOTIFY_TIMEOUT_S = 5.0
|
|
71
|
+
|
|
72
|
+
EVENT_SEAT_BLOCKED = "seat_blocked"
|
|
73
|
+
EVENT_VERDICT = "verdict"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _kill_notify_group(proc):
|
|
77
|
+
"""Kill the notifier's whole process group (it runs in
|
|
78
|
+
its own session), so a timed-out — or merely
|
|
79
|
+
backgrounding — notifier leaves no children behind.
|
|
80
|
+
Mirrors probe._kill_probe_group (the C5 pattern). Never
|
|
81
|
+
raises."""
|
|
82
|
+
killpg = getattr(os, "killpg", None)
|
|
83
|
+
if killpg is not None:
|
|
84
|
+
try:
|
|
85
|
+
pgid = os.getpgid(proc.pid)
|
|
86
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
87
|
+
# The child has exited and been reaped: it WAS
|
|
88
|
+
# its group leader (start_new_session), so the
|
|
89
|
+
# pid IS the pgid — and the group can still hold
|
|
90
|
+
# live grandchildren.
|
|
91
|
+
pgid = proc.pid
|
|
92
|
+
try:
|
|
93
|
+
killpg(pgid, signal.SIGKILL)
|
|
94
|
+
return
|
|
95
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
96
|
+
pass
|
|
97
|
+
try:
|
|
98
|
+
proc.kill()
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _supervise_notify_run(proc, timeout):
|
|
104
|
+
"""The notifier's supervised wait — the pairing the
|
|
105
|
+
subprocess-timeout guard accepts by name, as it does the
|
|
106
|
+
probe harness's and the evidence sandbox's: the wait is
|
|
107
|
+
deadline-bounded (wait(timeout=...)) and expiry kills
|
|
108
|
+
the whole process group, not just the direct child
|
|
109
|
+
(L10/NT9). The group is swept on normal exit too, so a
|
|
110
|
+
notifier that backgrounds a helper and returns leaves
|
|
111
|
+
nothing behind (the P3-06 rule). Raises
|
|
112
|
+
subprocess.TimeoutExpired on a hang, exactly as
|
|
113
|
+
subprocess.run(timeout=...) did."""
|
|
114
|
+
try:
|
|
115
|
+
proc.wait(timeout=timeout)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
_kill_notify_group(proc)
|
|
118
|
+
try:
|
|
119
|
+
proc.wait(timeout=5)
|
|
120
|
+
except Exception: # noqa: BLE001 — reaping is
|
|
121
|
+
pass # best-effort; the kill already landed
|
|
122
|
+
raise
|
|
123
|
+
_kill_notify_group(proc)
|
|
124
|
+
return proc.returncode
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _default_runner(argv, timeout):
|
|
128
|
+
"""Run argv directly (no shell), bounded by `timeout`,
|
|
129
|
+
output discarded (the notification is the side effect;
|
|
130
|
+
anything the command prints is the operator's own console
|
|
131
|
+
business, not the record's). Returns the exit code.
|
|
132
|
+
Raises subprocess.TimeoutExpired on a hang, OSError when
|
|
133
|
+
the command cannot be started at all."""
|
|
134
|
+
proc = subprocess.Popen(
|
|
135
|
+
argv, stdin=subprocess.DEVNULL,
|
|
136
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
137
|
+
shell=False, start_new_session=True)
|
|
138
|
+
return _supervise_notify_run(proc, timeout)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# The summary length ceiling (P3-12): a notification is a
|
|
142
|
+
# doorbell, not a document — past this the summary is cut
|
|
143
|
+
# and marked with an ellipsis.
|
|
144
|
+
_SUMMARY_CAP_CHARS = 500
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _one_line(text):
|
|
148
|
+
"""The summary as it reaches BOTH the notifier argv and
|
|
149
|
+
the recorded outcome: one line, no control bytes, capped.
|
|
150
|
+
|
|
151
|
+
Whitespace runs collapse to single spaces (a seat name is
|
|
152
|
+
operator config and could carry a newline). Every other
|
|
153
|
+
control/escape byte (C0, DEL, C1 — ESC and BEL included,
|
|
154
|
+
so ANSI/OSC sequences lose their teeth) is dropped
|
|
155
|
+
(P3-12): a hostile or merely odd seat name must not
|
|
156
|
+
smuggle terminal escapes into a notifier UI that renders
|
|
157
|
+
raw argv, nor a 100KB string into the record."""
|
|
158
|
+
line = " ".join(str(text or "").split())
|
|
159
|
+
line = "".join(
|
|
160
|
+
ch for ch in line
|
|
161
|
+
if not (ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F))
|
|
162
|
+
if len(line) > _SUMMARY_CAP_CHARS:
|
|
163
|
+
line = line[:_SUMMARY_CAP_CHARS - 1] + "…"
|
|
164
|
+
return line
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Notifier:
|
|
168
|
+
"""One run's notification channel: a configured command
|
|
169
|
+
plus the outcomes it produced. Constructed by the caller
|
|
170
|
+
(CLI: from the user setup; tests: with a stub runner) and
|
|
171
|
+
handed to core.run_review / run_verification. Every
|
|
172
|
+
method is total — fire() records and returns, never
|
|
173
|
+
raises."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, command, timeout=NOTIFY_TIMEOUT_S,
|
|
176
|
+
runner=None):
|
|
177
|
+
self.command = [str(c) for c in command]
|
|
178
|
+
self.timeout = float(timeout)
|
|
179
|
+
self._runner = runner or _default_runner
|
|
180
|
+
self._fired = set()
|
|
181
|
+
self.outcomes = []
|
|
182
|
+
|
|
183
|
+
def fire(self, event, summary, dedupe_key=None):
|
|
184
|
+
"""Invoke the command once for `event`, unless an
|
|
185
|
+
event with `dedupe_key` already fired in this run.
|
|
186
|
+
Returns the recorded outcome dict, or None when the
|
|
187
|
+
invocation was suppressed as a duplicate."""
|
|
188
|
+
key = dedupe_key if dedupe_key is not None else (event,)
|
|
189
|
+
if key in self._fired:
|
|
190
|
+
return None
|
|
191
|
+
self._fired.add(key)
|
|
192
|
+
summary = _one_line(summary)
|
|
193
|
+
argv = self.command + [event, summary]
|
|
194
|
+
started = time.monotonic()
|
|
195
|
+
status, exit_code, detail = "ok", None, None
|
|
196
|
+
try:
|
|
197
|
+
exit_code = self._runner(argv, self.timeout)
|
|
198
|
+
if exit_code != 0:
|
|
199
|
+
status = "failed"
|
|
200
|
+
except subprocess.TimeoutExpired:
|
|
201
|
+
status = "timeout"
|
|
202
|
+
except Exception as exc: # noqa: BLE001 — a notifier
|
|
203
|
+
# that cannot even start is an outcome, not a crash
|
|
204
|
+
status = "error"
|
|
205
|
+
detail = str(exc)[:200]
|
|
206
|
+
outcome = {"event": event, "summary": summary,
|
|
207
|
+
"status": status, "exit_code": exit_code,
|
|
208
|
+
"duration_ms": int(
|
|
209
|
+
(time.monotonic() - started) * 1000)}
|
|
210
|
+
if detail:
|
|
211
|
+
outcome["detail"] = detail
|
|
212
|
+
self.outcomes.append(outcome)
|
|
213
|
+
return outcome
|
|
214
|
+
|
|
215
|
+
def seat_blocked(self, seat, cls):
|
|
216
|
+
"""The blocked-seat event (Epic K2 classes): once per
|
|
217
|
+
(seat, class) per run — a seat blocked in three batch
|
|
218
|
+
jobs is one fact for the operator, not three pings."""
|
|
219
|
+
label = f"blocked:{cls}" if cls else "blocked"
|
|
220
|
+
return self.fire(
|
|
221
|
+
EVENT_SEAT_BLOCKED, f"seat {seat} {label}",
|
|
222
|
+
dedupe_key=(EVENT_SEAT_BLOCKED, str(seat),
|
|
223
|
+
str(cls)))
|
|
224
|
+
|
|
225
|
+
def verdict(self, summary):
|
|
226
|
+
"""The gate-verdict event: once per run."""
|
|
227
|
+
return self.fire(EVENT_VERDICT, summary,
|
|
228
|
+
dedupe_key=(EVENT_VERDICT,))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def notifier_for(command, **kwargs):
|
|
232
|
+
"""A Notifier for a configured command argv, or None when
|
|
233
|
+
no command is configured — the unset state, in which the
|
|
234
|
+
review path is byte-identical to before this epic."""
|
|
235
|
+
if not command:
|
|
236
|
+
return None
|
|
237
|
+
return Notifier(command, **kwargs)
|