feat(k1): complete primary acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 23:03:59 +03:00
parent 9d51080d2e
commit aa3680948f
66 changed files with 6093 additions and 544 deletions
+87 -1
View File
@@ -19,6 +19,8 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import (
CaptureFormatError,
capture_mqtt,
iter_capture_frames,
read_capture_clock_envelope,
seal_capture_clock,
validate_private_ipv4,
)
@@ -97,11 +99,17 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
fake = FakeClient()
observed = []
clock_ready: list[bool] = []
def on_clock_established() -> None:
origin = tmp_path / "capture" / "mqtt.timeline.origin.json"
clock_ready.append(origin.is_file() and bool(origin.read_bytes()))
summary = capture_mqtt(
"192.168.1.50",
tmp_path / "capture",
duration_seconds=30,
on_clock_established=on_clock_established,
on_message_recorded=observed.append,
_client_factory=lambda: cast(mqtt.Client, fake),
)
@@ -109,6 +117,7 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
assert fake.disconnect_count == 1
assert clock_ready == [True]
assert summary["stop_reason"] == "keyboard_interrupt"
assert summary["message_count"] == 1
assert summary["payload_bytes"] == len(fake.payload)
@@ -151,8 +160,26 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
assert saved_summary == summary
assert saved_summary["schema_version"] == 2
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
clock = read_capture_clock_envelope(
capture_dir / "mqtt.timeline.json",
expected_sha256=saved_summary["artifact_hashes"]["capture_clock_sha256"],
)
assert clock.started_monotonic_ns <= observed[0].received_monotonic_ns
assert observed[0].received_monotonic_ns <= clock.completed_monotonic_ns
assert clock.duration_ns > 0
assert saved_summary["session_elapsed_seconds"] == pytest.approx(
clock.duration_ns / 1_000_000_000
)
assert saved_summary["artifacts"]["capture_clock"] == "mqtt.timeline.json"
for artifact_name in (
"mqtt.raw.k1mqtt",
"mqtt.metadata.jsonl",
"mqtt.timeline.origin.json",
"mqtt.timeline.json",
"mqtt.summary.json",
):
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
@@ -216,6 +243,65 @@ def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
def test_owner_seals_session_clock_after_all_producers_stop(tmp_path: Path) -> None:
capture_dir = tmp_path / "capture"
summary = capture_mqtt(
"192.168.1.50",
capture_dir,
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
provisional = read_capture_clock_envelope(capture_dir / "mqtt.timeline.json")
assert summary["capture_clock_scope"] == "transport"
sealed = seal_capture_clock(capture_dir)
updated = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert updated["capture_clock_scope"] == "session"
sealed_path = capture_dir / updated["artifacts"]["capture_clock"]
assert sealed_path.name == f"mqtt.timeline.session-{sealed.artifact_sha256}.json"
assert (capture_dir / "mqtt.timeline.json").read_bytes()
assert sealed.started_monotonic_ns == provisional.started_monotonic_ns
assert sealed.completed_monotonic_ns >= provisional.completed_monotonic_ns
assert updated["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
assert updated["session_elapsed_seconds"] == pytest.approx(sealed.duration_ns / 1_000_000_000)
repeated = seal_capture_clock(capture_dir)
repeated_summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert repeated == sealed
assert repeated_summary == updated
def test_owner_seal_is_retryable_if_summary_pointer_switch_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture_dir = tmp_path / "capture"
capture_mqtt(
"192.168.1.50",
capture_dir,
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
provisional_summary = (capture_dir / "mqtt.summary.json").read_bytes()
with monkeypatch.context() as context:
context.setattr(
capture_module,
"_write_json_atomic_replace",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected switch failure")),
)
with pytest.raises(CaptureError, match="injected switch failure"):
seal_capture_clock(capture_dir)
assert (capture_dir / "mqtt.summary.json").read_bytes() == provisional_summary
assert list(capture_dir.glob("mqtt.timeline.session-*.json"))
sealed = seal_capture_clock(capture_dir)
summary = json.loads((capture_dir / "mqtt.summary.json").read_text(encoding="utf-8"))
assert summary["capture_clock_scope"] == "session"
assert summary["artifact_hashes"]["capture_clock_sha256"] == sealed.artifact_sha256
def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None:
fake = FakeClient()
capture_dir = tmp_path / "capture"