flashgate 0.7.0__tar.gz → 0.7.2__tar.gz

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 (40) hide show
  1. {flashgate-0.7.0 → flashgate-0.7.2}/PKG-INFO +44 -6
  2. {flashgate-0.7.0 → flashgate-0.7.2}/README.md +43 -5
  3. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/bench.py +28 -4
  4. flashgate-0.7.2/flashgate/bench_serve.py +268 -0
  5. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/cli.py +35 -1
  6. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/records.py +39 -0
  7. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/PKG-INFO +44 -6
  8. {flashgate-0.7.0 → flashgate-0.7.2}/pyproject.toml +1 -1
  9. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_bench.py +52 -0
  10. flashgate-0.7.2/tests/test_bench_serve.py +257 -0
  11. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_cli.py +74 -0
  12. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_records.py +60 -0
  13. flashgate-0.7.0/flashgate/bench_serve.py +0 -122
  14. flashgate-0.7.0/tests/test_bench_serve.py +0 -127
  15. {flashgate-0.7.0 → flashgate-0.7.2}/LICENSE +0 -0
  16. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/__init__.py +0 -0
  17. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/__main__.py +0 -0
  18. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/board.py +0 -0
  19. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/flasher.py +0 -0
  20. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/gatestate.py +0 -0
  21. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/mcp_server.py +0 -0
  22. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/probes.py +0 -0
  23. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/results.py +0 -0
  24. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/serialmon.py +0 -0
  25. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/sttools.py +0 -0
  26. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/swdsig.py +0 -0
  27. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/SOURCES.txt +0 -0
  28. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/dependency_links.txt +0 -0
  29. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/entry_points.txt +0 -0
  30. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/requires.txt +0 -0
  31. {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/top_level.txt +0 -0
  32. {flashgate-0.7.0 → flashgate-0.7.2}/setup.cfg +0 -0
  33. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_board.py +0 -0
  34. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_flasher.py +0 -0
  35. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_gatestate.py +0 -0
  36. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_hook_stop.py +0 -0
  37. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_mcp.py +0 -0
  38. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_probes.py +0 -0
  39. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_serialmon.py +0 -0
  40. {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_swdsig.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashgate
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/Lion-1209/flashgate
@@ -69,6 +69,7 @@ including register readbacks (TIM3 CCR), not firmware self-reports:
69
69
  ```bash
70
70
  pip install -e . # Python 3.11+
71
71
  pip install -e ".[mcp]" # optional MCP server
72
+ pip install -e ".[bench]" # optional remote bench (device-connect)
72
73
 
73
74
  flashgate doctor # ST-Link / serial / toolchain sanity
74
75
  flashgate verify --all-probes # build → flash → evidence → sha → probes
@@ -134,6 +135,9 @@ tree+profile+tool-version identity the verdict applies to. The MCP
134
135
  firmware repo's `.gitignore` (the shipped example does), or every record
135
136
  write churns the fingerprint. A record that cannot be written is a
136
137
  warning, never a changed exit code — and a crashed run still leaves one.
138
+ The newest ~500 records are kept (best-effort pruning). `flashgate verify
139
+ --json` prints this run's record as pure-ASCII JSON on stdout (human logs
140
+ move to stderr) for scripting — safe on any console codepage.
137
141
 
138
142
  ## Remote bench (device-connect, Stage 2)
139
143
 
@@ -148,11 +152,16 @@ DEVICE_CONNECT_ALLOW_INSECURE=true DEVICE_CONNECT_DISCOVERY_MODE=d2d flashgate
148
152
  ```
149
153
 
150
154
  Remote callers discover `device_type=flashgate-bench` and use
151
- `describe_bench` / `start_verify` / `get_operation` / `cancel_operation`.
155
+ `describe_bench` / `start_verify` / `get_operation` / `cancel_operation`,
156
+ plus a `verify_completed` event per operation at its terminal state (an
157
+ operation drained during server shutdown does not emit one — polling
158
+ remains the authoritative contract).
152
159
  `start_verify` returns an `op_id` immediately (single verify at a time —
153
160
  a client retrying over a flaky network can never trigger a second
154
161
  flash); poll `get_operation` to the terminal snapshot, which carries the
155
- exit code and the run's full evidence record.
162
+ exit code and the run's full evidence record. Stop the server without
163
+ hunting PIDs: `flashgate --board <profile> bench-serve --stop` (it
164
+ drains the in-flight operation, then exits).
156
165
 
157
166
  **The red line for every consumer**: the mesh wraps any normally-delivered
158
167
  reply as `success: true` — including a busy bench answering
@@ -164,6 +173,29 @@ and per-check verdicts equal; `artifact_sha256` differs by design — it
164
173
  identifies the BUILD, which embeds its timestamp, while the tree
165
174
  fingerprint identifies the SOURCE).
166
175
 
176
+ **Deployment notes** (from the first real cross-host test, wired host
177
+ + Wi-Fi laptop, 2026-09-15):
178
+
179
+ - Multicast discovery does not cross all wired↔wireless routers. If
180
+ discovery finds nothing across machines but `ping` works, switch to
181
+ unicast: on the bench host set `ZENOH_LISTEN=tcp/0.0.0.0:7447` before
182
+ `bench-serve`; on the client set `MESSAGING_URLS=tcp/<host-ip>:7447`.
183
+ - Open UDP 7446 (multicast scouting) or TCP 7447 (unicast) inbound on
184
+ the host's firewall; a Wi-Fi client set to "Public network" blocks the
185
+ announcements it needs to receive.
186
+ - **One bench-serve per bench**: a second instance for the same
187
+ firmware dir is refused at startup (a loopback lock socket, released
188
+ automatically when the process dies). Before the lock existed, two
189
+ same-named servers split-brained the mesh and raced each other for
190
+ the board's serial port.
191
+
192
+ **Third-party licensing**: the `bench` extra depends on
193
+ [device-connect-edge](https://github.com/arm/device-connect)
194
+ (Apache-2.0) as a pip dependency only — flashgate stays MIT, and no
195
+ device-connect source is vendored into this repository (fixes go
196
+ upstream as PRs). Commercial distributions that bundle the dependency
197
+ must include its LICENSE and NOTICE files.
198
+
167
199
  **Security posture, stated plainly**: D2D mode is zero-authentication —
168
200
  anyone on the same LAN can discover the bench and `start_verify`, which
169
201
  FLASHES THE BOARD. Descriptions and records also carry local paths.
@@ -225,9 +257,11 @@ full profile field reference.
225
257
 
226
258
  ## Status
227
259
 
228
- Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
229
- implemented and validated on real hardware. Windows-first; Linux/macOS
230
- untested.
260
+ Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
261
+ verification records (per-check evidence with artifact hashes), and the
262
+ remote bench over device-connect — all implemented and validated on real
263
+ hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
264
+ bench, 2026-09-15). Windows-first; Linux/macOS untested.
231
265
 
232
266
  ## License
233
267
 
@@ -251,6 +285,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
251
285
  - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
252
286
  - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
253
287
  静默失效的设置第一步就会露馅
288
+ - 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
289
+ 产物哈希——事后可审计"当时验证的是什么、板子说了什么"
290
+ - `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
291
+ 协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
254
292
  - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
255
293
  会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
256
294
  打警告,会话不会被卡死
@@ -48,6 +48,7 @@ including register readbacks (TIM3 CCR), not firmware self-reports:
48
48
  ```bash
49
49
  pip install -e . # Python 3.11+
50
50
  pip install -e ".[mcp]" # optional MCP server
51
+ pip install -e ".[bench]" # optional remote bench (device-connect)
51
52
 
52
53
  flashgate doctor # ST-Link / serial / toolchain sanity
53
54
  flashgate verify --all-probes # build → flash → evidence → sha → probes
@@ -113,6 +114,9 @@ tree+profile+tool-version identity the verdict applies to. The MCP
113
114
  firmware repo's `.gitignore` (the shipped example does), or every record
114
115
  write churns the fingerprint. A record that cannot be written is a
115
116
  warning, never a changed exit code — and a crashed run still leaves one.
117
+ The newest ~500 records are kept (best-effort pruning). `flashgate verify
118
+ --json` prints this run's record as pure-ASCII JSON on stdout (human logs
119
+ move to stderr) for scripting — safe on any console codepage.
116
120
 
117
121
  ## Remote bench (device-connect, Stage 2)
118
122
 
@@ -127,11 +131,16 @@ DEVICE_CONNECT_ALLOW_INSECURE=true DEVICE_CONNECT_DISCOVERY_MODE=d2d flashgate
127
131
  ```
128
132
 
129
133
  Remote callers discover `device_type=flashgate-bench` and use
130
- `describe_bench` / `start_verify` / `get_operation` / `cancel_operation`.
134
+ `describe_bench` / `start_verify` / `get_operation` / `cancel_operation`,
135
+ plus a `verify_completed` event per operation at its terminal state (an
136
+ operation drained during server shutdown does not emit one — polling
137
+ remains the authoritative contract).
131
138
  `start_verify` returns an `op_id` immediately (single verify at a time —
132
139
  a client retrying over a flaky network can never trigger a second
133
140
  flash); poll `get_operation` to the terminal snapshot, which carries the
134
- exit code and the run's full evidence record.
141
+ exit code and the run's full evidence record. Stop the server without
142
+ hunting PIDs: `flashgate --board <profile> bench-serve --stop` (it
143
+ drains the in-flight operation, then exits).
135
144
 
136
145
  **The red line for every consumer**: the mesh wraps any normally-delivered
137
146
  reply as `success: true` — including a busy bench answering
@@ -143,6 +152,29 @@ and per-check verdicts equal; `artifact_sha256` differs by design — it
143
152
  identifies the BUILD, which embeds its timestamp, while the tree
144
153
  fingerprint identifies the SOURCE).
145
154
 
155
+ **Deployment notes** (from the first real cross-host test, wired host
156
+ + Wi-Fi laptop, 2026-09-15):
157
+
158
+ - Multicast discovery does not cross all wired↔wireless routers. If
159
+ discovery finds nothing across machines but `ping` works, switch to
160
+ unicast: on the bench host set `ZENOH_LISTEN=tcp/0.0.0.0:7447` before
161
+ `bench-serve`; on the client set `MESSAGING_URLS=tcp/<host-ip>:7447`.
162
+ - Open UDP 7446 (multicast scouting) or TCP 7447 (unicast) inbound on
163
+ the host's firewall; a Wi-Fi client set to "Public network" blocks the
164
+ announcements it needs to receive.
165
+ - **One bench-serve per bench**: a second instance for the same
166
+ firmware dir is refused at startup (a loopback lock socket, released
167
+ automatically when the process dies). Before the lock existed, two
168
+ same-named servers split-brained the mesh and raced each other for
169
+ the board's serial port.
170
+
171
+ **Third-party licensing**: the `bench` extra depends on
172
+ [device-connect-edge](https://github.com/arm/device-connect)
173
+ (Apache-2.0) as a pip dependency only — flashgate stays MIT, and no
174
+ device-connect source is vendored into this repository (fixes go
175
+ upstream as PRs). Commercial distributions that bundle the dependency
176
+ must include its LICENSE and NOTICE files.
177
+
146
178
  **Security posture, stated plainly**: D2D mode is zero-authentication —
147
179
  anyone on the same LAN can discover the bench and `start_verify`, which
148
180
  FLASHES THE BOARD. Descriptions and records also carry local paths.
@@ -204,9 +236,11 @@ full profile field reference.
204
236
 
205
237
  ## Status
206
238
 
207
- Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
208
- implemented and validated on real hardware. Windows-first; Linux/macOS
209
- untested.
239
+ Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
240
+ verification records (per-check evidence with artifact hashes), and the
241
+ remote bench over device-connect — all implemented and validated on real
242
+ hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
243
+ bench, 2026-09-15). Windows-first; Linux/macOS untested.
210
244
 
211
245
  ## License
212
246
 
@@ -230,6 +264,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
230
264
  - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
231
265
  - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
232
266
  静默失效的设置第一步就会露馅
267
+ - 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
268
+ 产物哈希——事后可审计"当时验证的是什么、板子说了什么"
269
+ - `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
270
+ 协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
233
271
  - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
234
272
  会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
235
273
  打警告,会话不会被卡死
@@ -110,10 +110,12 @@ class _Operation:
110
110
  class BenchDriver:
111
111
  """Single-board bench: owns one in-flight verify and its history."""
112
112
 
113
- def __init__(self, board: Board, verify_fn: VerifyFn | None = None):
113
+ def __init__(self, board: Board, verify_fn: VerifyFn | None = None,
114
+ on_complete: "Callable[[dict], None] | None" = None):
114
115
  self._board = board
115
116
  self._verify_fn = verify_fn or (
116
117
  lambda b, names: cli_mod.cmd_verify(b, names))
118
+ self._on_complete = on_complete # auxiliary: see set_on_complete
117
119
  self._lock = threading.Lock()
118
120
  self._ops: dict[str, _Operation] = {}
119
121
  self._threads: dict[str, threading.Thread] = {}
@@ -128,6 +130,22 @@ class BenchDriver:
128
130
  f"(single-flight and record association both assume it)")
129
131
  _DRIVERS[key] = self
130
132
 
133
+ def set_on_complete(self, cb: "Callable[[dict], None] | None") -> None:
134
+ """Register a best-effort completion callback: invoked once with
135
+ the terminal snapshot when an operation reaches succeeded/failed/
136
+ cancelled. Exceptions in the callback are swallowed —
137
+ notification must never affect the operation or the bench (the
138
+ RPC front-end uses this to emit verify_completed events)."""
139
+ self._on_complete = cb
140
+
141
+ def _notify(self, op: _Operation) -> None:
142
+ if self._on_complete is None:
143
+ return
144
+ try:
145
+ self._on_complete(op.snapshot())
146
+ except Exception:
147
+ pass
148
+
131
149
  # ------------------------------------------------------ describe_bench
132
150
  def describe(self) -> dict:
133
151
  probes_error = ""
@@ -200,9 +218,14 @@ class BenchDriver:
200
218
  op.state = "cancelled"
201
219
  op.finished_at = _iso()
202
220
  op.summary = "cancelled before execution"
203
- return True
204
- op.cancel_requested = True
205
- return False
221
+ cancelled = True
222
+ else:
223
+ cancelled = False
224
+ op.cancel_requested = True
225
+ if cancelled:
226
+ self._notify(op) # outside the lock: a custom
227
+ return True # callback may query the bench
228
+ return False
206
229
 
207
230
  # ------------------------------------------------------ wait (helper)
208
231
  def wait(self, op_id: str, timeout_s: float = 600.0) -> dict | None:
@@ -237,6 +260,7 @@ class BenchDriver:
237
260
  op.state = "failed"
238
261
  op.error = ((op.error + " | ") if op.error else "") + \
239
262
  "worker exited without a verdict"
263
+ self._notify(op)
240
264
 
241
265
  def _attach_record(self, op: _Operation) -> None:
242
266
  """Attach THIS run's evidence record via the in-process registry.
@@ -0,0 +1,268 @@
1
+ """flashgate bench-serve: expose the local bench over device-connect (L1).
2
+
3
+ A thin shell over BenchDriver (L0) — exactly four named RPCs, nothing
4
+ else: no arbitrary shell, no path arguments, no console passthrough.
5
+ Which board a bench serves is fixed by the --board profile the server
6
+ was started with; remote callers cannot choose it.
7
+
8
+ Requires the optional extra:
9
+
10
+ pip install "flashgate[bench]" # -> device-connect-edge
11
+
12
+ D2D mode (no broker, Zenoh multicast scouting) is the default shape for
13
+ a bench: set DEVICE_CONNECT_ALLOW_INSECURE=true for local development
14
+ and force DEVICE_CONNECT_DISCOVERY_MODE=d2d when no broker URLs exist.
15
+
16
+ THE RED LINE for every consumer (GPT6 report §4.2): the mesh wraps a
17
+ normally-delivered reply as success:true regardless of what the bench
18
+ verified. Judge verification ONLY by the operation payload —
19
+ state / exit_code / status and the embedded record's checks — never by
20
+ transport success alone.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import socket
27
+ import threading
28
+ import _thread
29
+ import zlib
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ from .bench import BenchBusyError, BenchDriver
34
+ from .board import Board
35
+
36
+
37
+ def _lock_port(fw_dir: Path) -> int:
38
+ return 17500 + (zlib.crc32(
39
+ str(fw_dir.resolve()).lower().encode("utf-8")) % 1000)
40
+
41
+
42
+ def acquire_bench_lock(fw_dir: Path) -> socket.socket:
43
+ """OS-level single-instance lock per bench, auto-released when the
44
+ process dies (a bound socket cannot outlive its process).
45
+
46
+ Why: two bench-serves for the SAME firmware dir register the same
47
+ device_id on the mesh and split-brain every RPC — the losing process
48
+ races the winner for the board's serial port and surfaces as
49
+ access-denied (seen live in the first cross-host test, 2026-09-15).
50
+ Different boards (different fw dirs) lock different ports and may
51
+ share a host."""
52
+ s = socket.socket()
53
+ try:
54
+ s.bind(("127.0.0.1", _lock_port(fw_dir)))
55
+ s.listen(1)
56
+ return s
57
+ except OSError:
58
+ s.close()
59
+ raise RuntimeError(
60
+ f"another bench-serve for {fw_dir} appears to be running on "
61
+ f"this machine — one bench-serve per bench (the running one "
62
+ f"owns lock port {_lock_port(fw_dir)}; kill it first)")
63
+
64
+
65
+ def _start_lock_listener(lock: socket.socket,
66
+ interrupt=None) -> threading.Thread:
67
+ """Accept `stop` commands on the bench lock port. The stopper gets an
68
+ immediate ack; THIS process then exits through serve()'s drain path —
69
+ the operator never has to hunt for a PID (the pain that motivated
70
+ this, see v0.7.1 field notes).
71
+
72
+ `interrupt` defaults to interrupting the MAIN thread (where serve()
73
+ runs asyncio.run — the KeyboardInterrupt unwinds through the drain
74
+ path). It is injectable so tests can observe the signal without
75
+ receiving a real KeyboardInterrupt."""
76
+ if interrupt is None:
77
+ interrupt = _thread.interrupt_main
78
+ def _loop() -> None:
79
+ while True:
80
+ try:
81
+ conn, _ = lock.accept()
82
+ except OSError:
83
+ return # lock closed: server exiting
84
+ try:
85
+ conn.settimeout(2) # a silent local connection must
86
+ data = conn.recv(64) # not wedge the stop channel (R4)
87
+ if data.strip().lower() == b"stop":
88
+ try:
89
+ conn.sendall(b"ok: draining and stopping\n")
90
+ except OSError:
91
+ pass
92
+ try:
93
+ interrupt()
94
+ except Exception:
95
+ pass
96
+ return
97
+ conn.sendall(b"unknown command\n")
98
+ except OSError:
99
+ pass
100
+ finally:
101
+ try:
102
+ conn.close()
103
+ except OSError:
104
+ pass
105
+ t = threading.Thread(target=_loop, daemon=True,
106
+ name="flashgate-bench-lock")
107
+ t.start()
108
+ return t
109
+
110
+
111
+ def stop_bench(fw_dir: Path) -> bool:
112
+ """Signal a running bench-serve for this firmware dir to stop.
113
+ True = signalled (it drains its in-flight operation, then exits);
114
+ False = no bench-serve is holding the lock."""
115
+ try:
116
+ with socket.create_connection(("127.0.0.1", _lock_port(fw_dir)),
117
+ timeout=3) as s:
118
+ s.sendall(b"stop\n")
119
+ reply = s.recv(128).decode("utf-8", errors="replace")
120
+ return reply.startswith("ok")
121
+ except OSError:
122
+ return False
123
+
124
+
125
+ def _build_driver(bench: BenchDriver):
126
+ from device_connect_edge.drivers import DeviceDriver, emit, rpc
127
+
128
+ class FlashGateBenchDriver(DeviceDriver):
129
+ device_type = "flashgate-bench"
130
+
131
+ def __init__(self, bench: BenchDriver):
132
+ super().__init__() # base builds _functions_cache etc.
133
+ self._bench = bench
134
+ self._loop: "asyncio.AbstractEventLoop | None" = None
135
+
136
+ @emit()
137
+ async def verify_completed(self, op_id: str, state: str,
138
+ exit_code: Optional[int] = None,
139
+ status: Optional[str] = None) -> None:
140
+ """Fires once per operation at its terminal state
141
+ (succeeded / failed / cancelled) — polling-free completion
142
+ for remote consumers. Auxiliary: polling get_operation
143
+ remains the authoritative contract."""
144
+
145
+ def _operation_done(self, snapshot: dict) -> None:
146
+ # Called from the bench worker THREAD: hop onto the runtime
147
+ # loop. Best-effort — an emit failure never touches the op.
148
+ loop = self._loop
149
+ if loop is None or loop.is_closed():
150
+ return
151
+ payload = {"op_id": snapshot["op_id"], "state": snapshot["state"]}
152
+ # Omit null fields entirely: the SDK's schema converter drops
153
+ # Optional's null, so emitting exit_code=None (cancelled /
154
+ # crashed ops) would produce payload/schema contradictions
155
+ # for validating consumers (adversarial review R2).
156
+ if snapshot.get("exit_code") is not None:
157
+ payload["exit_code"] = snapshot["exit_code"]
158
+ if snapshot.get("status") is not None:
159
+ payload["status"] = snapshot["status"]
160
+ try:
161
+ asyncio.run_coroutine_threadsafe(
162
+ self.verify_completed(**payload), loop)
163
+ except Exception:
164
+ pass
165
+
166
+ def _remember_loop(self) -> None:
167
+ if self._loop is None:
168
+ self._loop = asyncio.get_running_loop()
169
+
170
+ @rpc()
171
+ async def describe_bench(self) -> dict:
172
+ """Board identity, probe list, busy state of this bench."""
173
+ self._remember_loop()
174
+ return bench.describe()
175
+
176
+ @rpc()
177
+ async def start_verify(self, probes: Optional[list] = None) -> dict:
178
+ """Start one hardware verify; returns the operation snapshot
179
+ (state=running, op_id). Single-flight: a busy bench answers
180
+ {"error": "busy", ...} — a transport success that must NOT be
181
+ read as a verification pass.
182
+
183
+ Note: Optional[list] (typing.Union), NOT `list | None` — the
184
+ SDK's schema converter misses PEP 604 unions and would
185
+ advertise probes as a STRING, steering well-behaved callers
186
+ into flashing the board only to die on "unknown probe 'a'"
187
+ (adversarial review F1)."""
188
+ if probes is not None and (
189
+ not isinstance(probes, list)
190
+ or not all(isinstance(x, str) for x in probes)):
191
+ # reject BEFORE any hardware is touched (F2): a malformed
192
+ # argument must not cost a build+flash cycle
193
+ return {"error": "invalid_argument",
194
+ "detail": "probes must be a list of probe names or null"}
195
+ self._remember_loop()
196
+ try:
197
+ return bench.start_verify(probes)
198
+ except BenchBusyError as exc:
199
+ return {"error": "busy", "detail": str(exc)}
200
+
201
+ @rpc()
202
+ async def get_operation(self, op_id: str) -> dict:
203
+ """Poll an operation until state is succeeded|failed|cancelled;
204
+ the terminal snapshot carries exit_code/status and the run's
205
+ evidence record (checks + what the board actually said)."""
206
+ op = bench.get_operation(op_id)
207
+ if op is None:
208
+ return {"error": "unknown_operation", "op_id": op_id}
209
+ return op
210
+
211
+ @rpc()
212
+ async def cancel_operation(self, op_id: str) -> dict:
213
+ """True = cancelled (only before execution starts). Executing
214
+ operations are never torn down mid-run — the request is
215
+ recorded and the run completes with its REAL verdict."""
216
+ return {"op_id": op_id, "cancelled": bench.cancel_operation(op_id)}
217
+
218
+ return FlashGateBenchDriver
219
+
220
+
221
+ def serve(board: Board, device_id: str | None = None) -> int:
222
+ """Run the bench server until Ctrl+C. Blocks forever."""
223
+ try:
224
+ from device_connect_edge import DeviceRuntime
225
+ except ImportError:
226
+ print("bench-serve needs the optional dependency: "
227
+ 'pip install "flashgate[bench]" (device-connect-edge)')
228
+ return 2
229
+
230
+ try:
231
+ lock = acquire_bench_lock(board.firmware_dir) # held for life
232
+ except RuntimeError as exc:
233
+ print(f"[bench-serve] {exc}")
234
+ return 2
235
+
236
+ _start_lock_listener(lock)
237
+ bench = BenchDriver(board)
238
+ driver = _build_driver(bench)(bench)
239
+ bench.set_on_complete(driver._operation_done) # verify_completed events
240
+ ident = device_id or f"flashgate-bench-{board.name}"
241
+ print(f"[bench-serve] {ident}: {board.name} ({board.mcu}) — "
242
+ f"four RPCs over device-connect; Ctrl+C or `bench-serve --stop` "
243
+ f"to stop")
244
+ try:
245
+ asyncio.run(DeviceRuntime(driver=driver,
246
+ device_id=ident).run())
247
+ except KeyboardInterrupt:
248
+ # Drain before exit (bench.py's deployment constraint): a daemon
249
+ # worker killed mid-flash leaves CubeProgrammer orphaned and the
250
+ # run without its evidence record. A SECOND interrupt during the
251
+ # drain would escape and kill the worker mid-flash (R6) — ignore
252
+ # Ctrl+C/stop re-sends until the drain completes.
253
+ import signal
254
+ try:
255
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
256
+ except (ValueError, OSError):
257
+ pass
258
+ current = bench.describe().get("current_operation")
259
+ if current:
260
+ print(f"\n[bench-serve] draining {current} before stop...")
261
+ done = bench.wait(current, timeout_s=120)
262
+ print(f"[bench-serve] drained: {done['state']} "
263
+ f"exit={done.get('exit_code')}")
264
+ print("[bench-serve] stopped")
265
+ except Exception as exc: # e.g. invalid device_id (F5)
266
+ print(f"[bench-serve] failed: {type(exc).__name__}: {exc}")
267
+ return 2
268
+ return 0
@@ -10,6 +10,9 @@ Exit-code contract (the M3 Stop hook enforces these):
10
10
  from __future__ import annotations
11
11
 
12
12
  import argparse
13
+ import contextlib
14
+ import io
15
+ import json
13
16
  import os
14
17
  import re
15
18
  import shutil
@@ -648,6 +651,9 @@ def main(argv: list[str] | None = None) -> int:
648
651
  help="run every probe defined in the board profile")
649
652
  p_verify.add_argument("--evidence", choices=["uart", "swd", "auto"],
650
653
  help="boot-evidence channel (default: board profile evidence.mode)")
654
+ p_verify.add_argument("--json", action="store_true",
655
+ help="print this run's evidence record as JSON on "
656
+ "stdout (human logs move to stderr)")
651
657
  p_probe = sub.add_parser("probe", help="run probes against running firmware")
652
658
  p_probe.add_argument("names", nargs="*", metavar="NAME",
653
659
  help="probe names (default: all defined in the board profile)")
@@ -656,6 +662,10 @@ def main(argv: list[str] | None = None) -> int:
656
662
  "bench-serve", help="expose this bench over device-connect (optional extra: flashgate[bench])")
657
663
  p_bench.add_argument("--device-id", default=None,
658
664
  help="device-connect id (default: flashgate-bench-<board>)")
665
+ p_bench.add_argument("--stop", action="store_true",
666
+ help="signal the running bench-serve for this "
667
+ "board to stop (drains, then exits) instead "
668
+ "of starting a new one")
659
669
 
660
670
  args = parser.parse_args(argv)
661
671
  try:
@@ -669,11 +679,35 @@ def main(argv: list[str] | None = None) -> int:
669
679
  names: list[str] | None = args.probe
670
680
  if args.all_probes:
671
681
  names = ["all"]
682
+ if getattr(args, "json", False):
683
+ # stdout is the machine channel: human logs go to stderr
684
+ with contextlib.redirect_stdout(sys.stderr):
685
+ rc = cmd_verify(board, names, getattr(args, "evidence", None))
686
+ last = records.LAST
687
+ if last is not None and Path(last["fw_dir"]) == board.firmware_dir:
688
+ # ensure_ascii (default): the JSON must survive ANY
689
+ # consumer codepage — a cp936 pipe meeting U+FFFD from
690
+ # serial noise used to raise UnicodeEncodeError, wipe
691
+ # stdout and exit 1, which scripts misread as BUILD
692
+ # FAILED (adversarial review R1).
693
+ print(json.dumps(last["record"]))
694
+ else:
695
+ print(json.dumps({"error": "no record written",
696
+ "exit_code": rc}))
697
+ return rc
672
698
  return cmd_verify(board, names, getattr(args, "evidence", None))
673
699
  if args.cmd == "probe":
674
700
  return cmd_probe(board, args.names or None)
675
701
  if args.cmd == "bench-serve":
676
- from .bench_serve import serve
702
+ from .bench_serve import serve, stop_bench
703
+ if args.stop:
704
+ if stop_bench(board.firmware_dir):
705
+ print("[bench-serve] stop signalled — the server drains "
706
+ "its in-flight operation, then exits")
707
+ return 0
708
+ print("[bench-serve] no bench-serve is holding the lock for "
709
+ f"{board.firmware_dir}")
710
+ return 2
677
711
  return serve(board, args.device_id)
678
712
  simple = {
679
713
  "doctor": cmd_doctor, "build": cmd_build,
@@ -30,6 +30,12 @@ if TYPE_CHECKING: # stdlib-only at runtime (CLI dep);
30
30
  RECORDS_SCHEMA_VERSION = "1.0"
31
31
  RECORDS_DIRNAME = ".flashgate/records"
32
32
 
33
+ # Retention: records are small (a few KB each), but a bench left running
34
+ # for months would grow the dir without bound (architecture doc open
35
+ # question #2). Oldest files are pruned past the cap; the newest PASS
36
+ # and the running audit window are always preserved well inside it.
37
+ MAX_RECORDS = 500
38
+
33
39
  # In-process registry of the record written by the LAST write_record call.
34
40
  # The MCP server (which runs cmd_verify in-process) uses it to attach THIS
35
41
  # run's record — never a stale one picked blindly by mtime.
@@ -219,9 +225,42 @@ def write_record(record: dict, fw_dir: Path, fingerprint: str = "") -> Path:
219
225
  encoding="utf-8")
220
226
  LAST = {"path": path, "record": record, "fw_dir": fw_dir,
221
227
  "at": time.monotonic()}
228
+ _prune(fw_dir, exempt=path)
222
229
  return path
223
230
 
224
231
 
232
+ def _prune(fw_dir: Path, keep: int | None = None,
233
+ exempt: Path | None = None) -> None:
234
+ """Keep only the newest `keep` records (mtime order). Best-effort:
235
+ a file that cannot be deleted is skipped — retention must never
236
+ break record writing. `keep=None` resolves MAX_RECORDS at call time
237
+ (a plain default arg would freeze the constant at import)."""
238
+ if keep is None:
239
+ keep = MAX_RECORDS
240
+ directory = records_dir(fw_dir)
241
+ try:
242
+ # Secondary key = filename (its timestamp prefix): rapid writes
243
+ # land on the same mtime tick and a glob-order tiebreak could
244
+ # judge the JUST-WRITTEN file as oldest (flake found by the
245
+ # mutation round's baseline runs).
246
+ candidates = sorted(directory.glob("*.json"),
247
+ key=lambda p: (p.stat().st_mtime, p.name))
248
+ excess = candidates[:-keep] if keep else []
249
+ if exempt is not None:
250
+ # After an NTP clock rollback every EXISTING file looks
251
+ # "newer" than this one — the just-written record may land in
252
+ # the excess list; drop it from the DELETION list only (it
253
+ # still counts toward the cap, so steady state stays exact).
254
+ excess = [f for f in excess if f != exempt]
255
+ except OSError:
256
+ return
257
+ for stale in excess:
258
+ try:
259
+ stale.unlink()
260
+ except OSError:
261
+ pass
262
+
263
+
225
264
  def latest_record(fw_dir: Path) -> dict | None:
226
265
  """Most recent record (by mtime), or None when none exist yet."""
227
266
  directory = records_dir(fw_dir)