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.
@@ -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)