simulo-interfaces 0.14.0__tar.gz → 0.15.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 (59) hide show
  1. {simulo_interfaces-0.14.0/src/simulo_interfaces.egg-info → simulo_interfaces-0.15.2}/PKG-INFO +1 -1
  2. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/pyproject.toml +1 -1
  3. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/__init__.py +16 -0
  4. simulo_interfaces-0.15.2/src/simulo/interfaces/ids.py +134 -0
  5. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/__init__.py +55 -0
  6. simulo_interfaces-0.15.2/src/simulo/interfaces/platform/_payload.py +15 -0
  7. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/artifacts.py +40 -4
  8. simulo_interfaces-0.15.2/src/simulo/interfaces/platform/job_events.py +244 -0
  9. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/runs.py +61 -3
  10. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/submit.py +87 -8
  11. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2/src/simulo_interfaces.egg-info}/PKG-INFO +1 -1
  12. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo_interfaces.egg-info/SOURCES.txt +2 -0
  13. simulo_interfaces-0.14.0/src/simulo/interfaces/ids.py +0 -24
  14. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/MANIFEST.in +0 -0
  15. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/PYPI.md +0 -0
  16. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/setup.cfg +0 -0
  17. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/__init__.py +0 -0
  18. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/actuator.py +0 -0
  19. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/asset.py +0 -0
  20. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/entity.py +0 -0
  21. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/light.py +0 -0
  22. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/materials.py +0 -0
  23. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/physics.py +0 -0
  24. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/pose.py +0 -0
  25. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/primitives.py +0 -0
  26. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/prop.py +0 -0
  27. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/record.py +0 -0
  28. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/robot.py +0 -0
  29. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/scene.py +0 -0
  30. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/sensor.py +0 -0
  31. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/terrain.py +0 -0
  32. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/visual.py +0 -0
  33. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/authoring/world.py +0 -0
  34. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/exceptions.py +0 -0
  35. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/app.py +0 -0
  36. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/asset.py +0 -0
  37. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/asset_catalog.py +0 -0
  38. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/callbacks.py +0 -0
  39. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/debug.py +0 -0
  40. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/domain.py +0 -0
  41. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/enums.py +0 -0
  42. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/manifest.py +0 -0
  43. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/policy_bundle.py +0 -0
  44. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/runtime.py +0 -0
  45. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/platform/volume.py +0 -0
  46. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/py.typed +0 -0
  47. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/__init__.py +0 -0
  48. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/anomaly.py +0 -0
  49. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/components.py +0 -0
  50. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/env.py +0 -0
  51. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/player.py +0 -0
  52. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/policy.py +0 -0
  53. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/scenario.py +0 -0
  54. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/task.py +0 -0
  55. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/tensors.py +0 -0
  56. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo/interfaces/runtime/trainer.py +0 -0
  57. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo_interfaces.egg-info/dependency_links.txt +0 -0
  58. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo_interfaces.egg-info/requires.txt +0 -0
  59. {simulo_interfaces-0.14.0 → simulo_interfaces-0.15.2}/src/simulo_interfaces.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo-interfaces
3
- Version: 0.14.0
3
+ Version: 0.15.2
4
4
  Summary: Type contracts for the Simulo SDK. Installed automatically as a dependency of the simulo package.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo-interfaces"
7
- version = "0.14.0"
7
+ version = "0.15.2"
8
8
  description = "Type contracts for the Simulo SDK. Installed automatically as a dependency of the simulo package."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -31,13 +31,21 @@ from simulo.interfaces.ids import (
31
31
  CheckpointId,
32
32
  Digest,
33
33
  JobId,
34
+ JobPublicId,
35
+ ModelPublicId,
34
36
  OrganizationId,
37
+ OutputPublicId,
35
38
  PackageId,
36
39
  ProjectId,
40
+ RecordingPublicId,
37
41
  ResourceId,
38
42
  ResourceUri,
39
43
  TagId,
40
44
  TrainedModelId,
45
+ parse_job_public_id,
46
+ parse_model_public_id,
47
+ parse_output_public_id,
48
+ parse_recording_public_id,
41
49
  )
42
50
 
43
51
  __all__ = [
@@ -56,6 +64,14 @@ __all__ = [
56
64
  "ProjectId",
57
65
  "PackageId",
58
66
  "JobId",
67
+ "JobPublicId",
68
+ "ModelPublicId",
69
+ "RecordingPublicId",
70
+ "OutputPublicId",
71
+ "parse_job_public_id",
72
+ "parse_model_public_id",
73
+ "parse_recording_public_id",
74
+ "parse_output_public_id",
59
75
  "ResourceId",
60
76
  "CheckpointId",
61
77
  "TrainedModelId",
@@ -0,0 +1,134 @@
1
+ """Strongly-typed string identifiers for the platform domain model (Part B).
2
+
3
+ ``NewType`` aliases — zero runtime cost (each is the underlying ``str`` at
4
+ runtime) but distinct to the type checker, so a ``ProjectId`` can never be
5
+ passed where a ``JobId`` is expected.
6
+
7
+ The ``*PublicId`` aliases identify grammar-validated human-facing references.
8
+ Their parsers normalize only outer ASCII whitespace and ASCII letter case;
9
+ Unicode whitespace, Unicode case folding, wildcards, and partial IDs are
10
+ deliberately rejected.
11
+ """
12
+
13
+ import re
14
+ from typing import NewType
15
+
16
+ OrganizationId = NewType("OrganizationId", str)
17
+ ProjectId = NewType("ProjectId", str)
18
+ PackageId = NewType("PackageId", str)
19
+ JobId = NewType("JobId", str)
20
+ ResourceId = NewType("ResourceId", str)
21
+ CheckpointId = NewType("CheckpointId", str)
22
+ TrainedModelId = NewType("TrainedModelId", str)
23
+ TagId = NewType("TagId", str)
24
+ ResourceUri = NewType("ResourceUri", str) # e.g. "robot/so-arm-100:v3"
25
+ Digest = NewType("Digest", str) # content-addressed package source digest
26
+ RecordingId = NewType("RecordingId", str) # a job-produced MCAP recording (PR-0 wire contract)
27
+ WorkerId = NewType("WorkerId", str) # a claiming worker process (PR-0 wire contract)
28
+ AssetId = NewType("AssetId", str) # a catalog asset (USD Asset Catalogs wire contract, PR-1)
29
+ AssetVersionId = NewType("AssetVersionId", str) # one immutable asset version (PR-1)
30
+ ArtifactId = NewType("ArtifactId", str) # a job-produced output in the /artifacts projection (Plan B)
31
+
32
+ JobPublicId = NewType("JobPublicId", str)
33
+ ModelPublicId = NewType("ModelPublicId", str)
34
+ RecordingPublicId = NewType("RecordingPublicId", str)
35
+ OutputPublicId = NewType("OutputPublicId", str)
36
+
37
+ _ASCII_WHITESPACE = " \t\n\r\v\f"
38
+ _ASCII_LOWERCASE_TRANSLATION = str.maketrans(
39
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
40
+ "abcdefghijklmnopqrstuvwxyz",
41
+ )
42
+ _JOB_STEM_PATTERN = r"[a-z]{3,10}-[a-z]{3,10}-[0-9a-hjkmnp-tv-z]{6}"
43
+ _CANONICAL_CHILD_COUNTER_PATTERN = r"(?:(?!0000)[0-9]{4}|[1-9][0-9]{4,})"
44
+ _JOB_PUBLIC_ID_RE = re.compile(rf"job_{_JOB_STEM_PATTERN}\Z")
45
+ _MODEL_PUBLIC_ID_RE = re.compile(rf"mdl_{_JOB_STEM_PATTERN}-{_CANONICAL_CHILD_COUNTER_PATTERN}\Z")
46
+ _RECORDING_PUBLIC_ID_RE = re.compile(rf"rec_{_JOB_STEM_PATTERN}-{_CANONICAL_CHILD_COUNTER_PATTERN}\Z")
47
+ _OUTPUT_PUBLIC_ID_RE = re.compile(rf"out_{_JOB_STEM_PATTERN}-{_CANONICAL_CHILD_COUNTER_PATTERN}\Z")
48
+ _MAX_JOB_PUBLIC_ID_LENGTH = 32
49
+ _MAX_CHILD_PUBLIC_ID_LENGTH = 64
50
+
51
+
52
+ def _normalize_public_id(value: str, *, parser: str) -> str:
53
+ if not isinstance(value, str):
54
+ raise TypeError(f"{parser}() requires a str, got {type(value).__name__}.")
55
+ return value.strip(_ASCII_WHITESPACE).translate(_ASCII_LOWERCASE_TRANSLATION)
56
+
57
+
58
+ def _parse_public_id(value: str, *, parser: str, pattern: re.Pattern[str], max_length: int) -> str:
59
+ normalized = _normalize_public_id(value, parser=parser)
60
+ if len(normalized) > max_length or pattern.fullmatch(normalized) is None:
61
+ raise ValueError(f"{parser}() received a non-canonical public ID.")
62
+ return normalized
63
+
64
+
65
+ def parse_job_public_id(value: str) -> JobPublicId:
66
+ """Return a canonical Job ID such as ``job_quiet-cedar-7k3m4q``.
67
+
68
+ Only outer ASCII whitespace is trimmed and only ASCII ``A-Z`` is
69
+ lowercased. Matching is exact: prefixes, partial values, wildcards, and
70
+ non-ASCII input are rejected.
71
+ """
72
+
73
+ return JobPublicId(
74
+ _parse_public_id(
75
+ value,
76
+ parser="parse_job_public_id",
77
+ pattern=_JOB_PUBLIC_ID_RE,
78
+ max_length=_MAX_JOB_PUBLIC_ID_LENGTH,
79
+ )
80
+ )
81
+
82
+
83
+ def parse_model_public_id(value: str) -> ModelPublicId:
84
+ """Return a canonical Model ID such as ``mdl_quiet-cedar-7k3m4q-0001``.
85
+
86
+ Only outer ASCII whitespace is trimmed and only ASCII ``A-Z`` is
87
+ lowercased. Matching is exact and non-ASCII input is rejected. Counters
88
+ use exactly four digits through ``9999`` and expand unpadded at ``10000``.
89
+ """
90
+
91
+ return ModelPublicId(
92
+ _parse_public_id(
93
+ value,
94
+ parser="parse_model_public_id",
95
+ pattern=_MODEL_PUBLIC_ID_RE,
96
+ max_length=_MAX_CHILD_PUBLIC_ID_LENGTH,
97
+ )
98
+ )
99
+
100
+
101
+ def parse_recording_public_id(value: str) -> RecordingPublicId:
102
+ """Return a Recording ID such as ``rec_quiet-cedar-7k3m4q-0001``.
103
+
104
+ Only outer ASCII whitespace is trimmed and only ASCII ``A-Z`` is
105
+ lowercased. Matching is exact and non-ASCII input is rejected. Counters
106
+ use exactly four digits through ``9999`` and expand unpadded at ``10000``.
107
+ """
108
+
109
+ return RecordingPublicId(
110
+ _parse_public_id(
111
+ value,
112
+ parser="parse_recording_public_id",
113
+ pattern=_RECORDING_PUBLIC_ID_RE,
114
+ max_length=_MAX_CHILD_PUBLIC_ID_LENGTH,
115
+ )
116
+ )
117
+
118
+
119
+ def parse_output_public_id(value: str) -> OutputPublicId:
120
+ """Return an Output ID such as ``out_quiet-cedar-7k3m4q-0001``.
121
+
122
+ Only outer ASCII whitespace is trimmed and only ASCII ``A-Z`` is
123
+ lowercased. Matching is exact and non-ASCII input is rejected. Counters
124
+ use exactly four digits through ``9999`` and expand unpadded at ``10000``.
125
+ """
126
+
127
+ return OutputPublicId(
128
+ _parse_public_id(
129
+ value,
130
+ parser="parse_output_public_id",
131
+ pattern=_OUTPUT_PUBLIC_ID_RE,
132
+ max_length=_MAX_CHILD_PUBLIC_ID_LENGTH,
133
+ )
134
+ )
@@ -100,6 +100,34 @@ from simulo.interfaces.platform.callbacks import JobCallbackProtocol
100
100
  from simulo.interfaces.platform.debug import DebugSessionProtocol, ReplaySessionProtocol, VisualizationSessionProtocol
101
101
  from simulo.interfaces.platform.domain import Checkpoint, Job, Package, Project, Resource, Tag, TrainedModel
102
102
  from simulo.interfaces.platform.enums import ArtifactKind
103
+ from simulo.interfaces.platform.job_events import (
104
+ MAX_PUBLIC_ASSET_FINDING_COUNT,
105
+ MAX_PUBLIC_JOB_EVENT_ATTEMPT,
106
+ MAX_PUBLIC_JOB_EVENT_BYTES,
107
+ MAX_PUBLIC_JOB_EVENT_ENTRIES,
108
+ MAX_PUBLIC_JOB_EVENT_QUIET_SECONDS,
109
+ MAX_PUBLIC_JOB_EVENT_WIRE_BYTES,
110
+ PUBLIC_JOB_EVENT_ATTEMPT_MISMATCH_CODE,
111
+ PUBLIC_JOB_EVENT_OFFSET_CONFLICT_CODE,
112
+ PUBLIC_JOB_EVENT_OFFSET_GAP_CODE,
113
+ PUBLIC_JOB_EVENT_SCHEMA_VERSION,
114
+ PUBLIC_JOB_SUPPORT_ID_HEX_CHARS,
115
+ PUBLIC_JOB_SUPPORT_ID_PREFIX,
116
+ WORKER_JOB_EVENTS_ROUTE_TEMPLATE,
117
+ JobAssetFindingData,
118
+ JobAssetFindingEvent,
119
+ JobPreparingData,
120
+ JobPreparingEvent,
121
+ JobRunningData,
122
+ JobRunningEvent,
123
+ JobStillWorkingData,
124
+ JobStillWorkingEvent,
125
+ PublicAssetFindingReason,
126
+ PublicJobEvent,
127
+ PublicJobEventCode,
128
+ PublicJobEventSeverity,
129
+ PublicJobPreparationPhase,
130
+ )
103
131
  from simulo.interfaces.platform.manifest import (
104
132
  FINDING_CLASSIFICATIONS,
105
133
  MAX_PREFLIGHT_BODY_BYTES,
@@ -221,6 +249,33 @@ from simulo.interfaces.platform.volume import VolumeProtocol
221
249
  __all__ = [
222
250
  # enums
223
251
  "ArtifactKind",
252
+ # closed public job-event contract (#656)
253
+ "PUBLIC_JOB_EVENT_SCHEMA_VERSION",
254
+ "WORKER_JOB_EVENTS_ROUTE_TEMPLATE",
255
+ "MAX_PUBLIC_JOB_EVENT_BYTES",
256
+ "MAX_PUBLIC_JOB_EVENT_WIRE_BYTES",
257
+ "MAX_PUBLIC_JOB_EVENT_ENTRIES",
258
+ "MAX_PUBLIC_JOB_EVENT_ATTEMPT",
259
+ "MAX_PUBLIC_JOB_EVENT_QUIET_SECONDS",
260
+ "MAX_PUBLIC_ASSET_FINDING_COUNT",
261
+ "PUBLIC_JOB_SUPPORT_ID_PREFIX",
262
+ "PUBLIC_JOB_SUPPORT_ID_HEX_CHARS",
263
+ "PUBLIC_JOB_EVENT_OFFSET_GAP_CODE",
264
+ "PUBLIC_JOB_EVENT_OFFSET_CONFLICT_CODE",
265
+ "PUBLIC_JOB_EVENT_ATTEMPT_MISMATCH_CODE",
266
+ "PublicJobEventSeverity",
267
+ "PublicJobEventCode",
268
+ "PublicJobPreparationPhase",
269
+ "PublicAssetFindingReason",
270
+ "JobPreparingData",
271
+ "JobRunningData",
272
+ "JobStillWorkingData",
273
+ "JobAssetFindingData",
274
+ "JobPreparingEvent",
275
+ "JobRunningEvent",
276
+ "JobStillWorkingEvent",
277
+ "JobAssetFindingEvent",
278
+ "PublicJobEvent",
224
279
  # domain dataclasses
225
280
  "Tag",
226
281
  "Project",
@@ -0,0 +1,15 @@
1
+ """Internal helpers for tolerant response-record reconstruction."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import fields
5
+ from typing import Any
6
+
7
+
8
+ def known_payload_fields(record_type: Any, payload: Mapping[str, Any], *, owner: str) -> dict[str, Any]:
9
+ """Copy only fields declared by ``record_type`` from one response mapping."""
10
+
11
+ if not isinstance(payload, Mapping):
12
+ raise TypeError(f"{owner} requires a mapping, got {type(payload).__name__}.")
13
+ return {
14
+ definition.name: payload[definition.name] for definition in fields(record_type) if definition.name in payload
15
+ }
@@ -165,7 +165,17 @@ import re
165
165
  from collections.abc import Mapping
166
166
  from dataclasses import dataclass, field
167
167
 
168
- from simulo.interfaces.ids import ArtifactId, JobId
168
+ from simulo.interfaces.ids import (
169
+ ArtifactId,
170
+ JobId,
171
+ ModelPublicId,
172
+ OutputPublicId,
173
+ RecordingPublicId,
174
+ parse_model_public_id,
175
+ parse_output_public_id,
176
+ parse_recording_public_id,
177
+ )
178
+ from simulo.interfaces.platform._payload import known_payload_fields
169
179
  from simulo.interfaces.platform.enums import ArtifactKind
170
180
 
171
181
  # --------------------------------------------------------------------------
@@ -740,9 +750,9 @@ class ArtifactRecord:
740
750
 
741
751
  Wire-shaped by construction (``dataclasses.asdict`` serializes directly;
742
752
  ``kind`` is a ``StrEnum`` and JSON-encodes as its string value).
743
- Reconstruction from a JSON payload coerces the enum, the
744
- :class:`JobRecord <simulo.interfaces.platform.runs.JobRecord>` ``status``
745
- precedent: ``ArtifactRecord(**{**payload, "kind": ArtifactKind(payload["kind"])})``.
753
+ Use :meth:`from_payload` for tolerant response reconstruction: it ignores
754
+ additive unknown fields and coerces the known enum and public-ID fields.
755
+ Direct typed construction keeps the dataclass's strict keyword surface.
746
756
  """
747
757
 
748
758
  artifact_id: ArtifactId
@@ -805,6 +815,32 @@ class ArtifactRecord:
805
815
  not record one (recordings rows — the ``ModelRecord.kind`` unknown-value
806
816
  precedent)."""
807
817
 
818
+ public_id: ModelPublicId | RecordingPublicId | OutputPublicId | None = None
819
+ """The row's home Model, Recording, or Output ID.
820
+
821
+ ``None`` means an older producer omitted the additive field. The union
822
+ projection never mints a second ID for model or recording bytes.
823
+ """
824
+
825
+ @classmethod
826
+ def from_payload(cls, payload: Mapping[str, object]) -> "ArtifactRecord":
827
+ """Build from a response mapping while ignoring additive unknown fields."""
828
+
829
+ values = known_payload_fields(cls, payload, owner="ArtifactRecord.from_payload()")
830
+ if "kind" in values:
831
+ values["kind"] = ArtifactKind(values["kind"])
832
+ if values.get("public_id") is not None:
833
+ value = values["public_id"]
834
+ for parser in (parse_model_public_id, parse_recording_public_id, parse_output_public_id):
835
+ try:
836
+ values["public_id"] = parser(value)
837
+ break
838
+ except ValueError:
839
+ continue
840
+ else:
841
+ raise ValueError("ArtifactRecord.from_payload() received a non-canonical public ID.")
842
+ return cls(**values)
843
+
808
844
 
809
845
  @dataclass(frozen=True, slots=True, kw_only=True)
810
846
  class OutputFlag:
@@ -0,0 +1,244 @@
1
+ """Closed, versioned public job-event contract.
2
+
3
+ Workers may publish only these Simulo-owned events. The contract deliberately
4
+ contains no free-form message, path, URL, exception, or metadata field; public
5
+ copy is rendered by the trusted consumer from ``code`` and its typed ``data``.
6
+
7
+ The write-side byte offset is over canonical projected text, not request JSON
8
+ and not the legacy raw-log store. ``offset == total`` appends. An earlier
9
+ offset is an idempotent retry only when a stored event starts exactly there
10
+ with identical canonical projection bytes; different bytes (or an offset
11
+ inside an event) are ``event_offset_conflict``. A future offset is
12
+ ``event_offset_gap`` with the current public total. Every event carries the
13
+ current claim attempt and is fenced when it differs from the locked job row.
14
+ Every event POST must also carry the current claim lease in
15
+ :data:`simulo.interfaces.platform.submit.WORKER_LEASE_HEADER`; a missing or
16
+ stale lease is ``409 lease_lost`` and cannot append.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from enum import StrEnum
23
+ from typing import Literal, TypeAlias
24
+
25
+ PUBLIC_JOB_EVENT_SCHEMA_VERSION: Literal[1] = 1
26
+ WORKER_JOB_EVENTS_ROUTE_TEMPLATE = "/internal/worker/jobs/{job_id}/events"
27
+
28
+ # A public event is intentionally much smaller than the legacy 1 MiB raw-log
29
+ # chunk. The control plane applies this bound to the canonical projection.
30
+ MAX_PUBLIC_JOB_EVENT_BYTES = 4 * 1024
31
+ # Strict request-wire cap. Valid closed events are far smaller; this separate
32
+ # bound protects authentication-passing callers before JSON parsing.
33
+ MAX_PUBLIC_JOB_EVENT_WIRE_BYTES = 8 * 1024
34
+ # Bounds the server-owned provenance ledger kept outside the worker claim.
35
+ MAX_PUBLIC_JOB_EVENT_ENTRIES = 4_096
36
+ MAX_PUBLIC_JOB_EVENT_ATTEMPT = 1_000
37
+ MAX_PUBLIC_JOB_EVENT_QUIET_SECONDS = 24 * 60 * 60
38
+ MAX_PUBLIC_ASSET_FINDING_COUNT = 1_000
39
+
40
+ # Generated by trusted server code when an event-only job is enqueued. It is
41
+ # a correlation handle, not a credential or secret.
42
+ PUBLIC_JOB_SUPPORT_ID_PREFIX = "sup_"
43
+ PUBLIC_JOB_SUPPORT_ID_HEX_CHARS = 32
44
+
45
+ PUBLIC_JOB_EVENT_OFFSET_GAP_CODE = "event_offset_gap"
46
+ PUBLIC_JOB_EVENT_OFFSET_CONFLICT_CODE = "event_offset_conflict"
47
+ PUBLIC_JOB_EVENT_ATTEMPT_MISMATCH_CODE = "event_attempt_mismatch"
48
+
49
+
50
+ class PublicJobEventSeverity(StrEnum):
51
+ """Closed public severities used by the initial event set."""
52
+
53
+ INFO = "info"
54
+ WARNING = "warning"
55
+
56
+
57
+ class PublicJobEventCode(StrEnum):
58
+ """Closed event codes required by the asset-validation worker."""
59
+
60
+ JOB_PREPARING = "job.preparing"
61
+ JOB_RUNNING = "job.running"
62
+ JOB_STILL_WORKING = "job.still_working"
63
+ JOB_ASSET_FINDING = "job.asset_finding"
64
+
65
+
66
+ class PublicJobPreparationPhase(StrEnum):
67
+ """Safe preparation phases for the initial event schema."""
68
+
69
+ ASSET_VALIDATION = "asset_validation"
70
+
71
+
72
+ class PublicAssetFindingReason(StrEnum):
73
+ """Stable, product-owned asset finding reasons.
74
+
75
+ These values classify a trusted adapter outcome. They never carry the
76
+ native diagnostic or the asset's path/name.
77
+ """
78
+
79
+ ASSET_OPEN_FAILED = "asset_open_failed"
80
+ ASSET_REFERENCE_UNRESOLVED = "asset_reference_unresolved"
81
+ ASSET_MATERIAL_UNRESOLVED = "asset_material_unresolved"
82
+ ASSET_PRIM_INVALID = "asset_prim_invalid"
83
+
84
+
85
+ def _validate_attempt(attempt: int) -> None:
86
+ if isinstance(attempt, bool) or not isinstance(attempt, int):
87
+ raise TypeError("attempt must be an integer")
88
+ if not 1 <= attempt <= MAX_PUBLIC_JOB_EVENT_ATTEMPT:
89
+ raise ValueError(f"attempt must be between 1 and {MAX_PUBLIC_JOB_EVENT_ATTEMPT}")
90
+
91
+
92
+ def _validate_schema_version(schema_version: object) -> None:
93
+ # ``True == 1`` in Python; identity of the JSON scalar type is part of the
94
+ # version boundary, not merely numeric equality.
95
+ if type(schema_version) is not int or schema_version != PUBLIC_JOB_EVENT_SCHEMA_VERSION:
96
+ raise ValueError("unsupported public job-event schema version")
97
+
98
+
99
+ @dataclass(frozen=True, slots=True, kw_only=True)
100
+ class JobPreparingData:
101
+ phase: PublicJobPreparationPhase
102
+
103
+ def __post_init__(self) -> None:
104
+ if not isinstance(self.phase, PublicJobPreparationPhase):
105
+ raise TypeError("phase must be a PublicJobPreparationPhase")
106
+
107
+
108
+ @dataclass(frozen=True, slots=True, kw_only=True)
109
+ class JobRunningData:
110
+ """The running transition needs no worker-controlled detail."""
111
+
112
+
113
+ @dataclass(frozen=True, slots=True, kw_only=True)
114
+ class JobStillWorkingData:
115
+ quiet_seconds: int
116
+
117
+ def __post_init__(self) -> None:
118
+ if isinstance(self.quiet_seconds, bool) or not isinstance(self.quiet_seconds, int):
119
+ raise TypeError("quiet_seconds must be an integer")
120
+ if not 1 <= self.quiet_seconds <= MAX_PUBLIC_JOB_EVENT_QUIET_SECONDS:
121
+ raise ValueError("quiet_seconds must be between 1 and " f"{MAX_PUBLIC_JOB_EVENT_QUIET_SECONDS}")
122
+
123
+
124
+ @dataclass(frozen=True, slots=True, kw_only=True)
125
+ class JobAssetFindingData:
126
+ reason: PublicAssetFindingReason
127
+ count: int = 1
128
+
129
+ def __post_init__(self) -> None:
130
+ if not isinstance(self.reason, PublicAssetFindingReason):
131
+ raise TypeError("reason must be a PublicAssetFindingReason")
132
+ if isinstance(self.count, bool) or not isinstance(self.count, int):
133
+ raise TypeError("count must be an integer")
134
+ if not 1 <= self.count <= MAX_PUBLIC_ASSET_FINDING_COUNT:
135
+ raise ValueError(f"count must be between 1 and {MAX_PUBLIC_ASSET_FINDING_COUNT}")
136
+
137
+
138
+ @dataclass(frozen=True, slots=True, kw_only=True)
139
+ class JobPreparingEvent:
140
+ attempt: int
141
+ data: JobPreparingData
142
+ schema_version: Literal[1] = PUBLIC_JOB_EVENT_SCHEMA_VERSION
143
+ code: Literal[PublicJobEventCode.JOB_PREPARING] = PublicJobEventCode.JOB_PREPARING
144
+ severity: Literal[PublicJobEventSeverity.INFO] = PublicJobEventSeverity.INFO
145
+
146
+ def __post_init__(self) -> None:
147
+ _validate_attempt(self.attempt)
148
+ _validate_schema_version(self.schema_version)
149
+ if self.code is not PublicJobEventCode.JOB_PREPARING:
150
+ raise ValueError("code does not match JobPreparingEvent")
151
+ if self.severity is not PublicJobEventSeverity.INFO:
152
+ raise ValueError("severity does not match JobPreparingEvent")
153
+ if not isinstance(self.data, JobPreparingData):
154
+ raise TypeError("data must be JobPreparingData")
155
+
156
+
157
+ @dataclass(frozen=True, slots=True, kw_only=True)
158
+ class JobRunningEvent:
159
+ attempt: int
160
+ data: JobRunningData
161
+ schema_version: Literal[1] = PUBLIC_JOB_EVENT_SCHEMA_VERSION
162
+ code: Literal[PublicJobEventCode.JOB_RUNNING] = PublicJobEventCode.JOB_RUNNING
163
+ severity: Literal[PublicJobEventSeverity.INFO] = PublicJobEventSeverity.INFO
164
+
165
+ def __post_init__(self) -> None:
166
+ _validate_attempt(self.attempt)
167
+ _validate_schema_version(self.schema_version)
168
+ if self.code is not PublicJobEventCode.JOB_RUNNING:
169
+ raise ValueError("code does not match JobRunningEvent")
170
+ if self.severity is not PublicJobEventSeverity.INFO:
171
+ raise ValueError("severity does not match JobRunningEvent")
172
+ if not isinstance(self.data, JobRunningData):
173
+ raise TypeError("data must be JobRunningData")
174
+
175
+
176
+ @dataclass(frozen=True, slots=True, kw_only=True)
177
+ class JobStillWorkingEvent:
178
+ attempt: int
179
+ data: JobStillWorkingData
180
+ schema_version: Literal[1] = PUBLIC_JOB_EVENT_SCHEMA_VERSION
181
+ code: Literal[PublicJobEventCode.JOB_STILL_WORKING] = PublicJobEventCode.JOB_STILL_WORKING
182
+ severity: Literal[PublicJobEventSeverity.INFO] = PublicJobEventSeverity.INFO
183
+
184
+ def __post_init__(self) -> None:
185
+ _validate_attempt(self.attempt)
186
+ _validate_schema_version(self.schema_version)
187
+ if self.code is not PublicJobEventCode.JOB_STILL_WORKING:
188
+ raise ValueError("code does not match JobStillWorkingEvent")
189
+ if self.severity is not PublicJobEventSeverity.INFO:
190
+ raise ValueError("severity does not match JobStillWorkingEvent")
191
+ if not isinstance(self.data, JobStillWorkingData):
192
+ raise TypeError("data must be JobStillWorkingData")
193
+
194
+
195
+ @dataclass(frozen=True, slots=True, kw_only=True)
196
+ class JobAssetFindingEvent:
197
+ attempt: int
198
+ data: JobAssetFindingData
199
+ schema_version: Literal[1] = PUBLIC_JOB_EVENT_SCHEMA_VERSION
200
+ code: Literal[PublicJobEventCode.JOB_ASSET_FINDING] = PublicJobEventCode.JOB_ASSET_FINDING
201
+ severity: Literal[PublicJobEventSeverity.WARNING] = PublicJobEventSeverity.WARNING
202
+
203
+ def __post_init__(self) -> None:
204
+ _validate_attempt(self.attempt)
205
+ _validate_schema_version(self.schema_version)
206
+ if self.code is not PublicJobEventCode.JOB_ASSET_FINDING:
207
+ raise ValueError("code does not match JobAssetFindingEvent")
208
+ if self.severity is not PublicJobEventSeverity.WARNING:
209
+ raise ValueError("severity does not match JobAssetFindingEvent")
210
+ if not isinstance(self.data, JobAssetFindingData):
211
+ raise TypeError("data must be JobAssetFindingData")
212
+
213
+
214
+ PublicJobEvent: TypeAlias = JobPreparingEvent | JobRunningEvent | JobStillWorkingEvent | JobAssetFindingEvent
215
+
216
+
217
+ __all__ = [
218
+ "PUBLIC_JOB_EVENT_SCHEMA_VERSION",
219
+ "WORKER_JOB_EVENTS_ROUTE_TEMPLATE",
220
+ "MAX_PUBLIC_JOB_EVENT_BYTES",
221
+ "MAX_PUBLIC_JOB_EVENT_WIRE_BYTES",
222
+ "MAX_PUBLIC_JOB_EVENT_ENTRIES",
223
+ "MAX_PUBLIC_JOB_EVENT_ATTEMPT",
224
+ "MAX_PUBLIC_JOB_EVENT_QUIET_SECONDS",
225
+ "MAX_PUBLIC_ASSET_FINDING_COUNT",
226
+ "PUBLIC_JOB_SUPPORT_ID_PREFIX",
227
+ "PUBLIC_JOB_SUPPORT_ID_HEX_CHARS",
228
+ "PUBLIC_JOB_EVENT_OFFSET_GAP_CODE",
229
+ "PUBLIC_JOB_EVENT_OFFSET_CONFLICT_CODE",
230
+ "PUBLIC_JOB_EVENT_ATTEMPT_MISMATCH_CODE",
231
+ "PublicJobEventSeverity",
232
+ "PublicJobEventCode",
233
+ "PublicJobPreparationPhase",
234
+ "PublicAssetFindingReason",
235
+ "JobPreparingData",
236
+ "JobRunningData",
237
+ "JobStillWorkingData",
238
+ "JobAssetFindingData",
239
+ "JobPreparingEvent",
240
+ "JobRunningEvent",
241
+ "JobStillWorkingEvent",
242
+ "JobAssetFindingEvent",
243
+ "PublicJobEvent",
244
+ ]
@@ -151,8 +151,9 @@ from collections.abc import Mapping
151
151
  from dataclasses import dataclass, field
152
152
  from typing import Any, Optional
153
153
 
154
- from simulo.interfaces.ids import JobId, PackageId
155
- from simulo.interfaces.platform.artifacts import JobOutputsSummary
154
+ from simulo.interfaces.ids import JobId, JobPublicId, PackageId, parse_job_public_id
155
+ from simulo.interfaces.platform._payload import known_payload_fields
156
+ from simulo.interfaces.platform.artifacts import JobOutputsSummary, OutputFlag
156
157
  from simulo.interfaces.platform.enums import JobStatus
157
158
 
158
159
  #: Default TCP port for the jobs API. Locally this is where ``simulo-backend
@@ -234,7 +235,9 @@ class JobRecord:
234
235
  ``GET /v1/jobs/{job_id}``. Timestamps are ISO 8601 UTC strings (wire-shaped
235
236
  by construction so ``dataclasses.asdict`` serializes directly; ``status`` is
236
237
  a :class:`~simulo.interfaces.platform.enums.JobStatus` ``StrEnum`` and
237
- JSON-encodes as its string value).
238
+ JSON-encodes as its string value). Use :meth:`from_payload` for responses:
239
+ it ignores additive unknown fields and coerces known nested and enum values.
240
+ Direct typed construction keeps the dataclass's strict keyword surface.
238
241
  """
239
242
 
240
243
  job_id: JobId
@@ -358,3 +361,58 @@ class JobRecord:
358
361
  docstring: ``flags`` are what the worker announced at ``complete`` time;
359
362
  ``counts`` are reconciled at read time from actually-persisted artifact
360
363
  rows, so a count never asserts bytes that did not land."""
364
+
365
+ public_id: Optional[JobPublicId] = None
366
+ """Human-facing Job ID, or ``None`` when an older producer omitted it."""
367
+
368
+ @classmethod
369
+ def from_payload(cls, payload: Mapping[str, Any]) -> "JobRecord":
370
+ """Build from a response mapping while ignoring additive unknown fields."""
371
+
372
+ values = known_payload_fields(cls, payload, owner="JobRecord.from_payload()")
373
+ if "status" in values:
374
+ values["status"] = JobStatus(values["status"])
375
+ if "assets" in values:
376
+ assets = values["assets"]
377
+ if not isinstance(assets, (list, tuple)):
378
+ raise TypeError("JobRecord.from_payload().assets requires an array.")
379
+ if not all(isinstance(asset, str) for asset in assets):
380
+ raise TypeError("JobRecord.from_payload().assets[] requires a str.")
381
+ values["assets"] = tuple(assets)
382
+ if values.get("public_id") is not None:
383
+ values["public_id"] = parse_job_public_id(values["public_id"])
384
+
385
+ outputs = values.get("outputs")
386
+ if outputs is not None and not isinstance(outputs, JobOutputsSummary):
387
+ if not isinstance(outputs, Mapping):
388
+ raise TypeError("JobRecord.from_payload().outputs requires a mapping or JobOutputsSummary.")
389
+ output_values = known_payload_fields(
390
+ JobOutputsSummary,
391
+ outputs,
392
+ owner="JobRecord.from_payload().outputs",
393
+ )
394
+ if "counts" in output_values and not isinstance(output_values["counts"], Mapping):
395
+ raise TypeError("JobRecord.from_payload().outputs.counts requires a mapping.")
396
+ if "flags" in output_values:
397
+ flags = output_values["flags"]
398
+ if not isinstance(flags, (list, tuple)):
399
+ raise TypeError("JobRecord.from_payload().outputs.flags requires an array.")
400
+ parsed_flags = []
401
+ for flag in flags:
402
+ if isinstance(flag, OutputFlag):
403
+ parsed_flags.append(flag)
404
+ elif isinstance(flag, Mapping):
405
+ parsed_flags.append(
406
+ OutputFlag(
407
+ **known_payload_fields(
408
+ OutputFlag,
409
+ flag,
410
+ owner="JobRecord.from_payload().outputs.flags[]",
411
+ )
412
+ )
413
+ )
414
+ else:
415
+ raise TypeError("JobRecord.from_payload().outputs.flags[] requires a mapping or OutputFlag.")
416
+ output_values["flags"] = tuple(parsed_flags)
417
+ values["outputs"] = JobOutputsSummary(**output_values)
418
+ return cls(**values)
@@ -167,6 +167,16 @@ platform-standard shape: ``{"error": {"code": ..., "message": ..., "request_id":
167
167
  "lease_seconds": 300,
168
168
  "lease_id": "5e0c4a4e-..."}
169
169
 
170
+ A server-marked event-only asset-validation job additionally carries
171
+ ``public_event_schema_version``, ``public_log_offset``, and ``support_id``.
172
+ These fields are absent for ordinary and legacy jobs, so workers published
173
+ before the event contract continue to ignore the additive seam. The support
174
+ id is a server-generated, non-secret correlation handle; a worker reports
175
+ events without supplying or overriding it. See
176
+ :mod:`simulo.interfaces.platform.job_events` for the closed event schema.
177
+ The private provenance ledger that derives ``public_log_offset`` is never
178
+ included in the claim.
179
+
170
180
  or ``204`` with an empty body when there is no queued work. A claim is one
171
181
  atomic ``SELECT ... FOR UPDATE SKIP LOCKED`` transition of exactly one
172
182
  ``queued`` job to ``running`` — plus a status event, ``attempts += 1``, and
@@ -175,7 +185,7 @@ platform-standard shape: ``{"error": {"code": ..., "message": ..., "request_id":
175
185
  ``lease_id`` (lease-reaper wave, additive) is the **per-claim ownership
176
186
  token**: a fresh server-generated opaque id stamped on the job row by this
177
187
  claim. The worker must present it back on every subsequent job-scoped call
178
- (heartbeat / logs / complete) in the :data:`WORKER_LEASE_HEADER` request
188
+ (heartbeat / logs / events / complete) in the :data:`WORKER_LEASE_HEADER` request
179
189
  header — it is what distinguishes "the worker holding the CURRENT claim"
180
190
  from "a worker that once held a claim on this job_id". A call whose
181
191
  presented lease does not match the job's current one (the job was reaped
@@ -197,9 +207,15 @@ platform-standard shape: ``{"error": {"code": ..., "message": ..., "request_id":
197
207
  a live run), because the atomic ``lease_id`` swap at reclaim is what
198
208
  fences out a stale worker, not the wall clock.
199
209
 
200
- * :data:`WORKER_JOB_LOGS_ROUTE_TEMPLATE` (``POST``, ``?offset=N``) — request
201
- body is raw log bytes (not JSON), capped at :data:`MAX_LOG_CHUNK_BYTES` per
202
- call. ``offset`` is the byte offset the worker believes the server is at
210
+ * :data:`WORKER_JOB_LOGS_ROUTE_TEMPLATE` (``POST``, ``?offset=N``) — for an
211
+ ordinary job, the request body is raw log bytes (not JSON), capped at
212
+ :data:`MAX_LOG_CHUNK_BYTES` per call. Asset-validation jobs are mutually
213
+ exclusive with this legacy surface: marked and unmarked rows both reject a
214
+ raw append with ``409 raw_logs_disabled_for_asset_validation``. A marked
215
+ asset-validation worker instead posts the closed event body to
216
+ :data:`simulo.interfaces.platform.job_events.WORKER_JOB_EVENTS_ROUTE_TEMPLATE`;
217
+ the control plane renders the canonical public text and stores only those
218
+ bytes. ``offset`` is the byte offset the worker believes the server is at
203
219
  (mirrors the client-side ``GET .../logs?offset=N`` semantics in
204
220
  ``runs.py``, from the writer's side):
205
221
 
@@ -221,6 +237,29 @@ platform-standard shape: ``{"error": {"code": ..., "message": ..., "request_id":
221
237
  only channel). A stale lease is ``409 lease_lost`` — a reaped worker can
222
238
  never append into the stream a later attempt now owns.
223
239
 
240
+ * :data:`simulo.interfaces.platform.job_events.WORKER_JOB_EVENTS_ROUTE_TEMPLATE`
241
+ (``POST``, ``?offset=N``) — the marked asset-validation replacement for raw
242
+ logs. Authentication and the machine rate limit run before the bounded body
243
+ is read; the JSON wire body is capped at
244
+ :data:`simulo.interfaces.platform.job_events.MAX_PUBLIC_JOB_EVENT_WIRE_BYTES`
245
+ and its canonical projection at
246
+ :data:`simulo.interfaces.platform.job_events.MAX_PUBLIC_JOB_EVENT_BYTES`.
247
+ Offsets are over canonical projected UTF-8 bytes only:
248
+
249
+ - ``offset == <public total>`` appends the new projection.
250
+ - ``offset < <public total>`` succeeds only when a ledger entry starts
251
+ exactly there with identical canonical projection bytes. Different bytes
252
+ or an offset inside an event answer ``409 event_offset_conflict``.
253
+ - ``offset > <public total>`` answers ``409 event_offset_gap`` with the
254
+ current public total.
255
+
256
+ The request must carry the current claim lease in
257
+ :data:`WORKER_LEASE_HEADER`; a missing or stale value answers ``409
258
+ lease_lost`` and cannot append. The body ``attempt`` must equal the locked
259
+ job row's current attempt; otherwise it is fenced with ``409
260
+ event_attempt_mismatch``. Physical legacy raw rows are outside this logical
261
+ byte space and cannot affect claim, retry, GET/tail, or flattening behavior.
262
+
224
263
  * :data:`WORKER_JOB_HEARTBEAT_ROUTE_TEMPLATE` (``POST``) — no request body
225
264
  required; extends the claim lease. The request carries the claim's
226
265
  ``lease_id`` in :data:`WORKER_LEASE_HEADER`; a stale lease is ``409
@@ -349,10 +388,20 @@ models; the list is empty, never an error.
349
388
  """
350
389
 
351
390
  import re
391
+ from collections.abc import Mapping
352
392
  from dataclasses import dataclass
353
393
  from typing import Optional
354
394
 
355
- from simulo.interfaces.ids import JobId, RecordingId, TrainedModelId
395
+ from simulo.interfaces.ids import (
396
+ JobId,
397
+ ModelPublicId,
398
+ RecordingId,
399
+ RecordingPublicId,
400
+ TrainedModelId,
401
+ parse_model_public_id,
402
+ parse_recording_public_id,
403
+ )
404
+ from simulo.interfaces.platform._payload import known_payload_fields
356
405
 
357
406
  # --------------------------------------------------------------------------
358
407
  # Client-facing routes (bearer = user JWT; require_active_org server-side)
@@ -443,7 +492,9 @@ SEED_SOURCE_CONFLICT_CODE = "seed_source_conflict"
443
492
 
444
493
  #: ``POST`` — atomically claim the next queued job (``FOR UPDATE SKIP LOCKED``).
445
494
  WORKER_CLAIM_ROUTE = "/internal/worker/claim"
446
- #: ``POST`` — append raw log bytes at a byte offset (``?offset=N``). ``.format(job_id=...)``.
495
+ #: ``POST`` — append ordinary-job raw log bytes at a byte offset
496
+ #: (``?offset=N``). Asset-validation jobs must use the closed event route.
497
+ #: ``.format(job_id=...)``.
447
498
  WORKER_JOB_LOGS_ROUTE_TEMPLATE = "/internal/worker/jobs/{job_id}/logs"
448
499
  #: ``POST`` — extend the claim lease; response carries the ``cancelled`` seam.
449
500
  #: ``.format(job_id=...)``.
@@ -468,9 +519,9 @@ WORKER_JOB_MODELS_PRESIGN_ROUTE_TEMPLATE = "/internal/worker/jobs/{job_id}/model
468
519
  WORKER_JOB_MODELS_ROUTE_TEMPLATE = "/internal/worker/jobs/{job_id}/models"
469
520
 
470
521
  #: Request header carrying the claim's ``lease_id`` ownership token on every
471
- #: job-scoped worker call (heartbeat / logs / complete). A header — not a body
522
+ #: job-scoped worker call (heartbeat / logs / events / complete). A header — not a body
472
523
  #: field — because the logs route's body is raw bytes and heartbeat has no
473
- #: body; one uniform channel for all three. A missing or mismatching value is
524
+ #: body; one uniform channel for all four. A missing or mismatching value is
474
525
  #: ``409 lease_lost`` (see the module docstring's claim section for the full
475
526
  #: lease/reaper semantics). Recording presign/finalize are deliberately NOT
476
527
  #: lease-gated: the worker uploads recordings AFTER ``complete`` — i.e. after
@@ -525,6 +576,8 @@ class RecordingRecord:
525
576
  them. The three episode-summary fields are optional because not every
526
577
  recording is episodic (e.g. a raw rollout capture) and because a worker
527
578
  may finalize a recording before its summary stats are computed.
579
+ Use :meth:`from_payload` for tolerant response reconstruction; direct typed
580
+ construction keeps the dataclass's strict keyword surface.
528
581
  """
529
582
 
530
583
  recording_id: RecordingId
@@ -560,6 +613,18 @@ class RecordingRecord:
560
613
  success_rate: Optional[float] = None
561
614
  """Fraction of episodes that reached a success termination, if computed."""
562
615
 
616
+ public_id: Optional[RecordingPublicId] = None
617
+ """Human-facing Recording ID, or ``None`` when an older producer omitted it."""
618
+
619
+ @classmethod
620
+ def from_payload(cls, payload: Mapping[str, object]) -> "RecordingRecord":
621
+ """Build from a response mapping while ignoring additive unknown fields."""
622
+
623
+ values = known_payload_fields(cls, payload, owner="RecordingRecord.from_payload()")
624
+ if values.get("public_id") is not None:
625
+ values["public_id"] = parse_recording_public_id(values["public_id"])
626
+ return cls(**values)
627
+
563
628
 
564
629
  #: The checkpoint kinds a training job produces (``simulo.checkpoint.v1``:
565
630
  #: ``best.pt`` = highest-mean-episode-reward snapshot, ``latest.pt`` = most
@@ -583,6 +648,8 @@ class ModelRecord:
583
648
  ``digest_sha256`` is always present (bare 64-hex): the worker hashes the
584
649
  checkpoint before finalizing, and clients MUST verify the downloaded bytes
585
650
  against it.
651
+ Use :meth:`from_payload` for tolerant response reconstruction; direct typed
652
+ construction keeps the dataclass's strict keyword surface.
586
653
  """
587
654
 
588
655
  model_id: TrainedModelId
@@ -609,6 +676,18 @@ class ModelRecord:
609
676
  created_at: str
610
677
  """When the model was finalized (ISO 8601 UTC)."""
611
678
 
679
+ public_id: Optional[ModelPublicId] = None
680
+ """Human-facing Model ID, or ``None`` when an older producer omitted it."""
681
+
682
+ @classmethod
683
+ def from_payload(cls, payload: Mapping[str, object]) -> "ModelRecord":
684
+ """Build from a response mapping while ignoring additive unknown fields."""
685
+
686
+ values = known_payload_fields(cls, payload, owner="ModelRecord.from_payload()")
687
+ if values.get("public_id") is not None:
688
+ values["public_id"] = parse_model_public_id(values["public_id"])
689
+ return cls(**values)
690
+
612
691
 
613
692
  # --------------------------------------------------------------------------
614
693
  # Reserved runtime environment keys
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo-interfaces
3
- Version: 0.14.0
3
+ Version: 0.15.2
4
4
  Summary: Type contracts for the Simulo SDK. Installed automatically as a dependency of the simulo package.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -23,6 +23,7 @@ src/simulo/interfaces/authoring/terrain.py
23
23
  src/simulo/interfaces/authoring/visual.py
24
24
  src/simulo/interfaces/authoring/world.py
25
25
  src/simulo/interfaces/platform/__init__.py
26
+ src/simulo/interfaces/platform/_payload.py
26
27
  src/simulo/interfaces/platform/app.py
27
28
  src/simulo/interfaces/platform/artifacts.py
28
29
  src/simulo/interfaces/platform/asset.py
@@ -31,6 +32,7 @@ src/simulo/interfaces/platform/callbacks.py
31
32
  src/simulo/interfaces/platform/debug.py
32
33
  src/simulo/interfaces/platform/domain.py
33
34
  src/simulo/interfaces/platform/enums.py
35
+ src/simulo/interfaces/platform/job_events.py
34
36
  src/simulo/interfaces/platform/manifest.py
35
37
  src/simulo/interfaces/platform/policy_bundle.py
36
38
  src/simulo/interfaces/platform/runs.py
@@ -1,24 +0,0 @@
1
- """Strongly-typed string identifiers for the platform domain model (Part B).
2
-
3
- ``NewType`` aliases — zero runtime cost (each is the underlying ``str`` at
4
- runtime) but distinct to the type checker, so a ``ProjectId`` can never be
5
- passed where a ``JobId`` is expected.
6
- """
7
-
8
- from typing import NewType
9
-
10
- OrganizationId = NewType("OrganizationId", str)
11
- ProjectId = NewType("ProjectId", str)
12
- PackageId = NewType("PackageId", str)
13
- JobId = NewType("JobId", str)
14
- ResourceId = NewType("ResourceId", str)
15
- CheckpointId = NewType("CheckpointId", str)
16
- TrainedModelId = NewType("TrainedModelId", str)
17
- TagId = NewType("TagId", str)
18
- ResourceUri = NewType("ResourceUri", str) # e.g. "robot/so-arm-100:v3"
19
- Digest = NewType("Digest", str) # content-addressed package source digest
20
- RecordingId = NewType("RecordingId", str) # a job-produced MCAP recording (PR-0 wire contract)
21
- WorkerId = NewType("WorkerId", str) # a claiming worker process (PR-0 wire contract)
22
- AssetId = NewType("AssetId", str) # a catalog asset (USD Asset Catalogs wire contract, PR-1)
23
- AssetVersionId = NewType("AssetVersionId", str) # one immutable asset version (PR-1)
24
- ArtifactId = NewType("ArtifactId", str) # a job-produced output in the /artifacts projection (Plan B)