flashgate 0.7.1__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 (38) hide show
  1. {flashgate-0.7.1 → flashgate-0.7.2}/PKG-INFO +28 -6
  2. {flashgate-0.7.1 → flashgate-0.7.2}/README.md +27 -5
  3. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/bench.py +28 -4
  4. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/bench_serve.py +114 -5
  5. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/cli.py +35 -1
  6. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/records.py +39 -0
  7. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/PKG-INFO +28 -6
  8. {flashgate-0.7.1 → flashgate-0.7.2}/pyproject.toml +1 -1
  9. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_bench.py +52 -0
  10. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_bench_serve.py +89 -0
  11. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_cli.py +74 -0
  12. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_records.py +60 -0
  13. {flashgate-0.7.1 → flashgate-0.7.2}/LICENSE +0 -0
  14. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/__init__.py +0 -0
  15. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/__main__.py +0 -0
  16. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/board.py +0 -0
  17. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/flasher.py +0 -0
  18. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/gatestate.py +0 -0
  19. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/mcp_server.py +0 -0
  20. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/probes.py +0 -0
  21. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/results.py +0 -0
  22. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/serialmon.py +0 -0
  23. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/sttools.py +0 -0
  24. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/swdsig.py +0 -0
  25. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/SOURCES.txt +0 -0
  26. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/dependency_links.txt +0 -0
  27. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/entry_points.txt +0 -0
  28. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/requires.txt +0 -0
  29. {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/top_level.txt +0 -0
  30. {flashgate-0.7.1 → flashgate-0.7.2}/setup.cfg +0 -0
  31. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_board.py +0 -0
  32. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_flasher.py +0 -0
  33. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_gatestate.py +0 -0
  34. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_hook_stop.py +0 -0
  35. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_mcp.py +0 -0
  36. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_probes.py +0 -0
  37. {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_serialmon.py +0 -0
  38. {flashgate-0.7.1 → 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.1
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
@@ -180,6 +189,13 @@ fingerprint identifies the SOURCE).
180
189
  same-named servers split-brained the mesh and raced each other for
181
190
  the board's serial port.
182
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
+
183
199
  **Security posture, stated plainly**: D2D mode is zero-authentication —
184
200
  anyone on the same LAN can discover the bench and `start_verify`, which
185
201
  FLASHES THE BOARD. Descriptions and records also carry local paths.
@@ -241,9 +257,11 @@ full profile field reference.
241
257
 
242
258
  ## Status
243
259
 
244
- Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
245
- implemented and validated on real hardware. Windows-first; Linux/macOS
246
- 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.
247
265
 
248
266
  ## License
249
267
 
@@ -267,6 +285,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
267
285
  - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
268
286
  - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
269
287
  静默失效的设置第一步就会露馅
288
+ - 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
289
+ 产物哈希——事后可审计"当时验证的是什么、板子说了什么"
290
+ - `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
291
+ 协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
270
292
  - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
271
293
  会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
272
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
@@ -159,6 +168,13 @@ fingerprint identifies the SOURCE).
159
168
  same-named servers split-brained the mesh and raced each other for
160
169
  the board's serial port.
161
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
+
162
178
  **Security posture, stated plainly**: D2D mode is zero-authentication —
163
179
  anyone on the same LAN can discover the bench and `start_verify`, which
164
180
  FLASHES THE BOARD. Descriptions and records also carry local paths.
@@ -220,9 +236,11 @@ full profile field reference.
220
236
 
221
237
  ## Status
222
238
 
223
- Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
224
- implemented and validated on real hardware. Windows-first; Linux/macOS
225
- 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.
226
244
 
227
245
  ## License
228
246
 
@@ -246,6 +264,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
246
264
  - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
247
265
  - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
248
266
  静默失效的设置第一步就会露馅
267
+ - 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
268
+ 产物哈希——事后可审计"当时验证的是什么、板子说了什么"
269
+ - `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
270
+ 协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
249
271
  - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
250
272
  会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
251
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.
@@ -24,6 +24,8 @@ from __future__ import annotations
24
24
 
25
25
  import asyncio
26
26
  import socket
27
+ import threading
28
+ import _thread
27
29
  import zlib
28
30
  from pathlib import Path
29
31
  from typing import Optional
@@ -60,8 +62,68 @@ def acquire_bench_lock(fw_dir: Path) -> socket.socket:
60
62
  f"owns lock port {_lock_port(fw_dir)}; kill it first)")
61
63
 
62
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
+
63
125
  def _build_driver(bench: BenchDriver):
64
- from device_connect_edge.drivers import DeviceDriver, rpc
126
+ from device_connect_edge.drivers import DeviceDriver, emit, rpc
65
127
 
66
128
  class FlashGateBenchDriver(DeviceDriver):
67
129
  device_type = "flashgate-bench"
@@ -69,10 +131,46 @@ def _build_driver(bench: BenchDriver):
69
131
  def __init__(self, bench: BenchDriver):
70
132
  super().__init__() # base builds _functions_cache etc.
71
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()
72
169
 
73
170
  @rpc()
74
171
  async def describe_bench(self) -> dict:
75
172
  """Board identity, probe list, busy state of this bench."""
173
+ self._remember_loop()
76
174
  return bench.describe()
77
175
 
78
176
  @rpc()
@@ -94,6 +192,7 @@ def _build_driver(bench: BenchDriver):
94
192
  # argument must not cost a build+flash cycle
95
193
  return {"error": "invalid_argument",
96
194
  "detail": "probes must be a list of probe names or null"}
195
+ self._remember_loop()
97
196
  try:
98
197
  return bench.start_verify(probes)
99
198
  except BenchBusyError as exc:
@@ -134,18 +233,28 @@ def serve(board: Board, device_id: str | None = None) -> int:
134
233
  print(f"[bench-serve] {exc}")
135
234
  return 2
136
235
 
236
+ _start_lock_listener(lock)
137
237
  bench = BenchDriver(board)
138
- driver_cls = _build_driver(bench)
238
+ driver = _build_driver(bench)(bench)
239
+ bench.set_on_complete(driver._operation_done) # verify_completed events
139
240
  ident = device_id or f"flashgate-bench-{board.name}"
140
241
  print(f"[bench-serve] {ident}: {board.name} ({board.mcu}) — "
141
- f"four RPCs over device-connect; Ctrl+C to stop")
242
+ f"four RPCs over device-connect; Ctrl+C or `bench-serve --stop` "
243
+ f"to stop")
142
244
  try:
143
- asyncio.run(DeviceRuntime(driver=driver_cls(bench),
245
+ asyncio.run(DeviceRuntime(driver=driver,
144
246
  device_id=ident).run())
145
247
  except KeyboardInterrupt:
146
248
  # Drain before exit (bench.py's deployment constraint): a daemon
147
249
  # worker killed mid-flash leaves CubeProgrammer orphaned and the
148
- # run without its evidence record.
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
149
258
  current = bench.describe().get("current_operation")
150
259
  if current:
151
260
  print(f"\n[bench-serve] draining {current} before stop...")
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashgate
3
- Version: 0.7.1
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
@@ -180,6 +189,13 @@ fingerprint identifies the SOURCE).
180
189
  same-named servers split-brained the mesh and raced each other for
181
190
  the board's serial port.
182
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
+
183
199
  **Security posture, stated plainly**: D2D mode is zero-authentication —
184
200
  anyone on the same LAN can discover the bench and `start_verify`, which
185
201
  FLASHES THE BOARD. Descriptions and records also carry local paths.
@@ -241,9 +257,11 @@ full profile field reference.
241
257
 
242
258
  ## Status
243
259
 
244
- Boot gate, probe gate, Stop hook, MCP server, SWD signature channel — all
245
- implemented and validated on real hardware. Windows-first; Linux/macOS
246
- 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.
247
265
 
248
266
  ## License
249
267
 
@@ -267,6 +285,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
267
285
  - 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
268
286
  - 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
269
287
  静默失效的设置第一步就会露馅
288
+ - 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
289
+ 产物哈希——事后可审计"当时验证的是什么、板子说了什么"
290
+ - `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
291
+ 协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
270
292
  - Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
271
293
  会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
272
294
  打警告,会话不会被卡死
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flashgate"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -347,3 +347,55 @@ class TestHardening:
347
347
  bench_mod.probe_mod.load_probes = orig
348
348
  assert info["probes"] == []
349
349
  assert "bad probes yaml" in info["probes_error"]
350
+
351
+
352
+ class TestCompletionCallback:
353
+ def test_notified_on_success_and_failure(self, tmp_path):
354
+ board = make_board(tmp_path)
355
+ events = []
356
+ d = BenchDriver(board, verify_fn=lambda b, n: len(events))
357
+ d.set_on_complete(lambda snap: events.append((snap["op_id"], snap["state"])))
358
+ d.wait(d.start_verify()["op_id"], 5)
359
+ d.wait(d.start_verify()["op_id"], 5)
360
+ assert [s for _, s in events] == ["succeeded", "failed"]
361
+
362
+ def test_notified_on_pre_start_cancel(self, tmp_path):
363
+ from flashgate.bench import _Operation
364
+ board = make_board(tmp_path)
365
+ events = []
366
+ d = BenchDriver(board, verify_fn=lambda b, n: 0)
367
+ d.set_on_complete(lambda snap: events.append(snap["state"]))
368
+ with d._lock:
369
+ op = _Operation(op_id="op-x", created_at="now")
370
+ d._ops[op.op_id] = op
371
+ d._current = op
372
+ assert d.cancel_operation("op-x") is True
373
+ assert events == ["cancelled"]
374
+
375
+ def test_callback_exception_swallowed(self, tmp_path):
376
+ board = make_board(tmp_path)
377
+
378
+ def boom(snap):
379
+ raise RuntimeError("callback bug")
380
+
381
+ d = BenchDriver(board, verify_fn=lambda b, n: 0)
382
+ d.set_on_complete(boom)
383
+ done = d.wait(d.start_verify()["op_id"], 5)
384
+ assert done["state"] == "succeeded" # bench unaffected
385
+
386
+
387
+ class TestCallbackGuard:
388
+ """Mutation P5: a callback exception must not even surface as an
389
+ unhandled-thread-exception warning."""
390
+
391
+ @pytest.mark.filterwarnings(
392
+ "error::pytest.PytestUnhandledThreadExceptionWarning")
393
+ def test_callback_exception_no_thread_warning(self, tmp_path):
394
+ board = make_board(tmp_path)
395
+
396
+ def boom(snap):
397
+ raise RuntimeError("callback bug")
398
+
399
+ d = BenchDriver(board, verify_fn=lambda b, n: 0)
400
+ d.set_on_complete(boom)
401
+ assert d.wait(d.start_verify()["op_id"], 5)["state"] == "succeeded"
@@ -166,3 +166,92 @@ class TestSingleInstanceLock:
166
166
  assert "one bench-serve per bench" in capsys.readouterr().out
167
167
  finally:
168
168
  lock.close()
169
+
170
+
171
+ class TestStopChannel:
172
+ def test_stop_signals_live_listener(self, tmp_path):
173
+ from pathlib import Path as P
174
+ from flashgate.bench_serve import (_start_lock_listener,
175
+ acquire_bench_lock, stop_bench)
176
+ signalled = []
177
+ # interrupt is called with NO arguments (like _thread.interrupt_main)
178
+ lock = acquire_bench_lock(tmp_path)
179
+ try:
180
+ thread = _start_lock_listener(
181
+ lock, interrupt=lambda: signalled.append("FIRED"))
182
+ assert stop_bench(tmp_path) is True
183
+ thread.join(timeout=3)
184
+ assert not thread.is_alive()
185
+ assert signalled == ["FIRED"] # the stop signal fired once
186
+ finally:
187
+ lock.close()
188
+
189
+ def test_stop_without_holder_is_false(self, tmp_path):
190
+ from flashgate.bench_serve import stop_bench
191
+ (tmp_path / "free").mkdir(exist_ok=True)
192
+ assert stop_bench(tmp_path / "free") is False
193
+
194
+ def test_cli_stop_without_server_exit_2(self, tmp_path, monkeypatch, capsys):
195
+ from flashgate import cli
196
+ board = make_board(tmp_path)
197
+ rc = cli.main(["--board", str(board.yaml_path), "bench-serve", "--stop"])
198
+ assert rc == 2
199
+ assert "no bench-serve" in capsys.readouterr().out
200
+
201
+
202
+ class TestCompletedEventWiring:
203
+ def test_operation_done_noop_without_loop(self, tmp_path):
204
+ board = make_board(tmp_path)
205
+ bench = BenchDriver(board, verify_fn=lambda b, n: 0)
206
+ drv = _build_driver(bench)(bench)
207
+ drv._operation_done({"op_id": "x", "state": "succeeded"}) # no crash
208
+
209
+ def test_loop_captured_on_first_rpc(self, tmp_path):
210
+ board = make_board(tmp_path)
211
+ bench = BenchDriver(board, verify_fn=lambda b, n: 0)
212
+ drv = _build_driver(bench)(bench)
213
+ assert drv._loop is None
214
+ asyncio.run(drv.describe_bench())
215
+ assert drv._loop is not None
216
+
217
+
218
+ class TestEventAndStopGuards:
219
+ """Mutation P6/P8: the loop guard must actually skip scheduling, and
220
+ stop_bench must not claim success without an ok ack."""
221
+
222
+ def test_no_scheduling_without_loop(self, tmp_path, monkeypatch):
223
+ board = make_board(tmp_path)
224
+ bench = BenchDriver(board, verify_fn=lambda b, n: 0)
225
+ drv = _build_driver(bench)(bench)
226
+ calls = []
227
+ monkeypatch.attr_target = None
228
+ import flashgate.bench_serve as bs
229
+ monkeypatch.setattr(
230
+ bs.asyncio, "run_coroutine_threadsafe",
231
+ lambda coro, loop: calls.append(loop))
232
+ drv._operation_done({"op_id": "x", "state": "succeeded"})
233
+ assert calls == [] # loop None: nothing scheduled
234
+ import threading
235
+ loop = asyncio.new_event_loop()
236
+ t = threading.Thread(target=loop.run_forever, daemon=True)
237
+ t.start()
238
+ try:
239
+ drv._loop = loop # as _remember_loop would
240
+ drv._operation_done({"op_id": "y", "state": "failed"})
241
+ assert len(calls) == 1 and calls[0] is loop
242
+ loop.call_soon_threadsafe(loop.stop)
243
+ finally:
244
+ t.join(2)
245
+ loop.close()
246
+
247
+ def test_stop_bench_false_without_ok_ack(self, tmp_path):
248
+ import socket as pysocket
249
+ from flashgate.bench_serve import _lock_port, stop_bench
250
+ # an impostor holds the port but does not speak the protocol
251
+ impostor = pysocket.socket()
252
+ impostor.bind(("127.0.0.1", _lock_port(tmp_path)))
253
+ impostor.listen(1)
254
+ try:
255
+ assert stop_bench(tmp_path) is False
256
+ finally:
257
+ impostor.close()
@@ -281,3 +281,77 @@ class TestUartBuildFailRecord:
281
281
  assert by_name["console"]["status"] == "passed"
282
282
  assert by_name["build"]["status"] == "failed"
283
283
  assert by_name["identity"]["status"] == "skipped"
284
+
285
+
286
+ class TestVerifyJsonFlag:
287
+ def test_json_stdout_pure_record(self, tmp_path, monkeypatch, capsys):
288
+ from flashgate import records as rec
289
+ board = make_board(tmp_path)
290
+ monkeypatch.setattr(cli, "_console_port", lambda b: (None, "no serial"))
291
+ monkeypatch.setattr(cli, "_build", lambda b, j=None: cli.EXIT_BUILD)
292
+ rc = cli.main(["--board", str(board.yaml_path), "verify", "--json"])
293
+ assert rc == cli.EXIT_BUILD
294
+ out = capsys.readouterr()
295
+ import json as _json
296
+ payload = _json.loads(out.out.strip().splitlines()[-1])
297
+ assert payload["run"]["exit_code"] == 1
298
+ by_name = {c["name"]: c for c in payload["checks"]}
299
+ assert by_name["build"]["status"] == "failed"
300
+ assert by_name["identity"]["status"] == "skipped"
301
+ assert "[verify]" in out.err # human logs on stderr
302
+
303
+
304
+ class TestVerifyJsonGuard:
305
+ """Mutation P4: a stale LAST entry from ANOTHER firmware dir must not
306
+ be printed as this run's record."""
307
+
308
+ def test_foreign_last_not_printed(self, tmp_path, monkeypatch, capsys):
309
+ import json as _json
310
+ from flashgate import records as rec
311
+ board = make_board(tmp_path)
312
+ rec.LAST = {"path": None, "fw_dir": "Z:/somewhere-else",
313
+ "record": {"run": {"exit_code": 0}}, "at": 0.0}
314
+
315
+ def no_write(b, names, evidence=None):
316
+ return 7 # fails, writes nothing
317
+
318
+ monkeypatch.setattr(cli, "cmd_verify", no_write)
319
+ rc = cli.main(["--board", str(board.yaml_path), "verify", "--json"])
320
+ assert rc == 7
321
+ payload = _json.loads(capsys.readouterr().out.strip().splitlines()[-1])
322
+ assert payload == {"error": "no record written", "exit_code": 7}
323
+
324
+ def test_stdout_carries_no_human_logs(self, tmp_path, monkeypatch, capsys):
325
+ board = make_board(tmp_path)
326
+ monkeypatch.setattr(cli, "_console_port", lambda b: (None, "no serial"))
327
+ monkeypatch.setattr(cli, "_build", lambda b, j=None: cli.EXIT_BUILD)
328
+ cli.main(["--board", str(board.yaml_path), "verify", "--json"])
329
+ out = capsys.readouterr().out
330
+ assert "[verify]" not in out and "[build]" not in out
331
+
332
+
333
+ class TestJsonEncodingGuard:
334
+ """Adversarial R1: the JSON must survive a non-UTF-8 consumer pipe —
335
+ U+FFFD from serial noise met cp936 and used to wipe stdout, exit 1."""
336
+
337
+ def test_json_output_is_pure_ascii(self, tmp_path, monkeypatch, capsys):
338
+ import json as _json
339
+ from flashgate import records as rec
340
+ board = make_board(tmp_path)
341
+
342
+ def noisy(b, names, evidence=None):
343
+ j = rec.VerifyJournal(["boot"], mode="uart", probe_names=names)
344
+ j.check("boot", "failed")
345
+ j.add_evidence("console-tail", "COM3",
346
+ "noise \ufffd 拒绝访问 \ufffd")
347
+ r = j.to_record(b, 3, "timed_out", "x")
348
+ rec.write_record(r, b.firmware_dir, fingerprint="ee" * 32)
349
+ return 3
350
+
351
+ monkeypatch.setattr(cli, "cmd_verify", noisy)
352
+ rc = cli.main(["--board", str(board.yaml_path), "verify", "--json"])
353
+ assert rc == 3
354
+ raw = capsys.readouterr().out.strip().splitlines()[-1]
355
+ raw.encode("ascii") # must not raise
356
+ payload = _json.loads(raw)
357
+ assert "\ufffd" in str(payload) # content survives, escaped
@@ -6,6 +6,7 @@ record-level echo of the gate's core rule.
6
6
  """
7
7
 
8
8
  import hashlib
9
+ import json
9
10
  from pathlib import Path
10
11
  from types import SimpleNamespace
11
12
 
@@ -233,3 +234,62 @@ class TestCrashNetAndUniqueness:
233
234
  # compare by path content, NOT latest_record's mtime order —
234
235
  # same-tick writes make that ordering flaky on some filesystems
235
236
  assert _json.loads(p2.read_text(encoding="utf-8"))["record_id"] == p2.stem
237
+
238
+
239
+ class TestRetention:
240
+ """Architecture-doc open question #2: records must not grow unbounded."""
241
+
242
+ def test_prune_keeps_newest_cap(self, tmp_path, monkeypatch):
243
+ from flashgate import records as rec
244
+ monkeypatch.setattr(rec, "MAX_RECORDS", 5)
245
+ for i in range(8):
246
+ j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
247
+ j.check("build", "passed")
248
+ rec.write_record(j.to_record(_board_stub(), i % 8, "failed", "x"),
249
+ tmp_path, fingerprint=f"{i:064d}")
250
+ files = sorted(rec.records_dir(tmp_path).glob("*.json"))
251
+ assert len(files) == 5
252
+ kept = [json.loads(f.read_text(encoding="utf-8"))["run"]["exit_code"]
253
+ for f in files]
254
+ assert kept == [3, 4, 5, 6, 7] # the five NEWEST by mtime
255
+
256
+ def test_prune_never_breaks_writing(self, tmp_path, monkeypatch):
257
+ import pytest
258
+ from flashgate import records as rec
259
+ monkeypatch.setattr(rec, "MAX_RECORDS", 2)
260
+ for i in range(4):
261
+ j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
262
+ j.check("build", "passed")
263
+ path = rec.write_record(
264
+ j.to_record(_board_stub(), 0, "succeeded", "x"),
265
+ tmp_path, fingerprint=f"{i:064d}")
266
+ assert path.exists()
267
+
268
+
269
+ class TestPruneGuards:
270
+ """Adversarial-round fixes: the just-written record survives an NTP
271
+ clock rollback (R3); retention stays best-effort, never fatal."""
272
+
273
+ def test_just_written_survives_clock_rollback(self, tmp_path, monkeypatch):
274
+ import os
275
+ from datetime import datetime, timedelta
276
+ from flashgate import records as rec
277
+ monkeypatch.setattr(rec, "MAX_RECORDS", 3)
278
+ # three records whose mtimes are ONE HOUR IN THE FUTURE
279
+ for i in range(3):
280
+ j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
281
+ j.check("build", "passed")
282
+ p = rec.write_record(j.to_record(_board_stub(), 0, "succeeded", "x"),
283
+ tmp_path, fingerprint=f"{i:064d}")
284
+ future = datetime.now() + timedelta(hours=1)
285
+ os.utime(p, (future.timestamp(), future.timestamp()))
286
+ # a fresh write under the rolled-back clock must survive its own prune
287
+ j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
288
+ j.check("build", "passed")
289
+ fresh = rec.write_record(j.to_record(_board_stub(), 7, "failed", "x"),
290
+ tmp_path, fingerprint="ff" * 32)
291
+ assert fresh.exists(), "the just-written record must never be pruned"
292
+ # (latest_record sorts by mtime, so the future-stamped files win —
293
+ # correct behavior under clock skew; the invariant is survival)
294
+ import json as _json
295
+ assert _json.loads(fresh.read_text(encoding="utf-8"))["record_id"] == fresh.stem
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes