feat(simulation): add Worker AI polygon runtime and terrain navigation

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:45 +03:00
parent a7c64e009d
commit f01bd39037
88 changed files with 9918 additions and 108 deletions
+9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import json
import os
import tempfile
@@ -8,6 +9,14 @@ from pathlib import Path
from typing import Any
def canonical_json_sha256(value: object) -> str:
"""Platform-independent identity for shared Core/Worker JSON contracts."""
payload = json.dumps(
value, ensure_ascii=False, allow_nan=False, separators=(",", ":"), sort_keys=True
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def utc_now_iso() -> str:
"""Return a stable UTC timestamp for manifests and capture artifacts."""
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
+75 -29
View File
@@ -1,33 +1,41 @@
"""Observation-only laboratory orchestration contracts."""
"""Observation contracts; platform-specific services are loaded only on demand.
from k1link.observatory.canonical_result import (
is_admitted_observatory_recorded_result,
)
from k1link.observatory.run_preparations import (
MAX_RUN_PREPARATION_RECORDS,
MAX_RUN_PREPARATION_STORAGE_BYTES,
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
OBSERVATORY_RUN_PREPARATION_SCHEMA,
RUN_PREPARATION_DATABASE_NAME,
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
ObservatoryRunPreparation,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
ObservatoryRunPreparationNotFoundError,
load_observatory_run_preparation_ledger,
observatory_run_preparation_request_sha256,
)
from k1link.observatory.setups import (
LABORATORY_SETUP_CATALOG_SCHEMA,
LABORATORY_SETUP_REGISTRY_SCHEMA,
OBSERVATORY_CALCULATION_PROFILE_SCHEMA,
LaboratorySetupRegistry,
LaboratorySetupRegistryError,
)
Importing a portable composition on Windows must not import Core's POSIX
artifact gateway or eagerly initialize session/recording infrastructure.
"""
from importlib import import_module
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from k1link.observatory.canonical_result import (
is_admitted_observatory_recorded_result,
)
from k1link.observatory.run_preparations import (
MAX_RUN_PREPARATION_RECORDS,
MAX_RUN_PREPARATION_STORAGE_BYTES,
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
OBSERVATORY_RUN_PREPARATION_SCHEMA,
RUN_PREPARATION_DATABASE_NAME,
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
ObservatoryRunPreparation,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
ObservatoryRunPreparationNotFoundError,
load_observatory_run_preparation_ledger,
observatory_run_preparation_request_sha256,
)
from k1link.observatory.setups import (
LABORATORY_SETUP_CATALOG_SCHEMA,
LABORATORY_SETUP_REGISTRY_SCHEMA,
OBSERVATORY_CALCULATION_PROFILE_SCHEMA,
LaboratorySetupRegistry,
LaboratorySetupRegistryError,
)
__all__ = [
"LABORATORY_SETUP_CATALOG_SCHEMA",
@@ -53,3 +61,41 @@ __all__ = [
"load_observatory_run_preparation_ledger",
"observatory_run_preparation_request_sha256",
]
_EXPORTS = {
"is_admitted_observatory_recorded_result": "k1link.observatory.canonical_result",
"MAX_RUN_PREPARATION_RECORDS": "k1link.observatory.run_preparations",
"MAX_RUN_PREPARATION_STORAGE_BYTES": "k1link.observatory.run_preparations",
"OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA": "k1link.observatory.run_preparations",
"OBSERVATORY_RUN_PREPARATION_SCHEMA": "k1link.observatory.run_preparations",
"RUN_PREPARATION_DATABASE_NAME": "k1link.observatory.run_preparations",
"RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS": "k1link.observatory.run_preparations",
"ObservatoryRunPreparation": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationCapacityError": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationConflictError": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationError": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationIntegrityError": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationIntent": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationLedger": "k1link.observatory.run_preparations",
"ObservatoryRunPreparationNotFoundError": "k1link.observatory.run_preparations",
"load_observatory_run_preparation_ledger": "k1link.observatory.run_preparations",
"observatory_run_preparation_request_sha256": "k1link.observatory.run_preparations",
"LABORATORY_SETUP_CATALOG_SCHEMA": "k1link.observatory.setups",
"LABORATORY_SETUP_REGISTRY_SCHEMA": "k1link.observatory.setups",
"OBSERVATORY_CALCULATION_PROFILE_SCHEMA": "k1link.observatory.setups",
"LaboratorySetupRegistry": "k1link.observatory.setups",
"LaboratorySetupRegistryError": "k1link.observatory.setups",
}
def __getattr__(name: str):
module = _EXPORTS.get(name)
if module is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(import_module(module), name)
globals()[name] = value
return value
def __dir__():
return sorted(set(globals()) | set(__all__))
+14 -4
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.portable_run_definitions import canonical_sha256
from k1link.artifacts import canonical_json_sha256 as canonical_sha256
COMPOSITION_SCHEMA: Final = "missioncore.observatory-ai-composition/v1"
MODULE_SCHEMA: Final = "missioncore.observatory-ai-module/v1"
@@ -179,6 +179,11 @@ class CompositionSpec:
"""Source-independent graph, in deterministic topological execution order."""
nodes: tuple[CompositionNode, ...]
execution_mode: str = "recorded-observation-only"
def __post_init__(self) -> None:
if self.execution_mode not in ("recorded-observation-only", "worker-local-simulation"):
raise CompositionError("unsupported composition execution mode")
@property
def source_capabilities(self) -> tuple[str, ...]:
@@ -212,7 +217,10 @@ class CompositionSpec:
"nodes": [node.as_dict() for node in self.nodes],
"source_capabilities": list(self.source_capabilities),
"outputs": list(self.outputs),
"execution": {"max_parallel_nodes": 1, "mode": "recorded-observation-only"},
"execution": {
"max_parallel_nodes": 2 if self.execution_mode == "worker-local-simulation" else 1,
"mode": self.execution_mode,
},
}
@property
@@ -286,7 +294,9 @@ class ModuleRegistry:
],
}
def compose(self, document: object) -> CompositionSpec:
def compose(
self, document: object, *, execution_mode: str = "recorded-observation-only"
) -> CompositionSpec:
root = _object(document, {"schema_version", "selections"})
if root["schema_version"] != COMPOSITION_SCHEMA:
raise CompositionError("unsupported composition schema")
@@ -381,7 +391,7 @@ class ModuleRegistry:
for key in ready:
ordered.append(pending.pop(key))
emitted.add(key)
return CompositionSpec(tuple(ordered))
return CompositionSpec(tuple(ordered), execution_mode=execution_mode)
def node_input_identity(
+53 -1
View File
@@ -97,6 +97,11 @@ _AUTHORITY: Final = {
}
_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS simulation_worker_reservation (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner_id TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS observatory_recorded_jobs (
job_id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
@@ -1185,6 +1190,46 @@ class ObservatoryRecordedJobQueue:
self._lock = threading.RLock()
self._initialize()
def reserve_simulation(self, owner_id: str) -> None:
"""Reserve this Worker's GPU atomically against recorded/live admission.
A lost heartbeat does not prove GPU release. This reservation survives
restarts and is removed only by the exact simulator's release receipt.
"""
if not re.fullmatch(r"airun-[a-f0-9]{32}", owner_id):
raise ValueError("invalid simulation resource owner")
with self._transaction() as connection:
existing = connection.execute(
"SELECT owner_id FROM simulation_worker_reservation"
).fetchone()
if existing is not None:
if existing["owner_id"] == owner_id:
return
raise ObservatoryRecordedQueueBusyError("Worker занят другим прогоном симуляции.")
busy = connection.execute(
"SELECT job_id FROM observatory_recorded_jobs "
"WHERE state IN ('claimed', 'running', 'paused', 'preemption-pending', "
"'reconciliation-required') LIMIT 1"
).fetchone()
if busy is not None or self._open_live_lease_row(connection) is not None:
raise ObservatoryRecordedQueueBusyError("Worker занят задачей AI Inference.")
connection.execute(
"INSERT INTO simulation_worker_reservation VALUES (1, ?, ?)",
(owner_id, self._timestamp()),
)
def release_simulation(self, owner_id: str) -> None:
"""Called only after the trusted simulator reports all GPU work stopped."""
with self._transaction() as connection:
existing = connection.execute(
"SELECT owner_id FROM simulation_worker_reservation"
).fetchone()
if existing is not None and existing["owner_id"] != owner_id:
raise ObservatoryRecordedQueueConflictError("simulation resource owner changed")
connection.execute(
"DELETE FROM simulation_worker_reservation WHERE owner_id = ?", (owner_id,)
)
def resolve_definition(
self,
setup_id: str,
@@ -1444,7 +1489,10 @@ class ObservatoryRecordedJobQueue:
label="legacy claim receipt",
)
row = None
if self._open_live_lease_row(connection) is None:
simulation = connection.execute(
"SELECT owner_id FROM simulation_worker_reservation"
).fetchone()
if self._open_live_lease_row(connection) is None and simulation is None:
active_owner = connection.execute(
"SELECT job_id FROM observatory_recorded_jobs "
"WHERE state IN ('claimed', 'running', 'preemption-pending', "
@@ -2382,6 +2430,10 @@ class ObservatoryRecordedJobQueue:
_validate_pattern(lease_id, _LEASE_ID, "live lease id")
self.recover_stale_claims()
with self._transaction() as connection:
if connection.execute("SELECT owner_id FROM simulation_worker_reservation").fetchone():
raise ObservatoryRecordedQueueBusyError(
"simulation has not released the Worker GPU"
)
lease = self._get_live_lease(connection, lease_id)
if lease.state == "active":
return lease
+155 -74
View File
@@ -1,78 +1,86 @@
"""Mission Core qualification and simulation boundaries."""
"""Mission Core simulation contracts, with platform services loaded on demand.
from k1link.simulation.contracts import (
AckermannControlSetpoint,
AuthorityProfile,
CommandAuthorityScope,
ControlProfile,
ControlSetpoint,
DifferentialControlSetpoint,
ProviderPin,
QualificationArtifact,
QualificationEvent,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
SimulationContractError,
)
from k1link.simulation.orchestrator import (
ActiveQualificationRunError,
SimulationApplicationService,
SimulationOrchestratorError,
SimulationWorkerPort,
WorkerStartResult,
WorkerStopResult,
)
from k1link.simulation.process_supervisor import (
OwnedProcess,
PosixProcessSupervisor,
ProcessSpec,
ProcessStopResult,
ProcessSupervisorError,
)
from k1link.simulation.provider_contract import (
PROVIDER_PROFILE_SCHEMA,
ProviderRole,
SimulationClockDescriptor,
SimulationProviderContractError,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
from k1link.simulation.run_store import (
QualificationRunConflictError,
QualificationRunIntegrityError,
QualificationRunNotFoundError,
QualificationRunStore,
QualificationRunStoreError,
QualificationRunTransitionError,
)
from k1link.simulation.s0 import (
CheckStatus,
DoctorVerdict,
RuntimeAcceptance,
S0DoctorReport,
S0Profile,
S0ProfileError,
load_s0_profile,
run_s0_doctor,
)
from k1link.simulation.stock_rover import (
LIFECYCLE_PROFILE_SCHEMA,
StockRoverLifecycleProfile,
StockRoverProfileError,
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
stock_rover_process_specs,
)
from k1link.simulation.worker import (
LocalProcessWorkerAdapter,
S0WorkerGuard,
SimulationWorldControl,
WorkerAdmission,
WorkerAdmissionError,
)
Portable AI compositions do not require the legacy S0/YAML or POSIX process
supervisor when loaded by the Windows Worker coordinator.
"""
from importlib import import_module
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from k1link.simulation.contracts import (
AckermannControlSetpoint,
AuthorityProfile,
CommandAuthorityScope,
ControlProfile,
ControlSetpoint,
DifferentialControlSetpoint,
ProviderPin,
QualificationArtifact,
QualificationEvent,
QualificationRun,
ReproducibilityTier,
RunKind,
RunState,
SimulationContractError,
)
from k1link.simulation.orchestrator import (
ActiveQualificationRunError,
SimulationApplicationService,
SimulationOrchestratorError,
SimulationWorkerPort,
WorkerStartResult,
WorkerStopResult,
)
from k1link.simulation.process_supervisor import (
OwnedProcess,
PosixProcessSupervisor,
ProcessSpec,
ProcessStopResult,
ProcessSupervisorError,
)
from k1link.simulation.provider_contract import (
PROVIDER_PROFILE_SCHEMA,
ProviderRole,
SimulationClockDescriptor,
SimulationProviderContractError,
SimulationProviderDescriptor,
SimulationProviderProfile,
)
from k1link.simulation.run_store import (
QualificationRunConflictError,
QualificationRunIntegrityError,
QualificationRunNotFoundError,
QualificationRunStore,
QualificationRunStoreError,
QualificationRunTransitionError,
)
from k1link.simulation.s0 import (
CheckStatus,
DoctorVerdict,
RuntimeAcceptance,
S0DoctorReport,
S0Profile,
S0ProfileError,
load_s0_profile,
run_s0_doctor,
)
from k1link.simulation.stock_rover import (
LIFECYCLE_PROFILE_SCHEMA,
StockRoverLifecycleProfile,
StockRoverProfileError,
StockRoverTargetPaths,
load_stock_rover_lifecycle_profile,
stock_rover_process_environment,
stock_rover_process_specs,
)
from k1link.simulation.worker import (
LocalProcessWorkerAdapter,
S0WorkerGuard,
SimulationWorldControl,
WorkerAdmission,
WorkerAdmissionError,
)
__all__ = [
"AckermannControlSetpoint",
@@ -133,3 +141,76 @@ __all__ = [
"stock_rover_process_environment",
"stock_rover_process_specs",
]
_EXPORTS = {
"AckermannControlSetpoint": "k1link.simulation.contracts",
"AuthorityProfile": "k1link.simulation.contracts",
"CommandAuthorityScope": "k1link.simulation.contracts",
"ControlProfile": "k1link.simulation.contracts",
"ControlSetpoint": "k1link.simulation.contracts",
"DifferentialControlSetpoint": "k1link.simulation.contracts",
"ProviderPin": "k1link.simulation.contracts",
"QualificationArtifact": "k1link.simulation.contracts",
"QualificationEvent": "k1link.simulation.contracts",
"QualificationRun": "k1link.simulation.contracts",
"ReproducibilityTier": "k1link.simulation.contracts",
"RunKind": "k1link.simulation.contracts",
"RunState": "k1link.simulation.contracts",
"SimulationContractError": "k1link.simulation.contracts",
"ActiveQualificationRunError": "k1link.simulation.orchestrator",
"SimulationApplicationService": "k1link.simulation.orchestrator",
"SimulationOrchestratorError": "k1link.simulation.orchestrator",
"SimulationWorkerPort": "k1link.simulation.orchestrator",
"WorkerStartResult": "k1link.simulation.orchestrator",
"WorkerStopResult": "k1link.simulation.orchestrator",
"OwnedProcess": "k1link.simulation.process_supervisor",
"PosixProcessSupervisor": "k1link.simulation.process_supervisor",
"ProcessSpec": "k1link.simulation.process_supervisor",
"ProcessStopResult": "k1link.simulation.process_supervisor",
"ProcessSupervisorError": "k1link.simulation.process_supervisor",
"PROVIDER_PROFILE_SCHEMA": "k1link.simulation.provider_contract",
"ProviderRole": "k1link.simulation.provider_contract",
"SimulationClockDescriptor": "k1link.simulation.provider_contract",
"SimulationProviderContractError": "k1link.simulation.provider_contract",
"SimulationProviderDescriptor": "k1link.simulation.provider_contract",
"SimulationProviderProfile": "k1link.simulation.provider_contract",
"QualificationRunConflictError": "k1link.simulation.run_store",
"QualificationRunIntegrityError": "k1link.simulation.run_store",
"QualificationRunNotFoundError": "k1link.simulation.run_store",
"QualificationRunStore": "k1link.simulation.run_store",
"QualificationRunStoreError": "k1link.simulation.run_store",
"QualificationRunTransitionError": "k1link.simulation.run_store",
"CheckStatus": "k1link.simulation.s0",
"DoctorVerdict": "k1link.simulation.s0",
"RuntimeAcceptance": "k1link.simulation.s0",
"S0DoctorReport": "k1link.simulation.s0",
"S0Profile": "k1link.simulation.s0",
"S0ProfileError": "k1link.simulation.s0",
"load_s0_profile": "k1link.simulation.s0",
"run_s0_doctor": "k1link.simulation.s0",
"LIFECYCLE_PROFILE_SCHEMA": "k1link.simulation.stock_rover",
"StockRoverLifecycleProfile": "k1link.simulation.stock_rover",
"StockRoverProfileError": "k1link.simulation.stock_rover",
"StockRoverTargetPaths": "k1link.simulation.stock_rover",
"load_stock_rover_lifecycle_profile": "k1link.simulation.stock_rover",
"stock_rover_process_environment": "k1link.simulation.stock_rover",
"stock_rover_process_specs": "k1link.simulation.stock_rover",
"LocalProcessWorkerAdapter": "k1link.simulation.worker",
"S0WorkerGuard": "k1link.simulation.worker",
"SimulationWorldControl": "k1link.simulation.worker",
"WorkerAdmission": "k1link.simulation.worker",
"WorkerAdmissionError": "k1link.simulation.worker",
}
def __getattr__(name: str):
module = _EXPORTS.get(name)
if module is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(import_module(module), name)
globals()[name] = value
return value
def __dir__():
return sorted(set(globals()) | set(__all__))
@@ -0,0 +1 @@
"""Independent, virtual-only AI polygon; never routes assets through the LCC pipeline."""
@@ -0,0 +1,194 @@
"""Simulation providers use the same immutable typed composition as AI Inference.
Pinhole camera contracts are intentionally distinct from recorded K1/KB4 ports.
Only installed adapters are executable; selection JSON never carries code.
"""
import hashlib
import json
from pathlib import Path
from k1link.observatory.modular_composition import (
COMPOSITION_SCHEMA,
CompositionError,
ModuleRegistry,
ModuleSpec,
canonical_bytes,
)
def digest(value):
return hashlib.sha256(canonical_bytes(value)).hexdigest()
def registry(adapter_root: Path) -> ModuleRegistry:
profile = json.loads((adapter_root / "models.worker-006.json").read_text())
models = {m["id"]: m for m in profile["models"]}
implementation = hashlib.sha256(
Path(__file__).with_name("inference.py").read_bytes()
).hexdigest()
ddr, detector = models["ddrnet-goose-pytorch-reference"], models["rf_detr_large"]
nav = profile["navigation"]
ade = models["segformer-b2-ade150"]
return ModuleRegistry(
(
ModuleSpec(
"simulation-ddrnet-goose",
"DDRNet · GOOSE 64",
"segmentation",
ddr["image"].removeprefix("sha256:"),
implementation,
ddr["checkpoint_sha256"],
digest(
{
"camera": profile["camera"],
"preprocess": ddr["preprocess"],
"labels": profile["labels_sha256"],
}
),
("source.camera.rgb",),
("segmentation.labels", "segmentation.surface"),
),
ModuleSpec(
"simulation-segformer-ade",
"SegFormer · природные поверхности",
"segmentation",
ade["image"].removeprefix("sha256:"),
digest(
{
"client": implementation,
"server": hashlib.sha256(
(adapter_root / "segformer/server.py").read_bytes()
).hexdigest(),
}
),
ade["checkpoint_sha256"],
digest(
{
"camera": profile["camera"],
"preprocess": ade["preprocess"],
"config": ade["config_sha256"],
"processor": ade["processor_sha256"],
"output": "ade150-labels-and-bool-surface-candidate-square512-v1",
}
),
("source.camera.rgb",),
("segmentation.labels", "segmentation.surface"),
),
ModuleSpec(
"simulation-rf-detr",
"RF-DETR · люди и животные",
"detection",
detector["image"].removeprefix("sha256:"),
implementation,
detector["checkpoint_sha256"],
digest(
{
"camera": profile["camera"],
"preprocess": detector["preprocess"],
"output": "normalized-xyxy-risk-boxes-v1",
}
),
("source.camera.rgb",),
("detection.boxes",),
),
ModuleSpec(
"simulation-waypoint-mission",
"Маршрут · контроль продвижения",
"policy",
nav["image"].removeprefix("sha256:"),
digest(
{
"policy": hashlib.sha256(
Path(__file__).with_name("mission_policy.py").read_bytes()
).hexdigest(),
"adapter": hashlib.sha256(
(adapter_root / "navigation_client.py").read_bytes()
).hexdigest(),
}
),
None,
digest(
{
"task": "operator-metric-waypoints-v1",
"recovery": "three-observed-reverse-and-replan-attempts-v2",
}
),
(
"segmentation.surface",
"source.camera.calibration",
"source.lidar",
"source.pose",
"source.simulation-time",
),
("navigation.goal", "navigation.intent"),
state_policy="causal-reset-at-source-start",
),
ModuleSpec(
"simulation-cmu-navigation",
"CMU · рельеф и движение",
"motion",
nav["image"].removeprefix("sha256:"),
digest(
{
p.relative_to(adapter_root).as_posix(): hashlib.sha256(
p.read_bytes()
).hexdigest()
for p in (
adapter_root / "navigation/server.py",
adapter_root / "navigation/footprint.py",
adapter_root / "navigation/terrain_costs.py",
adapter_root / "navigation/terrain_connectivity.cpp",
adapter_root / "navigation/fastdds.xml",
adapter_root / "navigation_client.py",
)
}
),
None,
digest(
{
"upstream": nav["upstream_commit"],
"footprint": [1, 1],
"inputs": "metric-occluded-range-and-rgb",
"command": "signed-mps-rps-observed-recovery-v2",
}
),
(
"detection.boxes",
"navigation.goal",
"navigation.intent",
"segmentation.surface",
"source.camera.calibration",
"source.lidar",
"source.pose",
),
("motion.command", "motion.path"),
state_policy="causal-reset-at-source-start",
),
)
)
def compose(adapter_root: Path, selection=None):
installed = registry(adapter_root)
if selection is None:
defaults = json.loads((adapter_root / "models.worker-006.json").read_text())[
"default_modules"
]
selection = {
"schema_version": COMPOSITION_SCHEMA,
"selections": [
{
"group": m.group,
"module_id": m.module_id,
"module_sha256": m.sha256,
"parameters": {},
}
for m in installed.modules
if m.module_id in defaults
],
}
result = installed.compose(selection, execution_mode="worker-local-simulation")
if "motion.command" not in result.outputs:
raise CompositionError("Для движения выберите модуль навигации и его зависимости.")
return result
@@ -0,0 +1,194 @@
"""Versioned scene preparation and camera-driven run contracts."""
from ipaddress import ip_address, ip_network
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
class Contract(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False, str_strip_whitespace=True)
class WorldCreate(Contract):
name: str = Field(min_length=1, max_length=120)
filename: str = Field(pattern=r"^[^/\\\x00-\x1f]{1,200}\.[pP][lL][yY]$")
byte_length: int = Field(gt=0, le=8 * 1024**3, strict=True)
author: str = Field(min_length=1, max_length=160)
license: str = Field(min_length=1, max_length=160)
source_url: HttpUrl | None = None
class WorldSettings(Contract):
# Source -> metric Z-up world; collision proxy is prepared separately.
meters_per_unit: float = Field(default=1, ge=0.0001, le=1000)
rotation_degrees: tuple[
Annotated[float, Field(ge=-360, le=360)],
Annotated[float, Field(ge=-360, le=360)],
Annotated[float, Field(ge=-360, le=360)],
] = (0, 0, 0)
ground_z: float = Field(default=0, ge=-10000, le=10000)
spawn_xy: tuple[
Annotated[float, Field(ge=-10000, le=10000)], Annotated[float, Field(ge=-10000, le=10000)]
] = (0, 0)
heading_degrees: float = Field(default=0, ge=-360, le=360)
camera_height_m: float = Field(default=0.5, ge=0.1, le=3)
max_speed_mps: float = Field(default=0.3, ge=0.05, le=1)
prepared: bool = False
route_xy: list[
tuple[
Annotated[float, Field(ge=-10000, le=10000)],
Annotated[float, Field(ge=-10000, le=10000)],
]
] = Field(default_factory=list, max_length=32)
class RunCreate(Contract):
world_id: str = Field(pattern=r"^aiworld-[a-f0-9]{32}$")
max_steps: int = Field(default=600, ge=1, le=3600, strict=True)
clock: Literal["lockstep", "realtime"] = "lockstep"
start_paused: bool = False
duration_seconds: int = Field(default=1800, ge=10, le=7200, strict=True)
composition: dict | None = None
class WorkerWorldCreate(WorldCreate):
"""Attestation from the connected Worker after validating its local files."""
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
collider_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
splat_count: int = Field(ge=1, le=20_000_000, strict=True)
settings: WorldSettings
class StreamEndpoint(Contract):
server: str
signaling_port: Literal[49100] = 49100
media_port: Literal[47998] = 47998
width: Literal[1280] = 1280
height: Literal[720] = 720
fps: Literal[30] = 30
@field_validator("server")
@classmethod
def private_address(cls, value: str) -> str:
address = ip_address(value)
if (
address.version != 4
or not (address.is_private or address in ip_network("100.64.0.0/10"))
or address.is_unspecified
or address.is_multicast
):
raise ValueError("Streaming requires a private IPv4 address")
return str(address)
class WorkerHello(Contract):
worker_id: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,80}$")
instance_id: str = Field(pattern=r"^[a-f0-9]{32}$")
runtime: Literal["isaac-sim-6.1"]
model_ids: list[Annotated[str, Field(min_length=1, max_length=100)]] = Field(
min_length=1, max_length=8
)
runtime_sources: dict[
Literal["worker", "scene", "models", "robot"],
Annotated[str, Field(pattern=r"^[a-f0-9]{64}$")],
] = Field(min_length=4, max_length=4)
profile_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
execution_modes: list[Literal["lockstep", "realtime"]] = ["lockstep"]
stream: StreamEndpoint | None = None
class Decision(Contract):
speed_mps: float = Field(ge=-1, le=1)
yaw_rate_rps: float = Field(ge=-1, le=1)
reason: Literal[
"road",
"obstacle",
"no-road",
"uncertain",
"inference-error",
"replanning",
"stuck",
"goal-reached",
"unstable",
"waiting",
]
road_fraction: float = Field(ge=0, le=1)
obstacle_count: int = Field(ge=0, le=300, strict=True)
class RunSample(Contract):
sequence: int = Field(ge=0, le=3600, strict=True)
simulation_time_ns: int = Field(ge=0, strict=True)
inference_ms: float = Field(ge=0, le=120000)
# The snapshot is an observation BEFORE the action below is applied.
pose_xy: tuple[float, float]
decision: Decision
image_jpeg_base64: str = Field(max_length=2 * 1024 * 1024)
class WorkerPoll(Contract):
instance_id: str = Field(pattern=r"^[a-f0-9]{32}$")
run_id: str | None = Field(default=None, pattern=r"^airun-[a-f0-9]{32}$")
class RunProgress(Contract):
phase: Literal["world", "models", "scene"]
class RunApplied(Contract):
sequence: int = Field(ge=0, le=3600, strict=True)
simulation_time_ns: int = Field(ge=0, strict=True)
physics_steps: Literal[6]
pose_xy: tuple[float, float]
pose_yaw: float | None = None
cycle_ms: float | None = Field(default=None, ge=0, le=120000)
render_ms: float | None = Field(default=None, ge=0, le=120000)
transport_ms: float | None = Field(default=None, ge=0, le=120000)
class WorkerResult(Contract):
instance_id: str = Field(pattern=r"^[a-f0-9]{32}$")
outcome: Literal["completed", "stopped", "failed"]
message: str = Field(default="", max_length=500)
resources_released: Literal[True]
class ViewControl(Contract):
camera: Literal["follow", "overview", "camera"]
class RealtimeSnapshot(Contract):
"""Bounded observation of Worker-owned state; never a physics-step receipt."""
sequence: int = Field(ge=0, strict=True)
control_sequence: int = Field(ge=0, strict=True)
state: Literal["ready", "running", "paused", "stopping"]
phase: Literal["scene", "models", "running"]
simulation_time_ns: int = Field(ge=0, strict=True)
wall_elapsed_seconds: float = Field(ge=0)
physics_steps: int = Field(ge=0, strict=True)
render_frames: int = Field(ge=0, strict=True)
sensor_frames: int = Field(ge=0, strict=True)
inference_count: int = Field(ge=0, strict=True)
dropped_frames: int = Field(ge=0, strict=True)
rtf: float = Field(ge=0, le=100)
render_fps: float = Field(ge=0, le=1000)
sensor_fps: float = Field(ge=0, le=1000)
ai_hz: float = Field(ge=0, le=1000)
inference_ms: float | None = Field(default=None, ge=0)
frame_age_ms: float | None = Field(default=None, ge=0)
command_age_ms: float | None = Field(default=None, ge=0)
pose_xy: tuple[float, float]
pose_yaw: float
speed_mps: float
applied_speed_mps: float = Field(ge=-1, le=1)
applied_yaw_rate_rps: float = Field(ge=-1, le=1)
stop_reason: Literal[
"none", "paused", "stale-camera", "stale-command", "inference-error", "unstable"
]
decision: Decision | None = None
ai_ready: bool
stream_ready: bool
camera: Literal["follow", "overview", "camera"]
@@ -0,0 +1,158 @@
"""Adapters for the pinned reference DDRNet and RF-DETR model contracts.
The simulation RGB pinhole profile is separate from the device's KB4 profile.
No device valid-FOV mask or recorded LiDAR calibration is applied to simulator RGB.
"""
import csv
import http.client
from pathlib import Path
from urllib.parse import urlsplit
import numpy as np
from PIL import Image
from k1link.perception.rf_detr_object_detector import (
COCO_SPARSE_TO_CONTIGUOUS,
RISK_CLASS_IDS,
TritonRfDetrHttpInferenceBackend,
preprocess_raw_kb4_rf_detr,
)
MODEL_IDS = ["ddrnet-goose-pytorch-reference", "rf_detr_large"]
GOOSE_SURFACE_IDS = (3, 5, 7, 9, 11, 18, 21, 23, 24, 31, 40, 50, 62)
def local_endpoint(endpoint: str):
parsed = urlsplit(endpoint)
if (
parsed.scheme != "http"
or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
or parsed.username
):
raise ValueError("Use a local Triton endpoint or a loopback tunnel")
return parsed
class PillowResizer:
def resize(self, image, width, height):
return np.asarray(Image.fromarray(image).resize((width, height), Image.Resampling.BILINEAR))
class ModelInference:
def __init__(
self,
endpoint: str,
goose_labels: Path,
ddrnet_endpoint: str,
*,
segmenter_id: str = "simulation-ddrnet-goose",
):
if segmenter_id not in {"simulation-ddrnet-goose", "simulation-segformer-ade"}:
raise ValueError("Uninstalled surface provider")
self.segmenter_id = segmenter_id
parsed = local_endpoint(ddrnet_endpoint)
local_endpoint(endpoint)
with goose_labels.open(newline="") as stream:
labels = {int(row["label_key"]): row["class_name"] for row in csv.DictReader(stream)}
if set(labels) != set(range(64)):
raise ValueError("Expected the existing GOOSE fine-64 label table")
self.road_ids = [
i
for i, name in labels.items()
if name in {"asphalt", "bikeway", "cobble", "sidewalk", "gravel", "soil"}
]
if len(self.road_ids) != 6:
raise ValueError("GOOSE road label mapping is incomplete")
self.connection = http.client.HTTPConnection(
parsed.hostname, parsed.port or 8000, timeout=10
)
self.detector = TritonRfDetrHttpInferenceBackend(endpoint, timeout_seconds=10)
self.detector_url = local_endpoint(endpoint)
def ready(self):
self.connection.request("GET", "/ready")
response = self.connection.getresponse()
response.read(65536)
if response.status != 200:
raise RuntimeError("Selected surface provider is unavailable")
connection = http.client.HTTPConnection(
self.detector_url.hostname, self.detector_url.port or 8000, timeout=10
)
try:
connection.request("GET", "/v2/models/rf_detr_large/versions/1/ready")
response = connection.getresponse()
response.read(65536)
if response.status != 200:
raise RuntimeError("RF-DETR is unavailable")
finally:
connection.close()
def segment(self, rgb: np.ndarray):
return self.surface(rgb)["segmentation.labels"]
def surface(self, rgb: np.ndarray):
if rgb.shape != (600, 800, 3) or rgb.dtype != np.uint8:
raise ValueError("Expected an 800x600 RGB simulation camera")
self.connection.request(
"POST",
"/infer",
body=np.ascontiguousarray(rgb).tobytes(),
headers={"Content-Type": "application/octet-stream"},
)
response = self.connection.getresponse()
ade = self.segmenter_id == "simulation-segformer-ade"
expected = 512**2 * (2 if ade else 1)
raw = response.read(expected + 1)
if response.status != 200 or len(raw) != expected:
raise RuntimeError("Surface provider response changed")
mask = np.frombuffer(raw[: 512**2], dtype=np.uint8).reshape(512, 512)
if np.any(mask >= (150 if ade else 64)):
raise RuntimeError("Surface label taxonomy changed")
if ade:
candidate = np.frombuffer(raw[512**2 :], dtype=np.uint8).reshape(512, 512)
if np.any(candidate > 1):
raise RuntimeError("Surface candidate raster changed")
candidate = candidate.astype(bool)
else:
candidate = np.isin(mask, GOOSE_SURFACE_IDS)
return {"segmentation.labels": mask.copy(), "segmentation.surface": candidate}
def detect(self, rgb: np.ndarray):
if rgb.shape != (600, 800, 3) or rgb.dtype != np.uint8:
raise ValueError("Expected an 800x600 RGB simulation camera")
bgr = np.ascontiguousarray(rgb[:, :, ::-1])
detector_tensor = preprocess_raw_kb4_rf_detr(
bgr, np.ones((600, 800), dtype=bool), resizer=PillowResizer()
)
output = self.detector.infer(detector_tensor)
if (
output.boxes.shape != (1, 300, 4)
or output.logits.shape != (1, 300, 91)
or not np.isfinite(output.boxes).all()
or not np.isfinite(output.logits).all()
):
raise RuntimeError("RF-DETR output contract changed")
probabilities = 1 / (1 + np.exp(-np.clip(output.logits[0].astype(np.float32), -80, 80)))
classes = [
key for key, value in COCO_SPARSE_TO_CONTIGUOUS.items() if value in RISK_CLASS_IDS
]
scores = probabilities[:, classes].max(axis=1)
boxes = []
for x, y, width, height in output.boxes[0][scores >= 0.25]:
if width <= 0 or height <= 0:
continue
# Unlike the recorded diagnostic filter, never discard a very large near obstacle.
box = np.clip([x - width / 2, y - height / 2, x + width / 2, y + height / 2], 0, 1)
boxes.append(tuple(float(value) for value in box))
return boxes
def infer(self, rgb: np.ndarray):
return np.isin(self.segment(rgb), self.road_ids), self.detect(rgb)
def close(self):
self.connection.close()
self.detector.close()
@@ -0,0 +1,116 @@
"""Bounded waypoint mission and progress recovery, driven only by observations.
Operator waypoints specify the task. They are not a collision map or a motion
trajectory. CMU retains authority to reject every requested local waypoint.
"""
import math
import numpy as np
def inclination(pose):
x, y = pose[3:5]
return math.degrees(math.acos(max(-1, min(1, 1 - 2 * (x * x + y * y)))))
class WaypointMission:
def __init__(self, route):
self.route = [list(p) for p in route]
self.index = self.attempts = 0
self.anchor = self.anchor_time = None
self.last_time = None
self.state = "following"
self.failed_goals = []
self.goal = None
self.fault = None
self.best_distance = None
self.recovery_goal = None
self.recovery_started = None
def resume(self):
# Pause freezes the world clock. Preserve the mission cursor and any
# latched failure. The paused world has not moved: retain the observed
# goal entering the camera blind strip, but revalidate it before motion.
self.anchor = self.anchor_time = None
def update(self, pose, seconds, choose_goal, choose_recovery=None):
xy = np.asarray(pose[:2])
if self.last_time is not None and seconds < self.last_time:
raise ValueError("Mission observations must not rewind")
self.last_time = seconds
if inclination(pose) >= 30:
self.fault = "unstable"
if self.fault:
return None, self.intent(self.fault)
if self.route and np.linalg.norm(xy - self.route[self.index]) <= 0.4:
if self.index == len(self.route) - 1:
self.fault = "goal-reached"
return None, self.intent(self.fault)
self.index += 1
self.anchor = self.goal = None
self.best_distance = None
self.recovery_goal = None
self.attempts = 0
target = self.route[self.index] if self.route else None
if self.anchor is None:
self.anchor, self.anchor_time = xy.copy(), seconds
distance = float(np.linalg.norm(xy - target)) if target is not None else None
if self.best_distance is None:
self.best_distance = distance
progress = self.best_distance - distance if target is not None else 0.0
if target is None and self.goal is not None:
direction = np.asarray(self.goal[:2]) - self.anchor
progress = float(
np.dot(xy - self.anchor, direction) / max(np.linalg.norm(direction), 0.1)
)
if self.recovery_goal is not None:
remaining = np.linalg.norm(xy - self.recovery_goal[:2])
observed = choose_recovery(self.recovery_goal) if choose_recovery is not None else None
if remaining > 0.3 and seconds - self.recovery_started < 6 and observed is not None:
return self.recovery_goal, self.intent("reversing")
# Recovery is an attempt to escape, never route progress. The next
# forward attempt must beat the previous best distance to the task.
self.recovery_goal = self.goal = None
self.anchor_time = seconds
return None, self.intent("replanning")
if progress >= 0.1:
# A reverse/forward cycle cannot replenish the recovery budget.
self.anchor, self.anchor_time = xy.copy(), seconds
self.best_distance = distance
self.attempts = 0
self.failed_goals.clear()
stalled = seconds - self.anchor_time >= 8
if stalled:
self.attempts += 1
if self.goal is not None:
self.failed_goals.append(self.goal)
self.goal = None
self.anchor_time = seconds
if self.attempts > 3:
self.fault = "stuck"
return None, self.intent("stuck")
self.state = "replanning"
if choose_recovery is not None:
self.recovery_goal = choose_recovery(None)
if self.recovery_goal is not None:
self.recovery_started = seconds
return self.recovery_goal, self.intent("reversing")
observed = choose_goal(target, self.goal, self.failed_goals)
if observed is None:
# A rejected frame stops motion, not causal memory. Forgetting the
# last observed exact waypoint strands it behind the near camera
# boundary on the next frame. choose_goal must revalidate visible
# support, and live range/collision checks retain final authority.
return None, self.intent("no-road")
self.goal = observed
self.state = "replanning" if self.attempts else "following"
return self.goal, self.intent(self.state)
def intent(self, state):
return dict(
state=state,
waypoint=self.index,
waypoint_count=len(self.route),
recovery_attempt=self.attempts,
)
@@ -0,0 +1,79 @@
"""Camera-only laboratory road follower. No scene truth or actor coordinates enter here.
This deliberately small baseline measures model-driven following/braking. It is
not a route planner, metric obstacle-distance estimator, or field safety policy.
"""
import numpy as np
from k1link.simulation.ai_polygon.contracts import Decision
class RoadPolicy:
def __init__(self, max_speed_mps: float):
if not 0 < max_speed_mps <= 1:
raise ValueError("invalid laboratory speed")
self.max_speed = max_speed_mps
self.clear_frames = 0
def reset(self):
self.clear_frames = 0
def decide(
self, road_mask: np.ndarray, boxes: list[tuple[float, float, float, float]]
) -> Decision:
if road_mask.shape != (512, 512) or road_mask.dtype != np.bool_:
raise ValueError("road mask must be the model's 512x512 boolean raster")
# Detector boxes are normalized in the full RGB camera, not the DDRNet crop.
if any(
not all(np.isfinite(box))
or not (0 <= box[0] <= box[2] <= 1 and 0 <= box[1] <= box[3] <= 1)
for box in boxes
):
raise ValueError("invalid observation box")
obstacles = sum(x1 < 0.68 and x2 > 0.32 and y2 > 0.55 for x1, _, x2, y2 in boxes)
near_road = road_mask[320:500, 100:412]
fraction = float(near_road.mean())
stop = "obstacle" if obstacles else "no-road" if fraction < 0.35 else None
if stop:
self.clear_frames = 0
return Decision(
speed_mps=0,
yaw_rate_rps=0,
reason=stop,
road_fraction=fraction,
obstacle_count=obstacles,
)
self.clear_frames += 1
if self.clear_frames < 3:
return Decision(
speed_mps=0,
yaw_rate_rps=0,
reason="uncertain",
road_fraction=fraction,
obstacle_count=0,
)
# Choose a contiguous visible corridor, rather than averaging two disconnected roads.
columns = near_road.mean(axis=0) >= 0.6
edges = np.flatnonzero(np.diff(np.r_[False, columns, False].astype(np.int8)))
spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b - a >= 48]
if not spans:
self.clear_frames = 0
return Decision(
speed_mps=0,
yaw_rate_rps=0,
reason="uncertain",
road_fraction=fraction,
obstacle_count=0,
)
left, right = min(spans, key=lambda span: abs((span[0] + span[1]) / 2 - 156))
center = (left + right) / 2
yaw = float(np.clip((156 - center) / 156, -0.6, 0.6))
speed = self.max_speed * min(1, fraction / 0.65) * (1 - abs(yaw))
return Decision(
speed_mps=float(speed),
yaw_rate_rps=yaw,
reason="road",
road_fraction=fraction,
obstacle_count=0,
)
+412
View File
@@ -0,0 +1,412 @@
"""Single-worker laboratory lifecycle and durable observation/decision journal."""
import base64
import hashlib
import io
import json
import os
import re
import secrets
import shutil
import stat
import threading
import time
from pathlib import Path
from uuid import uuid4
from PIL import Image
from k1link.artifacts import utc_now_iso
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
from k1link.simulation.ai_polygon.contracts import (
RealtimeSnapshot,
RunApplied,
RunCreate,
RunSample,
WorkerHello,
)
from k1link.simulation.ai_polygon.worlds import WorldStore, write_json
RUN_ID = re.compile(r"^airun-[a-f0-9]{32}$")
TERMINAL = {"completed", "stopped", "failed"}
WORKER_LEASE_SECONDS = 20
class RunStore:
def __init__(self, worlds: WorldStore, queue: ObservatoryRecordedJobQueue | None = None):
self.worlds = worlds
self.queue = queue
self.root = worlds.root.parent / "runs"
self.root.mkdir(mode=0o700, exist_ok=True)
self.lock = threading.RLock()
self.worker: dict | None = None
self.seen = 0.0
self.active: str | None = None
self.token_path = self.root.parent / "worker.token"
if not self.token_path.exists():
try:
fd = os.open(self.token_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, "w") as stream:
stream.write(secrets.token_urlsafe(48))
except FileExistsError:
pass
metadata = self.token_path.lstat()
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077:
raise ValueError("AI polygon worker token must be a private regular file")
with os.fdopen(os.open(self.token_path, os.O_RDONLY | os.O_NOFOLLOW), "r") as stream:
self.token = stream.read(513).strip()
if not 32 <= len(self.token) <= 512:
raise ValueError("AI polygon worker token is invalid")
# Keep realtime ownership uncertain until the same Worker reconciles it.
# A Core restart never proves that the remote GPU/process stopped.
for row in self.list():
if row["state"] not in TERMINAL:
if row.get("clock") == "realtime":
if self.active is not None:
raise RuntimeError("Multiple unreconciled simulation owners")
self.active = row["run_id"]
row.update(state="disconnected", message="Ожидаем состояние Worker.")
else:
row.update(state="failed", message="Связь с симуляцией прервана перезапуском.")
self._save(row)
def directory(self, run_id: str) -> Path:
if not RUN_ID.fullmatch(run_id):
raise FileNotFoundError(run_id)
path = self.root / run_id
if not path.is_dir() or path.is_symlink():
raise FileNotFoundError(run_id)
return path
def get(self, run_id: str) -> dict:
return json.loads((self.directory(run_id) / "run.json").read_text())
def list(self) -> list[dict]:
return sorted(
[
self.get(p.name)
for p in self.root.iterdir()
if RUN_ID.fullmatch(p.name) and p.is_dir() and not p.is_symlink()
],
key=lambda item: item["created_at"],
reverse=True,
)
def _save(self, doc: dict) -> None:
write_json(self.directory(doc["run_id"]) / "run.json", doc)
def _expire(self) -> None:
if self.worker is not None and time.monotonic() - self.seen > WORKER_LEASE_SECONDS:
if self.active:
row = self.get(self.active)
if row.get("clock") == "realtime":
row.update(
state="disconnected",
message="Связь с Worker потеряна. Состояние уточняется.",
)
else:
row.update(state="failed", message="Worker потерял связь. Прогон остановлен.")
self.active = None
self._save(row)
self.worker = None
def status(self) -> dict:
with self.lock:
self._expire()
return {
"available": self.worker is not None,
"worker": self.worker,
"active_run": self.get(self.active) if self.active else None,
}
def register(self, hello: WorkerHello) -> dict:
with self.lock:
self._expire()
if self.worker and self.worker["instance_id"] != hello.instance_id:
raise RuntimeError("Другой Worker уже подключён.")
if self.active and self.get(self.active)["worker"] != hello.model_dump():
raise RuntimeError("Нельзя менять модели во время прогона.")
self.worker = hello.model_dump()
self.seen = time.monotonic()
return {"registered": True}
def heartbeat(self, instance_id: str) -> dict:
with self.lock:
self._require_worker(instance_id)
self.seen = time.monotonic()
return {"alive": True}
def _require_worker(self, instance_id: str) -> None:
self._expire()
if self.worker is None or self.worker["instance_id"] != instance_id:
raise RuntimeError("Сессия Worker истекла; требуется переподключение.")
def start(self, request: RunCreate, request_id: str) -> dict:
if not re.fullmatch(r"[a-zA-Z0-9_-]{8,100}", request_id):
raise ValueError("Требуется уникальный идентификатор запуска.")
with self.lock:
self._expire()
for old in self.list():
if old["request_id"] == request_id:
if old["request"] != request.model_dump():
raise RuntimeError("Идентификатор запуска уже использован.")
return old
if self.worker is None:
raise RuntimeError("Подключите Worker с симуляцией и моделями inference.")
if request.clock not in self.worker.get("execution_modes", ["lockstep"]):
raise RuntimeError("Worker не поддерживает этот режим симуляции.")
if request.clock == "realtime" and not self.worker.get("stream"):
raise RuntimeError("Видеопоток Worker не настроен.")
if self.queue is None:
raise RuntimeError("Контроль занятости Worker недоступен.")
if self.active is not None:
raise RuntimeError("Сначала завершите текущий прогон.")
world = self.worlds.get(request.world_id)
storage = world.get("storage", {})
if storage.get("kind") == "worker" and (
storage["worker_id"] != self.worker["worker_id"] or request.clock != "realtime"
):
raise RuntimeError("Локация доступна только на подготовленном Worker.")
if world["status"] != "available" or not world["settings"]["prepared"]:
raise RuntimeError("Сначала проверьте масштаб, грунт и старт ровера.")
run_id = f"airun-{uuid4().hex}"
if (
request.clock == "lockstep"
and shutil.disk_usage(self.root).free < request.max_steps * 1024**2 + 512 * 1024**2
):
raise ValueError("Недостаточно места для кадров прогона.")
(self.root / run_id).mkdir(mode=0o700)
if request.clock == "lockstep":
(self.root / run_id / "frames").mkdir(mode=0o700)
row = {
"schema_version": "missioncore.ai-polygon-run/v1",
"run_id": run_id,
"request_id": request_id,
"request": request.model_dump(),
"created_at": utc_now_iso(),
"world": world,
"worker": dict(self.worker),
"state": "starting",
"control": "pause" if request.start_paused else "play",
"control_sequence": 0,
"camera": "follow",
"telemetry": None,
"step_budget": 0,
"samples": 0,
"applied_steps": 0,
"last_applied": None,
"last_sample": None,
"message": None,
"phase": "world",
"authority": "virtual-only",
"clock": request.clock,
"step_ns": 100_000_000,
}
self._save(row)
try:
self.queue.reserve_simulation(run_id)
except RuntimeError:
row.update(state="failed", message="Worker занят другой задачей.")
self._save(row)
raise
self.active = run_id
return row
def control(self, run_id: str, command: str) -> dict:
with self.lock:
self._expire()
row = self.get(run_id)
if row["state"] in TERMINAL:
return row
if command not in {"pause", "play", "step", "stop"}:
raise ValueError("Неизвестная команда симуляции.")
if command == "step" and row.get("clock") == "realtime":
raise RuntimeError("Realtime не допускает пошаговое продвижение времени.")
if command == "step" and row["state"] != "paused":
raise RuntimeError("Один шаг доступен только на паузе.")
if row["control"] == "stop":
return row
if row["control"] == command:
return row
row["control_sequence"] = row.get("control_sequence", 0) + 1
row["control"] = "pause" if command == "step" else command
row["step_budget"] = 1 if command == "step" else 0
# Paused is acknowledged by the Worker, not inferred from this request.
if command == "stop":
row["state"] = "stopping"
self._save(row)
return row
def poll(self, instance_id: str, run_id: str | None) -> dict:
with self.lock:
self._require_worker(instance_id)
self.seen = time.monotonic()
if self.active is None:
return {"run": None, "action": "idle"}
row = self.get(self.active)
if run_id is None:
return {"run": row, "action": "load"}
if run_id != self.active:
raise RuntimeError("Прогон Worker не совпадает с активным.")
action = row["control"]
if row.get("clock") == "realtime":
# Only actual local snapshots can acknowledge pause/play.
return {"run": row, "action": action}
if action == "pause":
if row["step_budget"]:
row["step_budget"] = 0
action = "step"
else:
row["state"] = "paused"
elif action == "play" and row["samples"]:
row["state"] = "running"
self._save(row)
return {"run": row, "action": action}
def progress(self, run_id: str, instance_id: str, phase: str) -> dict:
with self.lock:
self._require_worker(instance_id)
if run_id != self.active:
raise RuntimeError("Прогон уже завершён.")
row = self.get(run_id)
if row["state"] == "starting":
row["phase"] = phase
self._save(row)
return {"control": row["control"]}
def view(self, run_id: str, camera: str) -> dict:
with self.lock:
row = self.get(run_id)
if run_id != self.active or row.get("clock") != "realtime":
raise RuntimeError("Симуляция не запущена.")
if camera not in {"follow", "overview", "camera"}:
raise ValueError("Неизвестная камера.")
row["camera"] = camera
self._save(row)
return row
def snapshot(self, run_id: str, instance_id: str, snapshot: RealtimeSnapshot) -> dict:
with self.lock:
self._require_worker(instance_id)
row = self.get(run_id)
if self.active != run_id or row.get("clock") != "realtime":
raise RuntimeError("Прогон не принадлежит realtime Worker.")
if row["worker"]["instance_id"] != instance_id:
raise RuntimeError("Прогон принадлежит другому Worker.")
old = row.get("telemetry")
if old and snapshot.sequence <= old["sequence"]:
return {
"recorded": old["sequence"]
} # Same snapshot may be retried after reconnect.
if snapshot.control_sequence > row.get("control_sequence", 0):
raise ValueError("Worker подтвердил неизвестную команду.")
if old and snapshot.simulation_time_ns < old["simulation_time_ns"]:
raise ValueError("Время Worker не может идти назад.")
if abs(snapshot.applied_speed_mps) > row["world"]["settings"]["max_speed_mps"]:
raise ValueError("Команда превышает скорость прогона.")
row.update(
telemetry=snapshot.model_dump(),
phase=snapshot.phase,
samples=snapshot.inference_count,
message=None,
)
if row["control"] != "stop":
row["state"] = snapshot.state
row["telemetry_received_at"] = utc_now_iso()
self._save(row)
self.seen = time.monotonic()
return {"recorded": snapshot.sequence}
def sample(self, run_id: str, instance_id: str, sample: RunSample) -> dict:
with self.lock:
self._require_worker(instance_id)
if run_id != self.active:
raise RuntimeError("Прогон уже завершён.")
row = self.get(run_id)
if row.get("clock") != "lockstep":
raise RuntimeError("Realtime кадры хранятся только на Worker.")
if row["control"] == "stop":
raise RuntimeError("Получена команда остановки.")
if sample.sequence != row["samples"] or sample.sequence >= row["request"]["max_steps"]:
raise RuntimeError("Нарушена последовательность кадров.")
if row["samples"] != row["applied_steps"]:
raise RuntimeError("Предыдущий шаг физики не подтверждён.")
if sample.simulation_time_ns != sample.sequence * row["step_ns"]:
raise ValueError("Время кадра не соответствует шагу симуляции.")
if abs(sample.decision.speed_mps) > row["world"]["settings"]["max_speed_mps"]:
raise ValueError("Команда превышает скорость прогона.")
try:
image = base64.b64decode(sample.image_jpeg_base64, validate=True)
if not 4 <= len(image) <= 1024**2:
raise ValueError()
with Image.open(io.BytesIO(image)) as decoded:
if decoded.format != "JPEG" or decoded.size != (800, 600):
raise ValueError()
decoded.verify()
except Exception as exc:
raise ValueError("Неверный кадр камеры.") from exc
directory = self.directory(run_id)
if shutil.disk_usage(directory).free < len(image) + 512 * 1024**2:
raise ValueError("Недостаточно места для кадра прогона.")
filename = f"{sample.sequence:06d}.jpg"
(directory / "frames" / filename).write_bytes(image)
recorded = sample.model_dump(exclude={"image_jpeg_base64"})
recorded.update(image_sha256=hashlib.sha256(image).hexdigest(), image=filename)
with (directory / "decisions.jsonl").open("a", encoding="utf-8") as stream:
stream.write(json.dumps(recorded, allow_nan=False) + "\n")
stream.flush()
os.fsync(stream.fileno())
row.update(samples=row["samples"] + 1, last_sample=recorded, phase="running")
if row["state"] == "starting":
row["state"] = "running"
self._save(row)
return {"recorded": sample.sequence}
def applied(self, run_id: str, instance_id: str, receipt: RunApplied) -> dict:
with self.lock:
self._require_worker(instance_id)
if self.active != run_id:
raise RuntimeError("Прогон уже завершён.")
row = self.get(run_id)
if row.get("clock") != "lockstep":
raise RuntimeError("Realtime не использует подтверждения физических шагов.")
if receipt.sequence != row["applied_steps"] or row["samples"] != receipt.sequence + 1:
raise RuntimeError("Шаг не соответствует решению модели.")
if receipt.simulation_time_ns != (receipt.sequence + 1) * row["step_ns"]:
raise ValueError("Неверное время завершения шага.")
recorded = receipt.model_dump()
with (self.directory(run_id) / "motion.jsonl").open("a", encoding="utf-8") as stream:
stream.write(json.dumps(recorded, allow_nan=False) + "\n")
stream.flush()
os.fsync(stream.fileno())
row.update(applied_steps=row["applied_steps"] + 1, last_applied=recorded)
self._save(row)
return {"applied": receipt.sequence}
def finish(self, run_id: str, instance_id: str, outcome: str, message: str) -> dict:
with self.lock:
self._require_worker(instance_id)
row = self.get(run_id)
if row["worker"]["instance_id"] != instance_id:
raise RuntimeError("Прогон принадлежит другому Worker.")
if row["state"] in TERMINAL:
if self.queue is not None:
self.queue.release_simulation(run_id)
return row
if run_id != self.active:
raise RuntimeError("Прогон уже завершён.")
if (
row.get("clock") == "lockstep"
and outcome == "completed"
and (
row["samples"] != row["request"]["max_steps"]
or row["applied_steps"] != row["samples"]
)
):
raise RuntimeError("Прогон не достиг заданного числа шагов.")
row.update(state=outcome, message=message or None)
self._save(row)
if self.queue is not None:
self.queue.release_simulation(run_id)
self.active = None
return row
@@ -0,0 +1,24 @@
"""Collision identity is independent of episode start, heading and camera."""
def terrain_matches(manifest, world, generator_sha256=None):
if manifest.get("source_sha256") != world["sha256"]:
return False
if generator_sha256 and manifest.get("generator_sha256") != generator_sha256:
return False
old, new = manifest["settings"], world["settings"]
if any(old[key] != new[key] for key in ("meters_per_unit", "rotation_degrees")):
return False
if manifest.get("generator") == "paired-source":
# A supplied full-scene collider is not the generated 30 m tile below.
# Source/calibration and collider hashes still bind this asset; actual
# support and full-body clearance are checked at the new start by Isaac.
return manifest.get("collider_sha256") == world.get("collider_sha256") and bool(
manifest.get("collider_sha256")
)
# The versioned preparer captures a 30x30 m tile and a 12 m vertical band.
# Admit starts only within its interior; physical support is checked later.
return (
all(abs(old["spawn_xy"][i] - new["spawn_xy"][i]) <= 12 for i in (0, 1))
and abs(old["ground_z"] - new["ground_z"]) <= 2
)
+256
View File
@@ -0,0 +1,256 @@
"""Durable, resumable Gaussian asset admission, independent of XGRIDS sources."""
import hashlib
import json
import os
import re
import shutil
import threading
from pathlib import Path
from uuid import uuid4
import numpy as np
from k1link.artifacts import utc_now_iso
from k1link.simulation.ai_polygon.contracts import WorkerWorldCreate, WorldCreate, WorldSettings
CHUNK_BYTES = 4 * 1024**2
WORLD_ID = re.compile(r"^aiworld-[a-f0-9]{32}$")
SOURCES = [
{
"name": "Forest Scan",
"author": "draftmode",
"license": "CC BY 4.0",
"source_url": "https://superspl.at/scene/259c0051",
"description": "Лесная тропа и папоротники",
},
{
"name": "Bamboo Trail",
"author": "luckysplat",
"license": "CC BY 4.0",
"source_url": "https://superspl.at/scene/dd49e9a8",
"description": "Тропа в бамбуковой роще",
},
]
def write_json(path: Path, value: object) -> None:
temporary = path.with_name(f".{path.name}-{uuid4().hex}")
try:
with temporary.open("x", encoding="utf-8") as stream:
os.chmod(temporary, 0o600)
json.dump(value, stream, ensure_ascii=False, allow_nan=False)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def inspect_gaussian_ply(path: Path) -> int:
"""Admit only standard scalar binary 3DGS; do not label meshes as splats."""
with path.open("rb") as stream:
header = bytearray()
while len(header) < 65536:
line = stream.readline(1024)
header.extend(line)
if line.rstrip() == b"end_header":
break
if not line:
raise ValueError("В PLY отсутствует заголовок Gaussian-сцены.")
else:
raise ValueError("Заголовок PLY превышает допустимый размер.")
try:
lines = bytes(header).decode("ascii").splitlines()
except UnicodeDecodeError as exc:
raise ValueError("Некорректный заголовок PLY.") from exc
if lines[:2] != ["ply", "format binary_little_endian 1.0"]:
raise ValueError("Экспортируйте стандартный Gaussian PLY (binary little-endian).")
elements = [line for line in lines if line.startswith("element ")]
if len(elements) != 1 or not elements[0].startswith("element vertex "):
raise ValueError("Нужен Gaussian PLY с одним элементом vertex, без меша.")
count = int(elements[0].split()[-1])
properties = [line.split() for line in lines if line.startswith("property ")]
names = [prop[-1] for prop in properties]
required = {
"x",
"y",
"z",
"opacity",
"f_dc_0",
"f_dc_1",
"f_dc_2",
"scale_0",
"scale_1",
"scale_2",
"rot_0",
"rot_1",
"rot_2",
"rot_3",
}
if (
not 1 <= count <= 20_000_000
or not required.issubset(names)
or len(names) != len(set(names))
or not 14 <= len(properties) <= 128
or any(len(prop) != 3 or prop[1] not in {"float", "float32"} for prop in properties)
):
raise ValueError("PLY не содержит поддерживаемые Gaussian-атрибуты.")
if path.stat().st_size != len(header) + count * len(properties) * 4:
raise ValueError("Размер PLY не соответствует его заголовку.")
with path.open("rb") as stream:
stream.seek(len(header))
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b""):
if not np.isfinite(np.frombuffer(chunk, dtype="<f4")).all():
raise ValueError("PLY содержит нечисловые или бесконечные атрибуты.")
return count
class WorldStore:
def __init__(self, root: Path):
self.root = root / "ai-polygon" / "worlds"
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
self.lock = threading.RLock()
def directory(self, world_id: str) -> Path:
if not WORLD_ID.fullmatch(world_id):
raise FileNotFoundError(world_id)
path = self.root / world_id
if path.is_symlink() or not path.is_dir():
raise FileNotFoundError(world_id)
return path
def get(self, world_id: str) -> dict:
with self.lock:
path = self.directory(world_id)
document = json.loads((path / "world.json").read_text())
if document["status"] == "uploading":
source = path / "source.part"
if not source.exists():
source = path / "source.ply"
document["uploaded_bytes"] = source.stat().st_size
return document
def list(self) -> list[dict]:
return sorted(
[
self.get(p.name)
for p in self.root.iterdir()
if WORLD_ID.fullmatch(p.name) and p.is_dir() and not p.is_symlink()
],
key=lambda item: item["created_at"],
reverse=True,
)
def create(self, request: WorldCreate) -> dict:
with self.lock:
if shutil.disk_usage(self.root).free < request.byte_length + 512 * 1024**2:
raise ValueError("Недостаточно места для исходника сцены.")
world_id = f"aiworld-{uuid4().hex}"
path = self.root / world_id
path.mkdir(mode=0o700)
(path / "source.part").touch(mode=0o600)
document = {
"schema_version": "missioncore.ai-polygon-world/v1",
"world_id": world_id,
**request.model_dump(mode="json"),
"status": "uploading",
"uploaded_bytes": 0,
"sha256": None,
"splat_count": None,
"created_at": utc_now_iso(),
"settings": WorldSettings().model_dump(mode="json"),
}
write_json(path / "world.json", document)
return document
def register_worker_asset(self, request: WorkerWorldCreate, worker_id: str) -> dict:
"""Keep only an authenticated asset manifest on the operator machine."""
with self.lock:
payload = request.model_dump(mode="json")
storage = {"kind": "worker", "worker_id": worker_id}
for existing in self.list():
if existing.get("storage") == storage and existing["sha256"] == request.sha256:
if any(existing.get(k) != v for k, v in payload.items() if k != "settings"):
raise RuntimeError("Манифест сохранённой локации изменился.")
return existing
world_id = f"aiworld-{uuid4().hex}"
path = self.root / world_id
path.mkdir(mode=0o700)
document = {
"schema_version": "missioncore.ai-polygon-world/v1",
"world_id": world_id,
**payload,
"storage": storage,
"status": "available",
"uploaded_bytes": 0,
"created_at": utc_now_iso(),
}
write_json(path / "world.json", document)
return document
def append(self, world_id: str, offset: int, payload: bytes) -> dict:
with self.lock:
doc = self.get(world_id)
if doc["status"] != "uploading" or offset != doc["uploaded_bytes"]:
raise RuntimeError("Позиция загрузки изменилась. Возобновите передачу.")
if not 0 < len(payload) <= CHUNK_BYTES or offset + len(payload) > doc["byte_length"]:
raise ValueError("Размер фрагмента загрузки недопустим.")
if shutil.disk_usage(self.root).free < len(payload) + 512 * 1024**2:
raise ValueError("Недостаточно свободного места.")
part = self.directory(world_id) / "source.part"
with part.open("r+b") as stream:
stream.seek(offset)
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
return self.get(world_id)
def complete(self, world_id: str) -> dict:
with self.lock:
doc = self.get(world_id)
if doc["status"] == "available":
return doc
if doc["uploaded_bytes"] != doc["byte_length"]:
raise RuntimeError("Загрузка сцены ещё не завершена.")
directory = self.directory(world_id)
source = directory / "source.part"
# Recover an interrupted atomic publication without discarding the source.
if not source.exists():
source = directory / "source.ply"
count = inspect_gaussian_ply(source)
digest = hashlib.sha256()
with source.open("rb") as stream:
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b""):
digest.update(chunk)
source.replace(directory / "source.ply")
doc.update(status="available", sha256=digest.hexdigest(), splat_count=count)
write_json(directory / "world.json", doc)
return doc
def prefix_hashes(self, world_id: str) -> dict:
"""Verify a resumed file against every byte already admitted, in bounded chunks."""
with self.lock:
doc = self.get(world_id)
if doc.get("storage", {}).get("kind") == "worker":
raise RuntimeError("Исходник локации хранится на Worker.")
directory = self.directory(world_id)
source = directory / "source.part"
if not source.exists():
source = directory / "source.ply"
chunks = []
with source.open("rb") as stream:
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b""):
chunks.append(
{"byte_length": len(chunk), "sha256": hashlib.sha256(chunk).hexdigest()}
)
return {"uploaded_bytes": doc["uploaded_bytes"], "chunks": chunks}
def configure(self, world_id: str, settings: WorldSettings) -> dict:
with self.lock:
doc = self.get(world_id)
if doc["status"] != "available":
raise RuntimeError("Сначала завершите импорт сцены.")
doc["settings"] = settings.model_dump(mode="json")
write_json(self.directory(world_id) / "world.json", doc)
return doc
+231
View File
@@ -0,0 +1,231 @@
"""Control Station and authenticated simulation-worker ports for AI polygon."""
import secrets
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Header, HTTPException, Request, Response
from fastapi.responses import FileResponse
from k1link.observatory.recorded_jobs import ObservatoryRecordedJobQueue
from k1link.simulation.ai_polygon.composition import compose, registry
from k1link.simulation.ai_polygon.contracts import (
RealtimeSnapshot,
RunApplied,
RunCreate,
RunProgress,
RunSample,
ViewControl,
WorkerHello,
WorkerPoll,
WorkerResult,
WorkerWorldCreate,
WorldCreate,
WorldSettings,
)
from k1link.simulation.ai_polygon.runs import RunStore
from k1link.simulation.ai_polygon.worlds import CHUNK_BYTES, SOURCES, WorldStore
def build_ai_polygon_router(
data_dir: Path, queue: ObservatoryRecordedJobQueue | None = None
) -> APIRouter:
worlds = WorldStore(data_dir)
runs = RunStore(worlds, queue)
router = APIRouter(prefix="/api/v1/ai-polygon", tags=["ai-polygon"])
adapters = Path(__file__).resolve().parents[3] / "simulation/ai-polygon"
def invoke(fn, *args):
try:
return fn(*args)
except FileNotFoundError as exc:
raise HTTPException(404, "Локация или прогон не найдены.") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(409, str(exc)) from exc
def authenticate(authorization: str | None) -> None:
expected = f"Bearer {runs.token}"
if not authorization or not secrets.compare_digest(authorization, expected):
raise HTTPException(401, "Worker authentication required")
@router.get("/catalog")
def catalog():
return {
"schema_version": "missioncore.ai-polygon-catalog/v1",
"sources": SOURCES,
"worlds": worlds.list(),
"runtime": runs.status(),
"runs": runs.list()[:30],
}
@router.post("/worlds", status_code=201)
def create_world(body: WorldCreate):
return invoke(worlds.create, body)
@router.get("/worlds/{world_id}")
def get_world(world_id: str):
return invoke(worlds.get, world_id)
@router.get("/worlds/{world_id}/upload-prefix")
def upload_prefix(world_id: str):
return invoke(worlds.prefix_hashes, world_id)
@router.patch("/worlds/{world_id}/source")
async def upload(
world_id: str, request: Request, upload_offset: int = Header(alias="Upload-Offset", ge=0)
):
data = bytearray()
async for block in request.stream():
if len(data) + len(block) > CHUNK_BYTES:
raise HTTPException(413, "Фрагмент загрузки превышает 4 МБ.")
data.extend(block)
return invoke(worlds.append, world_id, upload_offset, bytes(data))
@router.post("/worlds/{world_id}/complete")
def complete(world_id: str):
return invoke(worlds.complete, world_id)
@router.put("/worlds/{world_id}/settings")
def settings(world_id: str, body: WorldSettings):
return invoke(worlds.configure, world_id, body)
@router.get("/worlds/{world_id}/source.ply")
def source(world_id: str):
row = invoke(worlds.get, world_id)
if row["status"] != "available":
raise HTTPException(409, "Импорт не завершён.")
if row.get("storage", {}).get("kind") == "worker":
raise HTTPException(409, "Исходник локации хранится на Worker.")
return FileResponse(
worlds.directory(world_id) / "source.ply",
media_type="application/octet-stream",
headers={
"ETag": f'"{row["sha256"]}"',
"Cache-Control": "private, max-age=31536000, immutable",
},
)
@router.post("/runs", status_code=201)
def start(body: RunCreate, idempotency_key: str = Header(alias="Idempotency-Key")):
if body.clock == "realtime":
graph = invoke(compose, adapters, body.composition)
body = body.model_copy(update={"composition": graph.selection_document()})
return invoke(runs.start, body, idempotency_key)
@router.get("/ai-modules")
def modules():
installed = invoke(registry, adapters)
graph = invoke(compose, adapters)
return {
"catalog": {**installed.catalog(), "authority": "virtual-only"},
"selection": graph.selection_document(),
"composition_sha256": graph.sha256,
}
@router.get("/runs/{run_id}")
def get_run(run_id: str):
runs.status()
return invoke(runs.get, run_id)
@router.post("/runs/{run_id}/{command}")
def control(run_id: str, command: Literal["pause", "play", "step", "stop"]):
return invoke(runs.control, run_id, command)
@router.get("/runs/{run_id}/frames/{sequence}.jpg")
def frame(run_id: str, sequence: int):
row = invoke(runs.get, run_id)
if not 0 <= sequence < row["samples"]:
raise HTTPException(404, "Кадр не найден.")
return FileResponse(
runs.directory(run_id) / "frames" / f"{sequence:06d}.jpg",
media_type="image/jpeg",
headers={"Cache-Control": "private, max-age=31536000, immutable"},
)
@router.put("/runs/{run_id}/view")
def view(run_id: str, body: ViewControl):
return invoke(runs.view, run_id, body.camera)
@router.get("/runs/{run_id}/decisions")
def decisions(run_id: str):
directory = invoke(runs.directory, run_id)
path = directory / "decisions.jsonl"
if not path.exists():
return Response("", media_type="application/x-ndjson")
return FileResponse(path, media_type="application/x-ndjson", filename="decisions.jsonl")
@router.post("/worker/register")
def register(body: WorkerHello, authorization: str | None = Header(default=None)):
authenticate(authorization)
return invoke(runs.register, body)
@router.post("/worker/worlds", status_code=201)
def register_worker_world(
body: WorkerWorldCreate,
authorization: str | None = Header(default=None),
instance_id: str = Header(alias="Worker-Instance"),
):
authenticate(authorization)
with runs.lock:
invoke(runs.heartbeat, instance_id)
worker = runs.status()["worker"]
return invoke(worlds.register_worker_asset, body, worker["worker_id"])
@router.post("/worker/heartbeat")
def heartbeat(body: WorkerPoll, authorization: str | None = Header(default=None)):
authenticate(authorization)
return invoke(runs.heartbeat, body.instance_id)
@router.post("/worker/poll")
def poll(body: WorkerPoll, authorization: str | None = Header(default=None)):
authenticate(authorization)
return invoke(runs.poll, body.instance_id, body.run_id)
@router.post("/worker/runs/{run_id}/progress")
def progress(
run_id: str,
body: RunProgress,
instance_id: str = Header(alias="Worker-Instance"),
authorization: str | None = Header(default=None),
):
authenticate(authorization)
return invoke(runs.progress, run_id, instance_id, body.phase)
@router.post("/worker/runs/{run_id}/samples")
def sample(
run_id: str,
body: RunSample,
instance_id: str = Header(alias="Worker-Instance"),
authorization: str | None = Header(default=None),
):
authenticate(authorization)
return invoke(runs.sample, run_id, instance_id, body)
@router.post("/worker/runs/{run_id}/snapshot")
def snapshot(
run_id: str,
body: RealtimeSnapshot,
instance_id: str = Header(alias="Worker-Instance"),
authorization: str | None = Header(default=None),
):
authenticate(authorization)
return invoke(runs.snapshot, run_id, instance_id, body)
@router.post("/worker/runs/{run_id}/finish")
def finish(run_id: str, body: WorkerResult, authorization: str | None = Header(default=None)):
authenticate(authorization)
return invoke(runs.finish, run_id, body.instance_id, body.outcome, body.message)
@router.post("/worker/runs/{run_id}/applied")
def applied(
run_id: str,
body: RunApplied,
instance_id: str = Header(alias="Worker-Instance"),
authorization: str | None = Header(default=None),
):
authenticate(authorization)
return invoke(runs.applied, run_id, instance_id, body)
return router
+5
View File
@@ -238,6 +238,7 @@ from k1link.missions.registration_runs import RegistrationRuns
from k1link.web.mission_registration_api import build_mission_registration_router
from k1link.web.mission_planner_api import build_mission_planner_router
from k1link.web.simulation_projects_api import build_simulation_projects_router
from k1link.web.ai_polygon_api import build_ai_polygon_router
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router
from k1link.web.system_telemetry_api import build_system_telemetry_router
from k1link.web.vegetation_shadow_lab_api import (
@@ -1996,6 +1997,10 @@ app.include_router(
)
)
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
try:
app.include_router(build_ai_polygon_router(session_store.data_dir, OBSERVATORY_RECORDED_JOB_QUEUE))
except (OSError, ValueError):
logging.getLogger(__name__).exception("AI polygon could not load its private runtime state")
app.include_router(
build_viewer_diagnostics_router(
expected_ui_build_id=lambda: frontend_build_id(frontend_dist),