fix(k1): gate control dialogue on live state

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 17:19:21 +03:00
parent a29b38a0d7
commit 36f0c93d2a
14 changed files with 1435 additions and 122 deletions
+163 -28
View File
@@ -7,6 +7,7 @@ import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
ApplicationAcceptanceError,
OperatorDialogueCheckpoint,
PhysicalAcceptanceChecklist,
PhysicalAcceptanceDialogueExecutor,
PhysicalAcceptancePermit,
@@ -15,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_CONFIG_RESPONSE_TOPIC,
ApplicationControlAuthority,
ShadowApplicationBootstrapOrchestrator,
build_canonical_post_start_observation,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
MODELING_RESPONSE_TOPIC,
@@ -91,8 +93,15 @@ def _modeling_response(action: ModelingAction) -> bytes:
class SyntheticAcceptanceTransport:
def __init__(self) -> None:
def __init__(self, clock: FakeClock | None = None) -> None:
self.batches: list[tuple[str, ...]] = []
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
self.clock = clock
self.pre_start_maintain_calls = 0
self.active_maintain_calls = 0
self.post_stop_maintain_calls = 0
self.start_emitted = False
self.stop_emitted = False
def exchange_batch_once(
self,
@@ -109,11 +118,15 @@ class SyntheticAcceptanceTransport:
else ModelingAction.STOP
)
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
if action is ModelingAction.START:
self.start_emitted = True
else:
self.stop_emitted = True
return responses
for envelope in envelopes:
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
assert prefix == "bootstrap"
assert prefix in {"bootstrap", "dialogue"}
ordinal = int(ordinal_text)
if message_type == "DeviceInfoRequest":
session = (
@@ -134,6 +147,36 @@ class SyntheticAcceptanceTransport:
responses[topic] = response
return responses
def maintain_open_for(
self,
duration_seconds: float,
*,
allowed_response_topics: Collection[str] = (),
) -> None:
self.maintain_calls.append(
(duration_seconds, tuple(sorted(allowed_response_topics)))
)
if self.clock is not None:
self.clock.now += duration_seconds
if self.stop_emitted:
self.post_stop_maintain_calls += 1
elif self.start_emitted:
self.active_maintain_calls += 1
else:
self.pre_start_maintain_calls += 1
def scan_initialization_complete(self, _binding: object) -> bool:
return self.active_maintain_calls >= 2
def pre_start_ready(self, _binding: object) -> bool:
return not self.start_emitted
def standby_complete(self, _binding: object) -> bool:
return self.stop_emitted and self.post_stop_maintain_calls >= 2
def validate_bound_status(self, _binding: object) -> None:
return None
def _response_suffix(message_type: str) -> str:
values = {
@@ -158,17 +201,56 @@ def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
)
def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit() -> None:
transport = SyntheticAcceptanceTransport()
permit = PhysicalAcceptancePermit(_checklist(ModelingAction.START))
executor = PhysicalAcceptanceDialogueExecutor(transport, permit)
class FakeClock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> None:
clock = FakeClock()
transport = SyntheticAcceptanceTransport(clock)
executor = PhysicalAcceptanceDialogueExecutor(transport)
orchestrator = ShadowApplicationBootstrapOrchestrator(
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
binding = executor.run_bootstrap(orchestrator)
with pytest.raises(ApplicationAcceptanceError, match="collapsed bootstrap is disabled"):
executor.run_bootstrap(orchestrator)
binding = executor.run_connection_stage(orchestrator)
with pytest.raises(ApplicationAcceptanceError, match="not issued by this canonical session"):
executor.run_workspace_entry_stage(
orchestrator,
OperatorDialogueCheckpoint(
event="workspace-entered",
operator_initiated=True,
owner_token=object(),
),
)
assert [len(batch) for batch in transport.batches] == [1, 2, 3]
workspace_checks = iter((False, False, True))
workspace_checkpoint = executor.wait_for_operator_checkpoint(
"workspace-entered",
lambda: next(workspace_checks),
)
executor.run_workspace_entry_stage(
orchestrator,
workspace_checkpoint,
)
project_checks = iter((False, False, True))
project_checkpoint = executor.wait_for_operator_checkpoint(
"project-prompt-opened",
lambda: next(project_checks),
)
executor.run_project_prompt_stage(
orchestrator,
project_checkpoint,
)
command = ShadowModelingCommand.from_command(
encode_modeling_start(
CommandHeaderIdentity(
@@ -181,23 +263,82 @@ def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit(
mount_type=MountType.HANDHELD,
)
)
response = executor.execute_modeling(command)
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
executor.execute_modeling(command)
start_wait_polls = 0
def start_confirmed() -> bool:
nonlocal start_wait_polls
start_wait_polls += 1
return start_wait_polls > 202
start_checkpoint = executor.wait_for_operator_checkpoint(
"start-confirmed",
start_confirmed,
)
# Human/UI time is serviced on the socket and precedes the command permit.
start_permit = PhysicalAcceptancePermit(
_checklist(ModelingAction.START),
ttl_seconds=120.0,
monotonic=clock,
)
post_start = build_canonical_post_start_observation(
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
binding,
)
response = executor.execute_canonical_start(
command,
post_start,
authority=ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
binding=binding,
permit=start_permit,
checkpoint=start_checkpoint,
)
assert response.action is ModelingAction.START
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1]
assert permit.snapshot()["consumed"] is True
stop_checks = iter((False, False, True))
executor.maintain_active_until_stop_requested(lambda: next(stop_checks))
stop_permit = PhysicalAcceptancePermit(
_checklist(ModelingAction.STOP),
ttl_seconds=120.0,
monotonic=clock,
)
stop_command = ShadowModelingCommand.from_command(
encode_modeling_stop(
CommandHeaderIdentity(
device_id=binding.vendor_device_id,
openapi_key=APPLICATION_KEY,
)
)
)
stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
standby_checks = iter((False, False, True))
executor.maintain_post_stop_until_standby_confirmed(
lambda: next(standby_checks)
)
assert stop_response.action is ModelingAction.STOP
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1, 1, 2, 1]
assert len(transport.maintain_calls) == 214
assert set(transport.maintain_calls) == {
(1.0, ("lixel/application/response/modeling_status",))
}
assert start_permit.snapshot()["consumed"] is True
assert stop_permit.snapshot()["consumed"] is True
assert executor.snapshot()["bootstrap_complete"] is True
assert executor.snapshot()["command_complete"] is True
assert executor.snapshot()["start_complete"] is True
assert executor.snapshot()["stop_complete"] is True
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
response_evidence = executor.snapshot()["response_evidence"]
assert isinstance(response_evidence, tuple)
assert len(response_evidence) == 10
assert len(response_evidence) == 13
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
assert response_evidence[-1]["operation_key"] == "modeling:start"
assert response_evidence[-1]["operation_key"] == "modeling:stop"
assert all("payload" not in item for item in response_evidence)
assert APPLICATION_KEY not in str(executor.snapshot())
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
with pytest.raises(ApplicationAcceptanceError, match="already attempted"):
executor.execute_modeling(command)
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
@@ -220,7 +361,6 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
executor = PhysicalAcceptanceDialogueExecutor(
CorruptDeviceConfigTransport(),
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
)
orchestrator = ShadowApplicationBootstrapOrchestrator(
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
@@ -232,7 +372,7 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
ApplicationAcceptanceError,
match=r"ordinal 4 \(DeviceConfigRequest\).*session correlation",
):
executor.run_bootstrap(orchestrator)
executor.run_connection_stage(orchestrator)
snapshot = executor.snapshot()
failure = snapshot["correlation_failure"]
@@ -253,12 +393,9 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
assert VENDOR_DEVICE_ID not in str(snapshot)
def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None:
def test_standalone_start_and_stop_are_both_disabled() -> None:
transport = SyntheticAcceptanceTransport()
start = PhysicalAcceptanceDialogueExecutor(
transport,
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
)
start = PhysicalAcceptanceDialogueExecutor(transport)
start_command = ShadowModelingCommand.from_command(
encode_modeling_start(
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY),
@@ -268,20 +405,18 @@ def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() ->
mount_type=MountType.HANDHELD,
)
)
with pytest.raises(ApplicationAcceptanceError, match="requires.*bootstrap"):
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
start.execute_modeling(start_command)
stop = PhysicalAcceptanceDialogueExecutor(
transport,
PhysicalAcceptancePermit(_checklist(ModelingAction.STOP)),
)
stop = PhysicalAcceptanceDialogueExecutor(transport)
stop_command = ShadowModelingCommand.from_command(
encode_modeling_stop(
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
)
)
assert stop.execute_modeling(stop_command).action is ModelingAction.STOP
assert transport.batches[-1] == ("modeling:stop",)
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
stop.execute_modeling(stop_command)
assert transport.batches == []
def test_acceptance_permit_rejects_implicit_or_expired_authority() -> None: