NODEDC_MISSION_CORE/src/k1link/simulation/worker.py

135 lines
5.0 KiB
Python

from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Protocol
from k1link.simulation.contracts import QualificationRun
from k1link.simulation.orchestrator import WorkerStartResult, WorkerStopResult
from k1link.simulation.process_supervisor import (
PosixProcessSupervisor,
ProcessSpec,
)
from k1link.simulation.s0 import S0Profile, load_s0_profile
class WorkerAdmissionError(RuntimeError):
"""The worker no longer matches the accepted S0 generation or D boundary."""
@dataclass(frozen=True, slots=True)
class WorkerAdmission:
profile: S0Profile
profile_sha256: str
artifact_run_root: PurePosixPath
process_run_root: PurePosixPath
class SimulationWorldControl(Protocol):
def pause(self, run_id: str) -> None: ...
def resume(self, run_id: str) -> None: ...
def step(self, run_id: str, step_count: int) -> int: ...
class S0WorkerGuard:
"""Bind every S1 start to the exact accepted S0 profile and D artifact root."""
def __init__(self, profile_path: Path) -> None:
self.profile_path = profile_path.expanduser().resolve()
self.profile = load_s0_profile(self.profile_path)
self.profile_sha256 = hashlib.sha256(self.profile_path.read_bytes()).hexdigest()
def admit(
self,
run: QualificationRun,
artifact_run_root: PurePosixPath,
process_run_root: PurePosixPath,
) -> WorkerAdmission:
if run.host_profile_id != self.profile.profile_id:
raise WorkerAdmissionError("run host profile id does not match accepted S0")
if run.host_profile_sha256 != self.profile_sha256:
raise WorkerAdmissionError("run host profile digest does not match accepted S0")
artifact_roots = tuple(
item.wsl_path
for item in self.profile.storage.mutable_paths
if item.identifier == "artifacts"
)
if len(artifact_roots) != 1:
raise WorkerAdmissionError("accepted S0 profile has no unique artifact root")
runtime_roots = tuple(
item.wsl_path
for item in self.profile.storage.mutable_paths
if item.identifier == "runtime"
)
if len(runtime_roots) != 1:
raise WorkerAdmissionError("accepted S0 profile has no unique runtime root")
expected_artifact_parent = artifact_roots[0] / "s1" / "runs"
expected_runtime_parent = runtime_roots[0] / "s1" / "runs"
if artifact_run_root != expected_artifact_parent / run.run_id:
raise WorkerAdmissionError("S1 run root escapes the accepted D-only layout")
if process_run_root != expected_runtime_parent / run.run_id:
raise WorkerAdmissionError("S1 process root escapes the accepted D-only layout")
if not all(
component.qualification_state == "accepted" for component in self.profile.components
):
raise WorkerAdmissionError("S0 component generation is no longer accepted")
return WorkerAdmission(
profile=self.profile,
profile_sha256=self.profile_sha256,
artifact_run_root=artifact_run_root,
process_run_root=process_run_root,
)
class LocalProcessWorkerAdapter:
"""Worker-local S1B adapter; transport and PX4 mapping remain separate ports."""
def __init__(
self,
guard: S0WorkerGuard,
supervisor: PosixProcessSupervisor,
specs: tuple[ProcessSpec, ...],
world_control: SimulationWorldControl,
) -> None:
self.guard = guard
self.supervisor = supervisor
self.specs = specs
self.world_control = world_control
def start(self, run: QualificationRun) -> WorkerStartResult:
artifact_root = next(
item.wsl_path
for item in self.guard.profile.storage.mutable_paths
if item.identifier == "artifacts"
)
artifact_run_root = artifact_root / "s1" / "runs" / run.run_id
process_run_root = PurePosixPath(self.supervisor.runtime_root.as_posix()) / run.run_id
admission = self.guard.admit(run, artifact_run_root, process_run_root)
records = self.supervisor.start(run.run_id, self.specs)
return WorkerStartResult(
provider_ids=tuple(record.process_id for record in records),
profile_sha256=admission.profile_sha256,
)
def pause(self, run: QualificationRun) -> None:
self.world_control.pause(run.run_id)
def resume(self, run: QualificationRun) -> None:
self.world_control.resume(run.run_id)
def step(self, run: QualificationRun, step_count: int) -> int:
return self.world_control.step(run.run_id, step_count)
def stop(self, run: QualificationRun) -> WorkerStopResult:
result = self.supervisor.stop()
return WorkerStopResult(
stopped_provider_ids=result.stopped_process_ids,
residue_provider_ids=result.residue_process_ids,
)
def reconcile(self, run: QualificationRun) -> WorkerStopResult:
return self.stop(run)