piphi-runtime-kit-python 0.3.0__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.
@@ -0,0 +1,180 @@
1
+ from .runtime.auth import (
2
+ RUNTIME_CONTAINER_ID_HEADER_NAME,
3
+ RUNTIME_INTERNAL_TOKEN_HEADER_NAME,
4
+ RuntimeAuthContext,
5
+ RuntimeAuthHeaders,
6
+ build_runtime_auth_headers,
7
+ extract_runtime_auth_headers,
8
+ format_runtime_auth_sync_log,
9
+ mask_token,
10
+ )
11
+ from .runtime.config_sync import ConfigSyncCoordinator, build_sync_response, reconcile_config_ids
12
+ from .runtime.configuration import (
13
+ build_config_apply_response,
14
+ build_config_remove_response,
15
+ format_config_apply_log,
16
+ redact_config_secrets,
17
+ validate_typed_config,
18
+ validate_typed_configs,
19
+ )
20
+ from .runtime.context import RuntimeContext
21
+ from .runtime.discovery import (
22
+ build_discovery_response,
23
+ format_discovery_attempt_log,
24
+ normalize_discovery_inputs,
25
+ )
26
+ from .runtime.errors import (
27
+ CoreAuthError,
28
+ CoreDeliveryError,
29
+ CoreRouteNotFoundError,
30
+ CoreServerError,
31
+ CoreTimeoutError,
32
+ CoreUnavailableError,
33
+ CoreUnexpectedResponseError,
34
+ classify_core_delivery_error,
35
+ )
36
+ from .runtime.dispatch import (
37
+ build_local_event_record,
38
+ dispatch_event_delivery,
39
+ dispatch_telemetry_delivery,
40
+ schedule_event_delivery,
41
+ schedule_telemetry_delivery,
42
+ )
43
+ from .runtime.events import (
44
+ EventClient,
45
+ build_core_event_payload,
46
+ build_event_ingest_response,
47
+ build_event_list_response,
48
+ format_event_log,
49
+ normalize_event_payload,
50
+ )
51
+ from .runtime.health import build_runtime_diagnostics_response, build_runtime_health_response
52
+ from .runtime.lifespan import (
53
+ DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
54
+ DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
55
+ DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME,
56
+ bind_core_http_client,
57
+ bootstrap_runtime_auth_from_env,
58
+ runtime_lifespan,
59
+ )
60
+ from .runtime.mqtt import (
61
+ DEFAULT_SOURCE_TOPIC_PREFIX,
62
+ MqttBrokerConfig,
63
+ MqttJsonClient,
64
+ build_source_errors_topic,
65
+ build_source_model_packets_topic,
66
+ build_source_packet_envelope,
67
+ build_source_packets_topic,
68
+ build_source_status_topic,
69
+ build_source_topic_root,
70
+ )
71
+ from .runtime.registry import RuntimeRegistry
72
+ from .runtime.state import RuntimeProcessState
73
+ from .runtime.starter import RuntimeStarter, create_runtime_starter
74
+ from .runtime.tasks import create_tracked_task, shutdown_background_tasks, track_background_task
75
+ from .runtime.telemetry import TelemetryClient, build_core_auth_headers
76
+ from .schemas import (
77
+ CoreEventPayload,
78
+ EventSeverity,
79
+ EventTransport,
80
+ IntegrationCommandRequest,
81
+ IntegrationDiscoveryRequest,
82
+ IntegrationDiscoveryResponse,
83
+ IntegrationEventIngestResponse,
84
+ IntegrationEventListResponse,
85
+ IntegrationEventRequest,
86
+ RuntimeConfig,
87
+ RuntimeConfigApplyResponse,
88
+ RuntimeConfigRemoveResponse,
89
+ RuntimeConfigSnapshot,
90
+ RuntimeConfigSyncResponse,
91
+ RuntimeDiagnosticsResponse,
92
+ RuntimeHealthResponse,
93
+ TelemetryPayload,
94
+ )
95
+
96
+ __all__ = [
97
+ "ConfigSyncCoordinator",
98
+ "CoreAuthError",
99
+ "CoreDeliveryError",
100
+ "CoreRouteNotFoundError",
101
+ "CoreServerError",
102
+ "CoreTimeoutError",
103
+ "CoreUnavailableError",
104
+ "CoreUnexpectedResponseError",
105
+ "DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS",
106
+ "DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME",
107
+ "DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME",
108
+ "RUNTIME_CONTAINER_ID_HEADER_NAME",
109
+ "RUNTIME_INTERNAL_TOKEN_HEADER_NAME",
110
+ "IntegrationCommandRequest",
111
+ "IntegrationDiscoveryRequest",
112
+ "IntegrationDiscoveryResponse",
113
+ "IntegrationEventRequest",
114
+ "RuntimeAuthContext",
115
+ "RuntimeAuthHeaders",
116
+ "RuntimeConfig",
117
+ "RuntimeConfigApplyResponse",
118
+ "RuntimeConfigRemoveResponse",
119
+ "RuntimeConfigSnapshot",
120
+ "RuntimeConfigSyncResponse",
121
+ "RuntimeContext",
122
+ "RuntimeRegistry",
123
+ "RuntimeProcessState",
124
+ "RuntimeStarter",
125
+ "RuntimeDiagnosticsResponse",
126
+ "RuntimeHealthResponse",
127
+ "TelemetryClient",
128
+ "EventClient",
129
+ "TelemetryPayload",
130
+ "CoreEventPayload",
131
+ "EventSeverity",
132
+ "EventTransport",
133
+ "IntegrationEventIngestResponse",
134
+ "IntegrationEventListResponse",
135
+ "build_discovery_response",
136
+ "classify_core_delivery_error",
137
+ "build_core_event_payload",
138
+ "build_event_ingest_response",
139
+ "build_event_list_response",
140
+ "build_local_event_record",
141
+ "build_core_auth_headers",
142
+ "build_config_apply_response",
143
+ "build_config_remove_response",
144
+ "build_runtime_auth_headers",
145
+ "build_runtime_diagnostics_response",
146
+ "build_runtime_health_response",
147
+ "build_sync_response",
148
+ "bind_core_http_client",
149
+ "bootstrap_runtime_auth_from_env",
150
+ "create_tracked_task",
151
+ "create_runtime_starter",
152
+ "dispatch_event_delivery",
153
+ "dispatch_telemetry_delivery",
154
+ "extract_runtime_auth_headers",
155
+ "format_config_apply_log",
156
+ "format_discovery_attempt_log",
157
+ "format_event_log",
158
+ "format_runtime_auth_sync_log",
159
+ "mask_token",
160
+ "MqttBrokerConfig",
161
+ "MqttJsonClient",
162
+ "normalize_discovery_inputs",
163
+ "normalize_event_payload",
164
+ "redact_config_secrets",
165
+ "reconcile_config_ids",
166
+ "runtime_lifespan",
167
+ "schedule_event_delivery",
168
+ "schedule_telemetry_delivery",
169
+ "shutdown_background_tasks",
170
+ "track_background_task",
171
+ "validate_typed_config",
172
+ "validate_typed_configs",
173
+ "DEFAULT_SOURCE_TOPIC_PREFIX",
174
+ "build_source_errors_topic",
175
+ "build_source_model_packets_topic",
176
+ "build_source_packet_envelope",
177
+ "build_source_packets_topic",
178
+ "build_source_status_topic",
179
+ "build_source_topic_root",
180
+ ]
@@ -0,0 +1,13 @@
1
+ """Optional framework adapters layered on top of the core runtime helpers."""
2
+
3
+ from .fastapi import (
4
+ get_payload_container_id,
5
+ sync_runtime_auth_from_fastapi_payload,
6
+ sync_runtime_auth_from_fastapi_request,
7
+ )
8
+
9
+ __all__ = [
10
+ "get_payload_container_id",
11
+ "sync_runtime_auth_from_fastapi_payload",
12
+ "sync_runtime_auth_from_fastapi_request",
13
+ ]
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ """Optional FastAPI-facing helpers for common PiPhi runtime patterns."""
4
+
5
+ from typing import TYPE_CHECKING, Any, Mapping
6
+
7
+ from ..runtime.auth import RuntimeAuthHeaders
8
+ from ..runtime.context import RuntimeContext
9
+
10
+ if TYPE_CHECKING:
11
+ from fastapi import Request
12
+
13
+
14
+ def get_payload_container_id(
15
+ payload: object | Mapping[str, Any] | None,
16
+ *,
17
+ attribute_name: str = "container_id",
18
+ ) -> str | None:
19
+ """Return a normalized container id from a payload object or mapping.
20
+
21
+ This lets integrations share one helper across Pydantic models, dataclasses,
22
+ or plain dictionaries when wiring request auth to PiPhi container scope.
23
+ """
24
+ if payload is None:
25
+ return None
26
+
27
+ value: Any
28
+ if isinstance(payload, Mapping):
29
+ value = payload.get(attribute_name)
30
+ else:
31
+ value = getattr(payload, attribute_name, None)
32
+
33
+ normalized = str(value or "").strip()
34
+ return normalized or None
35
+
36
+
37
+ def sync_runtime_auth_from_fastapi_request(
38
+ runtime_context: RuntimeContext,
39
+ request: "Request",
40
+ *,
41
+ payload_container_id: str | None = None,
42
+ ) -> RuntimeAuthHeaders:
43
+ """Sync runtime auth from a FastAPI request's headers into the context.
44
+
45
+ Typical usage:
46
+ parsed = sync_runtime_auth_from_fastapi_request(
47
+ runtime_context,
48
+ request,
49
+ payload_container_id=payload.container_id,
50
+ )
51
+ """
52
+ return runtime_context.auth.sync_from_headers(
53
+ request.headers,
54
+ payload_container_id=payload_container_id,
55
+ )
56
+
57
+
58
+ def sync_runtime_auth_from_fastapi_payload(
59
+ runtime_context: RuntimeContext,
60
+ request: "Request",
61
+ payload: object | Mapping[str, Any] | None,
62
+ *,
63
+ attribute_name: str = "container_id",
64
+ ) -> RuntimeAuthHeaders:
65
+ """Sync request auth using a payload object that carries container scope."""
66
+ return sync_runtime_auth_from_fastapi_request(
67
+ runtime_context,
68
+ request,
69
+ payload_container_id=get_payload_container_id(
70
+ payload,
71
+ attribute_name=attribute_name,
72
+ ),
73
+ )
@@ -0,0 +1,13 @@
1
+ """Convenience re-exports for the optional FastAPI adapter layer."""
2
+
3
+ from .adapters.fastapi import (
4
+ get_payload_container_id,
5
+ sync_runtime_auth_from_fastapi_payload,
6
+ sync_runtime_auth_from_fastapi_request,
7
+ )
8
+
9
+ __all__ = [
10
+ "get_payload_container_id",
11
+ "sync_runtime_auth_from_fastapi_payload",
12
+ "sync_runtime_auth_from_fastapi_request",
13
+ ]
@@ -0,0 +1,124 @@
1
+ from .auth import (
2
+ RUNTIME_CONTAINER_ID_HEADER_NAME,
3
+ RUNTIME_INTERNAL_TOKEN_HEADER_NAME,
4
+ RuntimeAuthContext,
5
+ RuntimeAuthHeaders,
6
+ build_runtime_auth_headers,
7
+ extract_runtime_auth_headers,
8
+ format_runtime_auth_sync_log,
9
+ mask_token,
10
+ )
11
+ from .config_sync import ConfigSyncCoordinator, build_sync_response, reconcile_config_ids
12
+ from .configuration import (
13
+ build_config_apply_response,
14
+ build_config_remove_response,
15
+ format_config_apply_log,
16
+ redact_config_secrets,
17
+ validate_typed_config,
18
+ validate_typed_configs,
19
+ )
20
+ from .context import RuntimeContext
21
+ from .discovery import (
22
+ build_discovery_response,
23
+ format_discovery_attempt_log,
24
+ normalize_discovery_inputs,
25
+ )
26
+ from .errors import (
27
+ CoreAuthError,
28
+ CoreDeliveryError,
29
+ CoreRouteNotFoundError,
30
+ CoreServerError,
31
+ CoreTimeoutError,
32
+ CoreUnavailableError,
33
+ CoreUnexpectedResponseError,
34
+ classify_core_delivery_error,
35
+ )
36
+ from .dispatch import (
37
+ build_local_event_record,
38
+ dispatch_event_delivery,
39
+ dispatch_telemetry_delivery,
40
+ schedule_event_delivery,
41
+ schedule_telemetry_delivery,
42
+ )
43
+ from .events import (
44
+ EventClient,
45
+ build_core_event_payload,
46
+ build_event_ingest_response,
47
+ build_event_list_response,
48
+ format_event_log,
49
+ normalize_event_payload,
50
+ )
51
+ from .health import build_runtime_diagnostics_response, build_runtime_health_response
52
+ from .lifespan import (
53
+ DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
54
+ DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME,
55
+ DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME,
56
+ bind_core_http_client,
57
+ bootstrap_runtime_auth_from_env,
58
+ runtime_lifespan,
59
+ )
60
+ from .registry import RuntimeRegistry
61
+ from .state import RuntimeProcessState
62
+ from .starter import RuntimeStarter, create_runtime_starter
63
+ from .tasks import create_tracked_task, shutdown_background_tasks, track_background_task
64
+ from .telemetry import TelemetryClient, build_core_auth_headers
65
+
66
+ __all__ = [
67
+ "ConfigSyncCoordinator",
68
+ "CoreAuthError",
69
+ "CoreDeliveryError",
70
+ "CoreRouteNotFoundError",
71
+ "CoreServerError",
72
+ "CoreTimeoutError",
73
+ "CoreUnavailableError",
74
+ "CoreUnexpectedResponseError",
75
+ "RUNTIME_CONTAINER_ID_HEADER_NAME",
76
+ "RUNTIME_INTERNAL_TOKEN_HEADER_NAME",
77
+ "RuntimeAuthContext",
78
+ "RuntimeAuthHeaders",
79
+ "RuntimeContext",
80
+ "RuntimeRegistry",
81
+ "RuntimeProcessState",
82
+ "RuntimeStarter",
83
+ "TelemetryClient",
84
+ "EventClient",
85
+ "build_local_event_record",
86
+ "build_config_apply_response",
87
+ "build_config_remove_response",
88
+ "build_discovery_response",
89
+ "build_core_event_payload",
90
+ "build_event_ingest_response",
91
+ "build_event_list_response",
92
+ "build_core_auth_headers",
93
+ "build_runtime_auth_headers",
94
+ "build_sync_response",
95
+ "bind_core_http_client",
96
+ "bootstrap_runtime_auth_from_env",
97
+ "classify_core_delivery_error",
98
+ "create_tracked_task",
99
+ "create_runtime_starter",
100
+ "dispatch_event_delivery",
101
+ "dispatch_telemetry_delivery",
102
+ "DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS",
103
+ "DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME",
104
+ "DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME",
105
+ "extract_runtime_auth_headers",
106
+ "format_config_apply_log",
107
+ "format_discovery_attempt_log",
108
+ "format_event_log",
109
+ "format_runtime_auth_sync_log",
110
+ "mask_token",
111
+ "build_runtime_diagnostics_response",
112
+ "build_runtime_health_response",
113
+ "normalize_discovery_inputs",
114
+ "normalize_event_payload",
115
+ "redact_config_secrets",
116
+ "reconcile_config_ids",
117
+ "runtime_lifespan",
118
+ "schedule_event_delivery",
119
+ "schedule_telemetry_delivery",
120
+ "shutdown_background_tasks",
121
+ "track_background_task",
122
+ "validate_typed_config",
123
+ "validate_typed_configs",
124
+ ]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for resolving PiPhi runtime auth across requests and background work."""
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Mapping
8
+
9
+
10
+ DEFAULT_INTERNAL_TOKEN_ENV_NAME = "PIPHI_INTEGRATION_INTERNAL_TOKEN"
11
+ RUNTIME_CONTAINER_ID_HEADER_NAME = "X-Container-Id"
12
+ RUNTIME_INTERNAL_TOKEN_HEADER_NAME = "X-PiPhi-Integration-Token"
13
+
14
+
15
+ def mask_token(token: str | None) -> str:
16
+ """Return a log-safe representation of an internal runtime token."""
17
+ normalized = (token or "").strip()
18
+ if not normalized:
19
+ return "missing"
20
+ if len(normalized) <= 10:
21
+ return "present"
22
+ return f"{normalized[:6]}...{normalized[-4:]}"
23
+
24
+
25
+ def _get_header_value(headers: Mapping[str, Any], name: str) -> str:
26
+ for key, value in headers.items():
27
+ if str(key).lower() == name.lower():
28
+ return str(value or "").strip()
29
+ return ""
30
+
31
+
32
+ @dataclass(slots=True, frozen=True)
33
+ class RuntimeAuthHeaders:
34
+ """Normalized PiPhi runtime auth values extracted from request headers."""
35
+ container_id: str = ""
36
+ internal_token: str = ""
37
+
38
+
39
+ def extract_runtime_auth_headers(headers: Mapping[str, Any]) -> RuntimeAuthHeaders:
40
+ """Extract PiPhi runtime auth headers from any case-insensitive header mapping."""
41
+ return RuntimeAuthHeaders(
42
+ container_id=_get_header_value(headers, RUNTIME_CONTAINER_ID_HEADER_NAME),
43
+ internal_token=_get_header_value(headers, RUNTIME_INTERNAL_TOKEN_HEADER_NAME),
44
+ )
45
+
46
+
47
+ def build_runtime_auth_headers(
48
+ *,
49
+ container_id: str,
50
+ internal_token: str | None,
51
+ ) -> dict[str, str]:
52
+ """Build the standard PiPhi runtime auth headers for outbound Core calls."""
53
+ headers = {RUNTIME_CONTAINER_ID_HEADER_NAME: container_id}
54
+ if internal_token:
55
+ headers[RUNTIME_INTERNAL_TOKEN_HEADER_NAME] = internal_token
56
+ return headers
57
+
58
+
59
+ def format_runtime_auth_sync_log(
60
+ parsed_headers: RuntimeAuthHeaders,
61
+ *,
62
+ payload_container_id: str | None = None,
63
+ ) -> str:
64
+ """Build a consistent log message for request-scoped runtime auth sync.
65
+
66
+ This keeps token masking and field naming consistent across integrations
67
+ that want to log the incoming runtime auth context for debugging.
68
+ """
69
+ return (
70
+ "runtime_internal_auth "
71
+ f"header_container_id={parsed_headers.container_id or 'missing'} "
72
+ f"payload_container_id={payload_container_id or 'missing'} "
73
+ f"token={mask_token(parsed_headers.internal_token)}"
74
+ )
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class RuntimeAuthContext:
79
+ """In-memory auth context shared by a runtime process.
80
+
81
+ Integrations can keep the most recent container scope and internal token here,
82
+ then reuse it for outbound telemetry or config sync calls.
83
+ """
84
+ container_id: str = ""
85
+ internal_token: str = ""
86
+
87
+ def update(
88
+ self,
89
+ *,
90
+ container_id: str | None = None,
91
+ internal_token: str | None = None,
92
+ ) -> None:
93
+ """Update the stored auth context with any non-empty incoming values."""
94
+ if container_id is not None:
95
+ resolved_container_id = str(container_id).strip()
96
+ if resolved_container_id:
97
+ self.container_id = resolved_container_id
98
+
99
+ if internal_token is not None:
100
+ resolved_internal_token = str(internal_token).strip()
101
+ if resolved_internal_token:
102
+ self.internal_token = resolved_internal_token
103
+
104
+ def sync_from_headers(
105
+ self,
106
+ headers: Mapping[str, Any],
107
+ *,
108
+ payload_container_id: str | None = None,
109
+ ) -> RuntimeAuthHeaders:
110
+ """Update the context from request headers and return the parsed values.
111
+
112
+ Typical usage:
113
+ parsed = runtime_context.auth.sync_from_headers(
114
+ request.headers,
115
+ payload_container_id=payload.container_id,
116
+ )
117
+ """
118
+ parsed_headers = extract_runtime_auth_headers(headers)
119
+ self.update(
120
+ container_id=parsed_headers.container_id or payload_container_id,
121
+ internal_token=parsed_headers.internal_token,
122
+ )
123
+ return parsed_headers
124
+
125
+ def resolve(
126
+ self,
127
+ *,
128
+ container_id: str | None = None,
129
+ token_env_name: str = DEFAULT_INTERNAL_TOKEN_ENV_NAME,
130
+ ) -> tuple[str, str]:
131
+ """Resolve the effective container scope and internal token for a request."""
132
+ resolved_container_id = str(container_id or "").strip() or self.container_id
133
+ resolved_internal_token = self.internal_token or (os.getenv(token_env_name) or "").strip()
134
+ return resolved_container_id, resolved_internal_token
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ """Helpers for basic config-sync orchestration and generation tracking."""
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Awaitable, Callable, TypeVar
7
+
8
+ from ..schemas import RuntimeConfig, RuntimeConfigSnapshot, RuntimeConfigSyncResponse
9
+ from .state import RuntimeProcessState
10
+
11
+ TConfig = TypeVar("TConfig", bound=RuntimeConfig)
12
+
13
+
14
+ def reconcile_config_ids(
15
+ *,
16
+ active_ids: list[str],
17
+ incoming_ids: list[str],
18
+ ) -> tuple[list[str], list[str]]:
19
+ """Return removed ids and unchanged ids from two config-id lists."""
20
+ active_set = set(active_ids)
21
+ incoming_set = set(incoming_ids)
22
+ removed_ids = sorted(active_set - incoming_set)
23
+ unchanged_ids = sorted(active_set & incoming_set)
24
+ return removed_ids, unchanged_ids
25
+
26
+
27
+ def build_sync_response(
28
+ *,
29
+ status: str,
30
+ container_id: str,
31
+ reason: str | None = None,
32
+ generation: int | None = None,
33
+ applied: list[str] | None = None,
34
+ removed: list[str] | None = None,
35
+ active_config_ids: list[str] | None = None,
36
+ metadata: dict | None = None,
37
+ ) -> RuntimeConfigSyncResponse:
38
+ """Build a standard PiPhi config-sync response payload."""
39
+ return RuntimeConfigSyncResponse(
40
+ status=status,
41
+ container_id=container_id,
42
+ reason=reason,
43
+ generation=generation,
44
+ applied=applied or [],
45
+ removed=removed or [],
46
+ active_config_ids=active_config_ids or [],
47
+ metadata=metadata or {},
48
+ )
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class ConfigSyncCoordinator:
53
+ """Track config generations and common sync response patterns.
54
+
55
+ This class intentionally stays small. It helps integrations decide whether
56
+ an incoming snapshot is stale and keeps generation tracking in one place.
57
+ """
58
+ process_state: RuntimeProcessState
59
+
60
+ def get_current_generation(self) -> int | None:
61
+ """Return the latest applied config generation for the process."""
62
+ return self.process_state.current_generation
63
+
64
+ def mark_generation(self, generation: int | None) -> int | None:
65
+ """Persist a new generation and return the effective stored value."""
66
+ if generation is not None:
67
+ self.process_state.current_generation = int(generation)
68
+ return self.process_state.current_generation
69
+
70
+ def is_stale(self, snapshot: RuntimeConfigSnapshot) -> bool:
71
+ """Return whether an incoming snapshot is older than the current state."""
72
+ incoming_generation = snapshot.generation
73
+ current_generation = self.process_state.current_generation
74
+ return bool(
75
+ incoming_generation is not None
76
+ and current_generation is not None
77
+ and int(incoming_generation) < int(current_generation)
78
+ )
79
+
80
+ def build_stale_response(
81
+ self,
82
+ *,
83
+ snapshot: RuntimeConfigSnapshot,
84
+ active_config_ids: list[str],
85
+ ) -> RuntimeConfigSyncResponse:
86
+ """Build the standard response for an ignored stale snapshot."""
87
+ return build_sync_response(
88
+ status="stale_ignored",
89
+ container_id=snapshot.container_id,
90
+ reason=snapshot.reason,
91
+ generation=self.process_state.current_generation,
92
+ active_config_ids=active_config_ids,
93
+ metadata={
94
+ "stale_generation_ignored": True,
95
+ "incoming_generation": snapshot.generation,
96
+ "current_generation": self.process_state.current_generation,
97
+ },
98
+ )
99
+
100
+ async def apply_snapshot(
101
+ self,
102
+ *,
103
+ snapshot: RuntimeConfigSnapshot,
104
+ active_config_ids: list[str],
105
+ apply_config: Callable[[RuntimeConfig], Awaitable[None]],
106
+ remove_config: Callable[[str], Awaitable[bool]],
107
+ get_active_config_ids: Callable[[], list[str]] | None = None,
108
+ ) -> RuntimeConfigSyncResponse:
109
+ """Apply a config snapshot using caller-provided apply/remove callbacks.
110
+
111
+ This keeps the integration-specific logic in the callbacks while the SDK
112
+ handles the generic PiPhi sync flow: stale detection, diffing, generation
113
+ tracking, and standard response payloads.
114
+ """
115
+ if self.is_stale(snapshot):
116
+ return self.build_stale_response(
117
+ snapshot=snapshot,
118
+ active_config_ids=active_config_ids,
119
+ )
120
+
121
+ incoming_ids = [config.id for config in snapshot.configs]
122
+ removed_ids, _unchanged_ids = reconcile_config_ids(
123
+ active_ids=active_config_ids,
124
+ incoming_ids=incoming_ids,
125
+ )
126
+
127
+ removed: list[str] = []
128
+ applied: list[str] = []
129
+
130
+ for config_id in removed_ids:
131
+ was_removed = await remove_config(config_id)
132
+ if was_removed:
133
+ removed.append(config_id)
134
+
135
+ for config in snapshot.configs:
136
+ await apply_config(config)
137
+ applied.append(config.id)
138
+
139
+ current_generation = self.mark_generation(snapshot.generation)
140
+ next_active_config_ids = (
141
+ get_active_config_ids() if get_active_config_ids is not None else incoming_ids
142
+ )
143
+
144
+ return build_sync_response(
145
+ status="synced",
146
+ container_id=snapshot.container_id,
147
+ reason=snapshot.reason,
148
+ generation=current_generation,
149
+ applied=applied,
150
+ removed=removed,
151
+ active_config_ids=next_active_config_ids,
152
+ metadata={
153
+ "applied_count": len(applied),
154
+ "removed_count": len(removed),
155
+ "current_generation": current_generation,
156
+ },
157
+ )