novaflow-types 0.1.0.dev1__py3-none-any.whl

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 (48) hide show
  1. novaflow/types/__init__.py +9 -0
  2. novaflow/types/comm/__init__.py +0 -0
  3. novaflow/types/comm/camera_registry.py +72 -0
  4. novaflow/types/comm/cloudevent.py +167 -0
  5. novaflow/types/comm/execution_state.py +22 -0
  6. novaflow/types/comm/heartbeat.py +11 -0
  7. novaflow/types/comm/kv_keys.py +185 -0
  8. novaflow/types/comm/programming.py +471 -0
  9. novaflow/types/comm/response_payload.py +87 -0
  10. novaflow/types/comm/run_hooks.py +60 -0
  11. novaflow/types/comm/status.py +19 -0
  12. novaflow/types/comm/subjects.py +75 -0
  13. novaflow/types/core/__init__.py +17 -0
  14. novaflow/types/core/action.py +1083 -0
  15. novaflow/types/core/base.py +24 -0
  16. novaflow/types/core/blackboard.py +90 -0
  17. novaflow/types/core/service.py +81 -0
  18. novaflow/types/core/shape_diff.py +72 -0
  19. novaflow/types/core/structural_id.py +439 -0
  20. novaflow/types/data/__init__.py +42 -0
  21. novaflow/types/data/depth_image.py +66 -0
  22. novaflow/types/data/dof_check.py +250 -0
  23. novaflow/types/data/image.py +35 -0
  24. novaflow/types/data/joints.py +23 -0
  25. novaflow/types/data/json_data.py +32 -0
  26. novaflow/types/data/mobile.py +95 -0
  27. novaflow/types/data/motion_item.py +127 -0
  28. novaflow/types/data/motion_routine.py +607 -0
  29. novaflow/types/data/motion_target.py +51 -0
  30. novaflow/types/data/nova_openapi/26.3.0/public.openapi.json +20077 -0
  31. novaflow/types/data/nova_spec.py +203 -0
  32. novaflow/types/data/pointcloud.py +38 -0
  33. novaflow/types/data/pose.py +27 -0
  34. novaflow/types/data/reference.py +35 -0
  35. novaflow/types/data/registry.py +286 -0
  36. novaflow/types/data/vector3d.py +12 -0
  37. novaflow/types/nova_client.py +43 -0
  38. novaflow/types/tree/__init__.py +89 -0
  39. novaflow/types/tree/bounds.py +100 -0
  40. novaflow/types/tree/io_action_naming.py +39 -0
  41. novaflow/types/tree/migrate_v2_to_v3.py +1299 -0
  42. novaflow/types/tree/naming.py +44 -0
  43. novaflow/types/tree/nodes.py +1074 -0
  44. novaflow/types/tree/references.py +212 -0
  45. novaflow/types/tree/tree.py +320 -0
  46. novaflow_types-0.1.0.dev1.dist-info/METADATA +37 -0
  47. novaflow_types-0.1.0.dev1.dist-info/RECORD +48 -0
  48. novaflow_types-0.1.0.dev1.dist-info/WHEEL +4 -0
@@ -0,0 +1,9 @@
1
+ from .core.action import NodeSchema, OperationType
2
+ from .core.service import ActionServiceMetadata, StateProviderSchema
3
+
4
+ __all__ = [
5
+ "NodeSchema",
6
+ "ActionServiceMetadata",
7
+ "StateProviderSchema",
8
+ "OperationType",
9
+ ]
File without changes
@@ -0,0 +1,72 @@
1
+ """``registry_cameras`` KV wire contract — the single typed source of truth for
2
+ camera-feed advertisements that connectors publish and canopy reads.
3
+
4
+ A connector (the mobile-integration-SDK ``RegistryPublisher``, the MockMobileBot
5
+ fixture, or — in future — a Nova-native camera service such as the Techman
6
+ eye-in-hand camera) publishes one entry per camera feed into the
7
+ ``registry_cameras`` JetStream KV bucket, keyed ``{robot_id}.{feed}``. Canopy
8
+ watches the bucket and renders each feed's ``browser_stream_url`` as an MJPEG
9
+ ``<img>`` in the robot panel.
10
+
11
+ Before this model the shape lived only in canopy's hand-written parser
12
+ (``parseCameraEntry``) — an untyped boundary a producer in a *separate* repo
13
+ could silently drift from. This model generates BOTH the canopy TypeScript type
14
+ (``pydantic2ts`` → ``cameraRegistryTypes.ts``) and the JSON Schema canopy
15
+ validates against on read (ajv → ``camera_registry.schema.json``), mirroring the
16
+ #251 wire-contract symmetry already used for the per-run comm-KV signals.
17
+
18
+ ``robot_id`` and ``feed`` are required — they carry the feed's identity and are
19
+ also encoded in the KV key. Everything else is optional so producers advertise
20
+ only what they have; canopy renders a placeholder for any feed lacking a
21
+ ``browser_stream_url``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+
28
+
29
+ class CameraRegistryEntry(BaseModel):
30
+ """One camera feed advertised in the ``registry_cameras`` KV bucket."""
31
+
32
+ # extra="ignore" (the default) keeps the JSON Schema free of
33
+ # ``additionalProperties: false`` so producers may carry extra keys
34
+ # (e.g. ``last_seen``) without failing canopy's read-side validation.
35
+ model_config = ConfigDict(extra="ignore")
36
+
37
+ robot_id: str = Field(
38
+ description="Controller/robot id this feed belongs to; the first "
39
+ "segment of the KV key ``{robot_id}.{feed}``.",
40
+ )
41
+ feed: str = Field(
42
+ description="Feed name unique within the robot (e.g. ``front_camera`` or "
43
+ "``eih``); the remainder of the KV key.",
44
+ )
45
+ robot_type: str = Field(
46
+ default="",
47
+ description="Robot model/type label, for display grouping.",
48
+ )
49
+ stream_url: str = Field(
50
+ default="",
51
+ description="In-cluster MJPEG endpoint. NOT browser-reachable; kept for "
52
+ "server-side / e2e consumers.",
53
+ )
54
+ browser_stream_url: str | None = Field(
55
+ default=None,
56
+ description="Browser-reachable MJPEG URL (through the cell ingress). "
57
+ "Canopy renders ONLY this; a feed without it shows a placeholder.",
58
+ )
59
+ snapshot_url: str | None = Field(
60
+ default=None,
61
+ description="Browser-reachable single-frame snapshot URL, if available.",
62
+ )
63
+ source: str = Field(
64
+ default="",
65
+ description="Publishing connector's source id (the ingress path segment).",
66
+ )
67
+ width: int | None = Field(default=None, description="Frame width in pixels.")
68
+ height: int | None = Field(default=None, description="Frame height in pixels.")
69
+ rotation: float = Field(
70
+ default=0,
71
+ description="Clockwise display rotation in degrees.",
72
+ )
@@ -0,0 +1,167 @@
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+ from enum import Enum
4
+ from typing import Generic, Optional, TypeVar
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ T = TypeVar("T")
9
+
10
+ _example_event_id = uuid.uuid4()
11
+ _example_datetime = datetime.now(timezone.utc)
12
+
13
+
14
+ class SpecVersion(str, Enum):
15
+ """
16
+ The version of the CloudEvents specification which an event uses.
17
+ This enables the interpretation of the context.
18
+
19
+ Currently, this attribute will only have the 'major' and 'minor' version numbers
20
+ included in it. This allows for 'patch' changes to the specification to be made
21
+ without changing this property's value in the serialization.
22
+ """
23
+
24
+ v0_3 = "0.3"
25
+ v1_0 = "1.0"
26
+
27
+
28
+ def date_factory() -> datetime:
29
+ """
30
+ Factory function to create a datetime object with the current UTC time.
31
+ This is used as a default value for the `time` field in CloudEvent.
32
+ """
33
+ return datetime.now(timezone.utc)
34
+
35
+
36
+ def uuid_factory() -> str:
37
+ """
38
+ Factory function to create a UUID string.
39
+ This is used as a default value for the `id` field in CloudEvent.
40
+ """
41
+ return str(uuid.uuid4())
42
+
43
+
44
+ class CloudEvent(BaseModel, Generic[T]):
45
+ data: Optional[T] = Field(
46
+ title="Event Data",
47
+ description=(
48
+ "CloudEvents MAY include domain-specific information about the occurrence."
49
+ " When present, this information will be encapsulated within data.It is"
50
+ " encoded into a media format which is specified by the datacontenttype"
51
+ " attribute (e.g. application/json), and adheres to the dataschema format"
52
+ " when those respective attributes are present."
53
+ ),
54
+ examples=[{"message": "This is an example of event data"}],
55
+ )
56
+ source: str = Field(
57
+ title="Event Source",
58
+ description=(
59
+ "Identifies the context in which an event happened. Often this will include"
60
+ " information such as the type of the event source, the organization"
61
+ " publishing the event or the process that produced the event. The exact"
62
+ " syntax and semantics behind the data encoded in the URI is defined by the"
63
+ " event producer.\n"
64
+ "\n"
65
+ "Producers MUST ensure that source + id is unique for"
66
+ " each distinct event.\n"
67
+ "\n"
68
+ "An application MAY assign a unique source to each"
69
+ " distinct producer, which makes it easy to produce unique IDs since no"
70
+ " other producer will have the same source. The application MAY use UUIDs,"
71
+ " URNs, DNS authorities or an application-specific scheme to create unique"
72
+ " source identifiers.\n"
73
+ "\n"
74
+ "A source MAY include more than one producer. In"
75
+ " that case the producers MUST collaborate to ensure that source + id is"
76
+ " unique for each distinct event."
77
+ ),
78
+ examples=["https://example.com/myapp/instance1"],
79
+ )
80
+ id: str = Field(
81
+ title="Event ID",
82
+ description=(
83
+ "Identifies the event. Producers MUST ensure that source + id is unique for"
84
+ " each distinct event. If a duplicate event is re-sent (e.g. due to a"
85
+ " network error) it MAY have the same id. Consumers MAY assume that Events"
86
+ " with identical source and id are duplicates. MUST be unique within the"
87
+ " scope of the producer"
88
+ ),
89
+ examples=[_example_event_id],
90
+ default_factory=uuid_factory,
91
+ )
92
+ type: str = Field(
93
+ title="Event Type",
94
+ description=(
95
+ "This attribute contains a value describing the type of event related to"
96
+ " the originating occurrence. Often this attribute is used for routing,"
97
+ " observability, policy enforcement, etc. The format of this is producer"
98
+ " defined and might include information such as the version of the type"
99
+ ),
100
+ examples=["com.github.pull_request.opened"],
101
+ )
102
+ specversion: SpecVersion = Field(
103
+ title="Specification Version",
104
+ description=(
105
+ "The version of the CloudEvents specification which the event uses. This"
106
+ " enables the interpretation of the context.\n"
107
+ "\n"
108
+ "Currently, this attribute will only have the 'major'"
109
+ " and 'minor' version numbers included in it. This allows for 'patch'"
110
+ " changes to the specification to be made without changing this property's"
111
+ " value in the serialization."
112
+ ),
113
+ examples=[SpecVersion.v1_0],
114
+ default=SpecVersion.v1_0,
115
+ )
116
+ time: Optional[datetime] = Field(
117
+ title="Occurrence Time",
118
+ description=(
119
+ " Timestamp of when the occurrence happened. If the time of the occurrence"
120
+ " cannot be determined then this attribute MAY be set to some other time"
121
+ " (such as the current time) by the CloudEvents producer, however all"
122
+ " producers for the same source MUST be consistent in this respect. In"
123
+ " other words, either they all use the actual time of the occurrence or"
124
+ " they all use the same algorithm to determine the value used."
125
+ ),
126
+ examples=[_example_datetime],
127
+ default_factory=date_factory,
128
+ )
129
+ subject: Optional[str] = Field(
130
+ title="Event Subject",
131
+ description=(
132
+ "This describes the subject of the event in the context of the event"
133
+ " producer (identified by source). In publish-subscribe scenarios, a"
134
+ " subscriber will typically subscribe to events emitted by a source, but"
135
+ " the source identifier alone might not be sufficient as a qualifier for"
136
+ " any specific event if the source context has internal"
137
+ " sub-structure.\n"
138
+ "\n"
139
+ "Identifying the subject of the event in context"
140
+ " metadata (opposed to only in the data payload) is particularly helpful in"
141
+ " generic subscription filtering scenarios where middleware is unable to"
142
+ " interpret the data content. In the above example, the subscriber might"
143
+ " only be interested in blobs with names ending with '.jpg' or '.jpeg' and"
144
+ " the subject attribute allows for constructing a simple and efficient"
145
+ " string-suffix filter for that subset of events."
146
+ ),
147
+ examples=["123"],
148
+ default=None,
149
+ )
150
+ datacontenttype: Optional[str] = Field(
151
+ title="Event Data Content Type",
152
+ description=(
153
+ "Content type of data value. This attribute enables data to carry any type"
154
+ " of content, whereby format and encoding might differ from that of the"
155
+ " chosen event format."
156
+ ),
157
+ examples=["application/json", "text/xml"],
158
+ default=None,
159
+ )
160
+ dataschema: Optional[str] = Field(
161
+ title="Event Data Schema",
162
+ description=(
163
+ "Identifies the schema that data adheres to. "
164
+ "Incompatible changes to the schema SHOULD be reflected by a different URI"
165
+ ),
166
+ default=None,
167
+ )
@@ -0,0 +1,22 @@
1
+ """Minimal, cross-package constant for control-plane's active-execution KV
2
+ entries.
3
+
4
+ Used by ADR-032's demand-map reconciliation (the IO action service checking
5
+ whether a run it still has demand recorded for is actually still executing).
6
+ The run_id itself is parsed directly from the KV key
7
+ (``runs.active.{run_id}``), not the stored value — control-plane's
8
+ ``ExecutionState`` value shape is control-plane-internal
9
+ (``control-plane/app/core/execution_state.py``, e.g. ``cp_instance_id``) and
10
+ deliberately not mirrored here, to avoid drift if its fields change
11
+ independently.
12
+
13
+ ADR-006 (2026-07 revision) replaced control-plane's single well-known
14
+ ``runs.current`` key with one key per active run (``runs.active.{run_id}``)
15
+ so N runs can be tracked concurrently — a reconciliation consumer watches
16
+ every key under this prefix instead of reading one.
17
+ """
18
+
19
+ # Must match control-plane/app/core/execution_state.py::RUNS_ACTIVE_PREFIX.
20
+ # Not re-exported from there (control-plane and this package are consumed
21
+ # independently), so keep the two literals in sync by hand if either changes.
22
+ RUNS_ACTIVE_PREFIX = "runs.active."
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+
4
+ class Heartbeat(BaseModel):
5
+ """Represents a heartbeat message from a service."""
6
+
7
+ model_config = ConfigDict(extra="forbid")
8
+
9
+ last_seen: int = Field(
10
+ ..., description="The last seen timestamp in Unix epoch format"
11
+ )
@@ -0,0 +1,185 @@
1
+ """Single source of truth for every per-run KV key + bucket name
2
+ in the MotionRoutine V2 programming-session pipeline.
3
+
4
+ The same class of drift this module guards against — wire-string
5
+ divergence between writer and reader as the codebase evolves — is
6
+ exactly what ``subjects.py`` already handles for NATS subjects.
7
+ This file is the KV-side twin.
8
+
9
+ Before this module existed, ``novaflow_programming_sessions`` was
10
+ spelled as a literal in 8+ places (run.py, motion_routine.py,
11
+ useNats.ts, programmingNats.ts, settings.py, multiple e2e tests).
12
+ A typo in one site or a future rename to a per-cell bucket name
13
+ would silently desync; the symptom would be "canopy shows stale
14
+ state and the action runs anyway", which is exactly the failure
15
+ mode KV-as-source-of-truth is supposed to prevent.
16
+
17
+ Python helpers below produce the canonical strings; the TypeScript
18
+ twin at ``canopy/src/types/generated/programmingKvKeys.ts`` is
19
+ GENERATED from this module by ``generate_comm_registries.py`` (#251),
20
+ so it can no longer drift by hand. Sibling test files still assert
21
+ the exact strings on both sides as a contract pin.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+
27
+ PROGRAMMING_SESSIONS_BUCKET = "novaflow_programming_sessions"
28
+ """Name of the JetStream KV bucket that holds the **small, durable / low-churn**
29
+ per-run programming-session keys — ``terminal`` and ``speed`` (until the latter
30
+ moves to the operator-control channel, issue #248). See ADR 022 + spec §14.1
31
+ for the ownership map.
32
+
33
+ The **high-churn** keys (``cursor``/``scrub_state``/``progress``) were split
34
+ out into :data:`PROGRAMMING_LIVE_BUCKET`. The **large** ``cached_plan`` keys
35
+ (per-segment full trajectories, ~hundreds of KB each, + the cross-run
36
+ ``routine.{hash}.*`` reuse cache) were split out into
37
+ :data:`PROGRAMMING_PLANS_BUCKET` — they accumulated to this bucket's
38
+ ``max_bytes`` cap and, because nats-py KV streams use ``discard=new``, the
39
+ full bucket then *rejected* the tiny but critical ``terminal`` write
40
+ (``10077 'maximum bytes exceeded'``), stranding the operator UI in "Running".
41
+ Keeping this bucket terminal/speed-only means it stays small and never fills."""
42
+
43
+
44
+ PROGRAMMING_PLANS_BUCKET = "novaflow_programming_plans"
45
+ """Name of the JetStream KV bucket that holds the **large, evictable** pre-plan
46
+ cache written by the MotionRoutine executor during planning:
47
+
48
+ * ``{run_id}.cached_plan`` — per-run summary (metadata only)
49
+ * ``{run_id}.cached_plan.{seg_idx}`` — per-run full per-segment trajectory
50
+ * ``routine.{hash}.cached_plan[.{seg_idx}]`` — the cross-run reuse cache
51
+ keyed by routine hash (spec §7.5), so an unchanged routine skips PRE-PLAN.
52
+
53
+ Split out of :data:`PROGRAMMING_SESSIONS_BUCKET` so this churning, large-payload
54
+ cache can never starve the critical ``terminal`` write — the whole point of the
55
+ split. Uses the KV default ``discard=new`` (this module's rule: KV never uses
56
+ ``discard=old``, which evicts by *write* age — a hot cross-run reuse key is
57
+ written once but read forever, so ``discard=old`` would drop it while hot). At
58
+ the cap, a *new* plan write is skipped (best-effort → that run re-plans) while
59
+ the existing — including the hot reuse — cache survives to its TTL; the 24h TTL
60
+ then frees space. ``terminal`` lives in its own bucket and is never affected
61
+ either way."""
62
+
63
+
64
+ PROGRAMMING_LIVE_BUCKET = "novaflow_programming_live"
65
+ """Name of the JetStream KV bucket that holds the **high-churn, ephemeral,
66
+ in-run-only** display state — ``cursor`` (re-written ~5 Hz after coalescing),
67
+ ``scrub_state``, and per-item ``progress.*``.
68
+
69
+ Split out from :data:`PROGRAMMING_SESSIONS_BUCKET` to fix the issue #186 OOM:
70
+ a constantly-overwritten key spawns a superseded revision per write, and those
71
+ revisions can only be reclaimed once the stream's ``first_seq`` advances past
72
+ them. A lingering low-sequence durable key (``terminal``, kept 24h for the
73
+ post-run banner; ``cached_plan``, kept for Run-again reuse) **pins**
74
+ ``first_seq``, so the cursor's superseded revisions accumulate as interior
75
+ tombstones until the durable key expires — the proven OOM mechanism. With the
76
+ churn isolated in its own bucket (no durable key to pin it) the revisions
77
+ self-reclaim per run; the bucket also runs the ephemeral storage profile
78
+ (memory/R1/cap + short TTL backstop, ADR-017 split rule).
79
+
80
+ Canopy watches **both** buckets (``{run}.>`` on each); writers target the
81
+ bucket each key belongs to."""
82
+
83
+
84
+ def cursor_key(run_id: str) -> str:
85
+ """``{run_id}.cursor`` — per-frame float cursor + item index."""
86
+ return f"{run_id}.cursor"
87
+
88
+
89
+ def scrub_state_key(run_id: str) -> str:
90
+ """``{run_id}.scrub_state`` — canopy reads this on reconnect to
91
+ re-derive the scrubber's current phase (running / paused / etc.)."""
92
+ return f"{run_id}.scrub_state"
93
+
94
+
95
+ def terminal_key(run_id: str) -> str:
96
+ """``{run_id}.terminal`` — "the run is done" signal canopy reads
97
+ to transition out of running state. Carries
98
+ ``{"status": ..., "message": ..., "data": ...}`` JSON."""
99
+ return f"{run_id}.terminal"
100
+
101
+
102
+ def speed_key(run_id: str) -> str:
103
+ """``{run_id}.speed`` — canopy's slider writes here; the action
104
+ watches and emits ``PlaybackSpeedRequest`` mid-flight."""
105
+ return f"{run_id}.speed"
106
+
107
+
108
+ def progress_item_key(run_id: str, item_index: int) -> str:
109
+ """``{run_id}.progress.{item_index}`` — per-item lifecycle
110
+ ("running" / "done" / "failed"). Wait/IO items use this; motion
111
+ segments emit through the same path."""
112
+ return f"{run_id}.progress.{item_index}"
113
+
114
+
115
+ def progress_key_prefix(run_id: str) -> str:
116
+ """``{run_id}.progress.`` — prefix used for KV-purge scans
117
+ (e.g. step-back rollbacks). Matches all ``progress_item_key``
118
+ values for this run."""
119
+ return f"{run_id}.progress."
120
+
121
+
122
+ def cached_plan_summary_key(run_id: str) -> str:
123
+ """``{run_id}.cached_plan`` — light-payload summary (no
124
+ ``joint_trajectory``) canopy can read in one shot for display.
125
+ Per-segment full payloads live at
126
+ ``cached_plan_segment_key(run_id, idx)``."""
127
+ return f"{run_id}.cached_plan"
128
+
129
+
130
+ def cached_plan_segment_key(run_id: str, segment_idx: int) -> str:
131
+ """``{run_id}.cached_plan.{segment_idx}`` — full
132
+ ``JointTrajectory`` payload for one segment. PR-23 Run-again
133
+ reuse + the action's PRE-PLAN write target the same key."""
134
+ return f"{run_id}.cached_plan.{segment_idx}"
135
+
136
+
137
+ def cross_run_cached_plan_summary_key(routine_hash: str) -> str:
138
+ """``routine.{routine_hash}.cached_plan`` — cross-run reuse
139
+ summary, keyed on the hash of ``MotionRoutineInput`` (spec
140
+ §7.5). Same shape as ``cached_plan_summary_key`` but lives
141
+ under a different prefix so per-run purges don't touch it."""
142
+ return f"routine.{routine_hash}.cached_plan"
143
+
144
+
145
+ def cross_run_cached_plan_segment_key(
146
+ routine_hash: str, segment_idx: int,
147
+ ) -> str:
148
+ """``routine.{routine_hash}.cached_plan.{segment_idx}`` — per-
149
+ segment full payload for cross-run reuse. PR-23."""
150
+ return f"routine.{routine_hash}.cached_plan.{segment_idx}"
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Per-run suffixes whose lifetime is bounded by the run, grouped by which
155
+ # bucket they live in. Used by ``control-plane/app/core/programming_kv.py``
156
+ # to purge each bucket on TERMINUS, uniformly across tree-mode + programming-
157
+ # mode runs. Since the #186 split, the high-churn display keys live in
158
+ # ``PROGRAMMING_LIVE_BUCKET`` and the rest in ``PROGRAMMING_SESSIONS_BUCKET``,
159
+ # so the purge must target each suffix in its OWN bucket.
160
+ # ---------------------------------------------------------------------------
161
+
162
+ LIVE_BUCKET_PURGE_SUFFIXES: tuple[str, ...] = (
163
+ "cursor",
164
+ "scrub_state",
165
+ # NOTE: per-item ``progress.*`` keys also live in the LIVE bucket but are
166
+ # variable-cardinality — the purge discovers them by prefix scan
167
+ # (``progress_key_prefix``), not from this fixed-suffix tuple.
168
+ )
169
+ """Fixed-suffix keys purged from :data:`PROGRAMMING_LIVE_BUCKET`."""
170
+
171
+ SESSIONS_BUCKET_PURGE_SUFFIXES: tuple[str, ...] = (
172
+ "speed",
173
+ )
174
+ """Fixed-suffix keys purged from :data:`PROGRAMMING_SESSIONS_BUCKET`.
175
+
176
+ NOTE: ``terminal`` is NOT purged — canopy displays it post-run and the
177
+ operator dismisses it. ``cached_plan`` is exempt for Run-again reuse
178
+ (spec §14.3 step 3; bucket-level ``max_age`` provides the TTL). Both
179
+ exemptions are load-bearing — don't add them here."""
180
+
181
+ PER_RUN_PURGE_SUFFIXES: tuple[str, ...] = (
182
+ *LIVE_BUCKET_PURGE_SUFFIXES,
183
+ *SESSIONS_BUCKET_PURGE_SUFFIXES,
184
+ )
185
+ """Back-compat union of every fixed purge suffix across both buckets."""