fdai-deployment-cli 0.1.2__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 (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,648 @@
1
+ """Presentation-only activity stream for the local standalone deployment coordinator.
2
+
3
+ The context-local display never writes operational evidence or grants authority. Callers advance
4
+ phases only after their existing checks pass, and explicitly confirm the final ready result.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ import threading
12
+ import time
13
+ from collections.abc import Callable, Iterator, Mapping
14
+ from contextlib import contextmanager
15
+ from contextvars import ContextVar, Token
16
+ from dataclasses import dataclass
17
+ from types import TracebackType
18
+ from typing import Literal, Self
19
+
20
+ from rich.console import Console, Group
21
+ from rich.live import Live
22
+ from rich.spinner import Spinner
23
+ from rich.table import Table
24
+ from rich.text import Text
25
+
26
+ from fdai_deployment_cli.contracts import canonical_digest
27
+ from fdai_deployment_cli.foundation_progress import FoundationCheckpoint
28
+
29
+ StageName = Literal[
30
+ "azure",
31
+ "kit",
32
+ "discovery",
33
+ "foundation",
34
+ "identity",
35
+ "transfer",
36
+ "substrate",
37
+ "runtime",
38
+ "images",
39
+ "database",
40
+ "capability",
41
+ "migration",
42
+ "application",
43
+ "initial-inventory",
44
+ "catalog-review",
45
+ "verification",
46
+ "cleanup",
47
+ ]
48
+ _STAGES: tuple[tuple[StageName, str], ...] = (
49
+ ("azure", "Azure sign-in"),
50
+ ("kit", "Signed deployment kit"),
51
+ ("discovery", "Foundation discovery"),
52
+ ("foundation", "Foundation checkpoints"),
53
+ ("identity", "Application identity"),
54
+ ("transfer", "Managed-host transfer"),
55
+ ("substrate", "Private infrastructure"),
56
+ ("runtime", "AKS cluster and node pools"),
57
+ ("images", "Runtime images"),
58
+ ("database", "Database placement"),
59
+ ("capability", "Capability mode"),
60
+ ("migration", "Database and catalogs"),
61
+ ("application", "Application deployment"),
62
+ ("initial-inventory", "Initial resource inventory"),
63
+ ("catalog-review", "Frozen catalog review"),
64
+ ("verification", "Health and zero-change plan"),
65
+ ("cleanup", "Cleanup and final receipt"),
66
+ )
67
+ _STATES = {
68
+ "pending": ("--", "dim"),
69
+ "running": ("RUN", "cyan"),
70
+ "waiting": ("WAIT", "yellow"),
71
+ "done": ("OK", "green"),
72
+ "skipped": ("SKIP", "dim"),
73
+ "failed": ("FAIL", "red"),
74
+ "interrupted": ("STOP", "yellow"),
75
+ "incomplete": ("HOLD", "yellow"),
76
+ }
77
+ _CURRENT: ContextVar[DeploymentProgress | None] = ContextVar("deployment_progress", default=None)
78
+
79
+
80
+ @dataclass
81
+ class _Phase:
82
+ label: str
83
+ state: str = "pending"
84
+ started: float | None = None
85
+ ended: float | None = None
86
+
87
+
88
+ class DeploymentProgress:
89
+ """Stream colored phase events and refresh only current work on stderr.
90
+
91
+ Suspend redraw before native details or an approval prompt owns the terminal. Events remain
92
+ in scrollback; redraw never replays them. Errors propagate unchanged, without retries or
93
+ inferred operational success. Plain output contains no terminal control sequences.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ *,
99
+ mode: str = "auto",
100
+ console: Console | None = None,
101
+ clock: Callable[[], float] = time.monotonic,
102
+ auto_refresh: bool = True,
103
+ ) -> None:
104
+ if mode not in {"auto", "stream", "plain", "off"}:
105
+ raise ValueError("deployment progress mode must be auto, stream, plain, or off")
106
+ self.console = console or Console(
107
+ stderr=True, force_terminal=sys.stderr.isatty(), markup=False, highlight=False
108
+ )
109
+ self.enabled = mode != "off"
110
+ self.dynamic = (
111
+ mode == "auto"
112
+ and self.console.is_terminal
113
+ and os.environ.get("TERM") != "dumb"
114
+ and "NO_COLOR" not in os.environ
115
+ )
116
+ self.stream = mode == "stream" and self.console.is_terminal
117
+ self._clock = clock
118
+ self._lock = threading.RLock()
119
+ self._phases = {name: _Phase(label) for name, label in _STAGES}
120
+ self._active: StageName | None = None
121
+ self._detail = "Preparing the local coordinator"
122
+ self._headline = "Deployment in progress"
123
+ self._style = "cyan"
124
+ self._started = clock()
125
+ self._ended: float | None = None
126
+ self._ready = False
127
+ self._suspended = 0
128
+ self._last_download_notice = 0
129
+ self._checkpoint: FoundationCheckpoint | None = None
130
+ self._checkpoint_signal: float | None = None
131
+ self._foundation_mode = ""
132
+ self._token: Token[DeploymentProgress | None] | None = None
133
+ self._spinner = Spinner("dots", style="bold cyan")
134
+ self._auto_refresh = auto_refresh
135
+ self._live = self._new_live()
136
+
137
+ def _new_live(self) -> Live:
138
+ """Start each terminal ownership interval with an empty cursor footprint."""
139
+
140
+ return Live(
141
+ get_renderable=self.render,
142
+ console=self.console,
143
+ auto_refresh=self._auto_refresh,
144
+ refresh_per_second=4,
145
+ transient=True,
146
+ redirect_stdout=False,
147
+ redirect_stderr=True,
148
+ )
149
+
150
+ @classmethod
151
+ def for_source(
152
+ cls,
153
+ *,
154
+ mode: str,
155
+ output: str,
156
+ ) -> DeploymentProgress:
157
+ """Select the append-only installer stream for interactive source deployment."""
158
+
159
+ selected = "off" if output != "text" else "stream" if mode == "auto" else mode
160
+ return cls(mode=selected)
161
+
162
+ def begin_source(self) -> None:
163
+ """Record source-route prerequisites without inventing signed-kit work."""
164
+
165
+ self.begin("azure")
166
+ self.detail("Using the authenticated Azure CLI session")
167
+ self.skip("kit", "Source checkout selected; no deployment kit is used")
168
+
169
+ def begin_hub(self) -> None:
170
+ """Distinguish signed service images from checkout-supplied support artifacts."""
171
+ self.begin("azure")
172
+ self.detail("Using the authenticated Azure CLI session")
173
+ self.skip("kit", "Hub signed images and matching checkout support selected; no kit")
174
+
175
+ def finish_source(self, result: Mapping[str, object]) -> bool:
176
+ """Render ready only for the source coordinator's verified terminal result."""
177
+
178
+ ready = result.get("deployment_ready") is True and result["state"] == "deployment-ready"
179
+ if ready:
180
+ self.ready()
181
+ return ready
182
+
183
+ def timing_receipt(
184
+ self,
185
+ *,
186
+ lane: str,
187
+ outcome: str,
188
+ recorded_at: str,
189
+ ) -> dict[str, object]:
190
+ """Return bounded duration evidence without granting deployment authority."""
191
+
192
+ if not lane or len(lane) > 64 or not outcome or len(outcome) > 128 or not recorded_at:
193
+ raise ValueError("deployment timing identity is invalid")
194
+ end = self._ended if self._ended is not None else self._clock()
195
+ phases = []
196
+ for name, phase in self._phases.items():
197
+ if phase.started is None:
198
+ continue
199
+ phase_end = phase.ended if phase.ended is not None else end
200
+ phases.append(
201
+ {
202
+ "name": name,
203
+ "state": phase.state,
204
+ "elapsed_ms": max(0, round((phase_end - phase.started) * 1000)),
205
+ }
206
+ )
207
+ receipt: dict[str, object] = {
208
+ "schema_version": "fdai.deployment-timing.v1",
209
+ "lane": lane,
210
+ "outcome": outcome,
211
+ "recorded_at": recorded_at,
212
+ "total_elapsed_ms": max(0, round((end - self._started) * 1000)),
213
+ "phases": phases,
214
+ "timing_evidence_only": True,
215
+ "apply_authorized": False,
216
+ "deployment_ready": False,
217
+ }
218
+ receipt["receipt_digest"] = canonical_digest(receipt)
219
+ return receipt
220
+
221
+ def __enter__(self) -> Self:
222
+ self._token = _CURRENT.set(self)
223
+ try:
224
+ self._emit(Text("FDAI | Azure deployment", style="bold cyan"))
225
+ if self.dynamic:
226
+ self._start_live()
227
+ except BaseException:
228
+ _CURRENT.reset(self._token)
229
+ self._token = None
230
+ raise
231
+ return self
232
+
233
+ def _start_live(self) -> None:
234
+ """Restore partial Rich startup on interrupts before its render hook exists."""
235
+
236
+ try:
237
+ self._live.start(refresh=True)
238
+ except BaseException as error:
239
+ try:
240
+ self._live.stop()
241
+ except (OSError, ValueError, IndexError):
242
+ error.add_note("Progress startup cleanup could not write to the terminal.")
243
+ finally:
244
+ try:
245
+ if self.dynamic:
246
+ self.console.show_cursor(True)
247
+ except (OSError, ValueError):
248
+ error.add_note("The terminal was unavailable while restoring the cursor.")
249
+ raise
250
+
251
+ def __exit__(
252
+ self,
253
+ exc_type: type[BaseException] | None,
254
+ exc: BaseException | None,
255
+ traceback: TracebackType | None,
256
+ ) -> None:
257
+ del exc_type, traceback
258
+ try:
259
+ with self._lock:
260
+ self._ended = self._clock()
261
+ if exc is not None:
262
+ interrupted = isinstance(exc, KeyboardInterrupt)
263
+ self._headline = (
264
+ "Deployment interrupted" if interrupted else "Deployment failed"
265
+ )
266
+ self._style = "yellow" if interrupted else "red"
267
+ self._detail = "Preserve state and claims; recovery needs a reviewed plan."
268
+ if self._active is not None:
269
+ phase = self._phases[self._active]
270
+ phase.state = "interrupted" if interrupted else "failed"
271
+ phase.ended = self._ended
272
+ elif not self._ready:
273
+ self._headline = "Deployment ended without a ready result"
274
+ self._style = "yellow"
275
+ if self._active is not None:
276
+ phase = self._phases[self._active]
277
+ phase.state = "incomplete"
278
+ phase.ended = self._ended
279
+ if self.dynamic:
280
+ self._live.stop()
281
+ if not self._ready and self._active is not None:
282
+ self._phase_event(self._active)
283
+ self.console.print(self.render())
284
+ elif self.enabled:
285
+ if exc is not None and self._active is not None:
286
+ self._phase_event(self._active)
287
+ self._plain(self._headline)
288
+ except (OSError, ValueError):
289
+ if exc is None:
290
+ raise
291
+ exc.add_note("Progress output failed; the original deployment error is preserved.")
292
+ finally:
293
+ if self._token is not None:
294
+ _CURRENT.reset(self._token)
295
+ self._token = None
296
+
297
+ def begin(self, name: StageName) -> None:
298
+ """Advance only after the caller's existing checks; retain one event per transition."""
299
+
300
+ with self._lock:
301
+ if name == self._active:
302
+ return
303
+ previous = self._active
304
+ now = self._clock()
305
+ if previous is not None:
306
+ self._phases[previous].state = "done"
307
+ self._phases[previous].ended = now
308
+ self._active = name
309
+ self._phases[name].state = "running"
310
+ self._phases[name].started = now
311
+ self._detail = ""
312
+ self._checkpoint = None
313
+ self._checkpoint_signal = None
314
+ self._foundation_mode = ""
315
+ if previous is not None:
316
+ self._phase_event(previous)
317
+ self._phase_event(name)
318
+ self._refresh()
319
+
320
+ def detail(self, text: str, *, log: bool = True) -> None:
321
+ """Show bounded application-owned text, never raw provider output or secrets."""
322
+
323
+ text = "".join(character if character.isprintable() else " " for character in text)[:160]
324
+ with self._lock:
325
+ changed = text != self._detail
326
+ self._detail = text
327
+ if changed and log:
328
+ self._emit(Text(f" {text}", style="dim"))
329
+ if log:
330
+ self._refresh()
331
+
332
+ def skip(self, name: StageName, reason: str) -> None:
333
+ """Record one route-inapplicable presentation phase without claiming an effect."""
334
+
335
+ reason = "".join(character if character.isprintable() else " " for character in reason)[
336
+ :120
337
+ ]
338
+ with self._lock:
339
+ if name == self._active:
340
+ raise ValueError("active deployment phase cannot be skipped")
341
+ phase = self._phases[name]
342
+ if phase.state == "done":
343
+ raise ValueError("completed deployment phase cannot be skipped")
344
+ phase.state = "skipped"
345
+ phase.ended = self._clock()
346
+ self._phase_event(name)
347
+ self._emit(Text(f" {reason}", style="dim"))
348
+ self._refresh()
349
+
350
+ def reset_foundation(self) -> None:
351
+ """Begin a new display-only child pass without changing the coordinator phase."""
352
+ with self._lock:
353
+ self._checkpoint = None
354
+ self._checkpoint_signal = None
355
+ self._foundation_mode = ""
356
+ self.detail("Starting Foundation checkpoints")
357
+
358
+ def foundation_mode(self, mode: str) -> None:
359
+ """Retain the recognized child mode, never an approval or readiness decision."""
360
+ with self._lock:
361
+ self._foundation_mode = mode
362
+ self.detail(f"{mode.capitalize()} | exact plan approvals remain required")
363
+
364
+ def foundation_checkpoint(self, value: FoundationCheckpoint) -> None:
365
+ """Append distinct checked child transitions without completing a coordinator phase."""
366
+ with self._lock:
367
+ changed = value != self._checkpoint
368
+ self._checkpoint = value
369
+ self._checkpoint_signal = self._clock()
370
+ if changed and self.dynamic:
371
+ status = "REPORTED COMPLETE" if value.state == "COMPLETE" else value.state
372
+ line = Text(f" [{value.number:02}/{value.total}] ", style="magenta")
373
+ line.append(f"{status} ", style=f"bold {_checkpoint_style(value)}")
374
+ line.append(value.label)
375
+ line.append(f" | {value.completed}/{value.total} complete", style="dim")
376
+ self._emit(line)
377
+ self._refresh()
378
+
379
+ def foundation_signal(self) -> None:
380
+ """Refresh observed activity only; a heartbeat never adds history or completes work."""
381
+ with self._lock:
382
+ self._checkpoint_signal = self._clock()
383
+ self._refresh()
384
+
385
+ def downloaded(self, byte_count: int) -> None:
386
+ """Report bytes actually read, without inventing a total, ETA, or verification."""
387
+
388
+ notice = self._last_download_notice == 0 or (
389
+ byte_count - self._last_download_notice >= 64 * 1024 * 1024
390
+ )
391
+ self.detail(
392
+ f"{byte_count / (1024 * 1024):.1f} MiB received; verification pending", log=notice
393
+ )
394
+ if notice:
395
+ self._last_download_notice = byte_count
396
+
397
+ @contextmanager
398
+ def installer_activity(
399
+ self,
400
+ label: str,
401
+ *,
402
+ heartbeat_seconds: float = 10.0,
403
+ ) -> Iterator[None]:
404
+ """Emit append-only start, heartbeat, and terminal events for one quiet operation."""
405
+
406
+ if heartbeat_seconds <= 0:
407
+ raise ValueError("installer heartbeat interval must be positive")
408
+ label = "".join(character if character.isprintable() else " " for character in label)[:120]
409
+ if not self.enabled or self.dynamic:
410
+ self.detail(label)
411
+ yield
412
+ return
413
+ started = self._clock()
414
+ stopped = threading.Event()
415
+ self._emit(Text.assemble((" => ", "bold cyan"), label))
416
+
417
+ def heartbeat() -> None:
418
+ while not stopped.wait(heartbeat_seconds):
419
+ line = Text(" [..] ", style="bold cyan")
420
+ line.append(label)
421
+ line.append(f" | elapsed {_elapsed(started, self._clock())}", style="dim")
422
+ with self._lock:
423
+ self._emit(line)
424
+
425
+ worker = threading.Thread(
426
+ target=heartbeat,
427
+ name="fdai-installer-progress",
428
+ daemon=True,
429
+ )
430
+ worker.start()
431
+ succeeded = False
432
+ try:
433
+ yield
434
+ succeeded = True
435
+ finally:
436
+ stopped.set()
437
+ worker.join(timeout=max(1.0, heartbeat_seconds))
438
+ elapsed = _elapsed(started, self._clock())
439
+ status = Text(" [OK] " if succeeded else " [FAIL] ")
440
+ status.stylize("bold green" if succeeded else "bold red")
441
+ status.append(label)
442
+ status.append(f" | elapsed {elapsed}", style="dim")
443
+ self._emit(status)
444
+
445
+ def ready(self) -> None:
446
+ """Finish presentation only after the caller has a verified deployment-ready result."""
447
+
448
+ with self._lock:
449
+ if self._active is not None:
450
+ self._phases[self._active].state = "done"
451
+ self._phases[self._active].ended = self._clock()
452
+ self._ready = True
453
+ self._headline = "Deployment ready"
454
+ self._style = "green"
455
+ self._detail = "Subscription-wide assurance remains open."
456
+ if self._active is not None:
457
+ self._phase_event(self._active)
458
+ self._refresh()
459
+
460
+ @contextmanager
461
+ def terminal_output(self, label: str, *, approval: bool = False) -> Iterator[None]:
462
+ """Yield the terminal to existing details or prompts without reading stdin."""
463
+
464
+ with self._lock:
465
+ phase = self._phases.get(self._active) if self._active is not None else None
466
+ previous_state = phase.state if phase is not None else "running"
467
+ if phase is not None and approval:
468
+ phase.state = "waiting"
469
+ self._suspended += 1
470
+ if self.dynamic and self._suspended == 1:
471
+ self._live.stop()
472
+ line = Text(
473
+ f"[{'WAIT' if approval else 'INFO'}] ", style="bold yellow" if approval else "cyan"
474
+ )
475
+ line.append(label, style="default")
476
+ self._emit(line)
477
+ succeeded = False
478
+ try:
479
+ yield
480
+ succeeded = True
481
+ finally:
482
+ with self._lock:
483
+ if phase is not None:
484
+ phase.state = previous_state
485
+ self._suspended -= 1
486
+ if self.dynamic and self._suspended == 0 and succeeded:
487
+ self._live = self._new_live()
488
+ self._start_live()
489
+
490
+ def render(self) -> Group:
491
+ """Render at most six unboxed lines of current work, never replaying past events."""
492
+ with self._lock:
493
+ now = self._ended if self._ended is not None else self._clock()
494
+ phase = self._phases.get(self._active) if self._active is not None else None
495
+ state = phase.state if phase is not None else "running"
496
+ label = phase.label if phase is not None else "Preparing"
497
+ duration = _elapsed(
498
+ phase.started if phase else None,
499
+ phase.ended if phase is not None and phase.ended is not None else now,
500
+ )
501
+ completed = sum(item.state == "done" for item in self._phases.values())
502
+ skipped = sum(item.state == "skipped" for item in self._phases.values())
503
+ elapsed = _elapsed(self._started, now)
504
+ headline, style, detail = self._headline, self._style, self._detail
505
+ checkpoint = self._checkpoint
506
+ age = _elapsed(self._checkpoint_signal, now)
507
+ status, phase_style = _STATES[state]
508
+ row = Table.grid(padding=(0, 1), expand=True)
509
+ row.add_column(width=4)
510
+ row.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
511
+ row.add_column(no_wrap=True)
512
+ row.add_row(
513
+ self._spinner if state == "running" else Text(status, style=f"bold {phase_style}"),
514
+ Text(label, style=f"bold {phase_style}"),
515
+ Text(duration, style="dim"),
516
+ )
517
+ lines = [
518
+ Text(f"FDAI | {headline}", style=f"bold {style}", no_wrap=True, overflow="ellipsis"),
519
+ Text(
520
+ f"{completed} complete | {skipped} skipped | "
521
+ f"{len(_STAGES) - completed - skipped} remaining | elapsed {elapsed}",
522
+ style="dim",
523
+ no_wrap=True,
524
+ overflow="ellipsis",
525
+ ),
526
+ ]
527
+ children: list[Text] = []
528
+ if checkpoint is not None:
529
+ child_style = _checkpoint_style(checkpoint)
530
+ child_state = (
531
+ "Reported complete"
532
+ if checkpoint.state == "COMPLETE"
533
+ else checkpoint.state.capitalize()
534
+ )
535
+ children = [
536
+ Text(
537
+ f"{checkpoint.number:02}/{checkpoint.total} {checkpoint.label}",
538
+ style=f"bold {child_style}",
539
+ no_wrap=True,
540
+ overflow="ellipsis",
541
+ ),
542
+ Text(
543
+ f"{child_state} | {checkpoint.completed}/{checkpoint.total} complete | "
544
+ f"{checkpoint.skipped} skipped | last activity {age}",
545
+ style=child_style,
546
+ no_wrap=True,
547
+ overflow="ellipsis",
548
+ ),
549
+ ]
550
+ return Group(
551
+ *lines,
552
+ row,
553
+ *children,
554
+ Text(detail, style=style, no_wrap=True, overflow="ellipsis"),
555
+ )
556
+
557
+ def _refresh(self) -> None:
558
+ if self.dynamic and self._suspended == 0 and self._token is not None:
559
+ self._live.refresh()
560
+
561
+ def _emit(self, text: Text) -> None:
562
+ """Serialize one event through Rich outside the state lock; keep plain output literal."""
563
+ if self.dynamic or self.stream:
564
+ self.console.print(text)
565
+ elif self.enabled:
566
+ self._plain(text.plain)
567
+
568
+ def _plain(self, text: str) -> None:
569
+ self.console.file.write(text + "\n")
570
+ self.console.file.flush()
571
+
572
+ def _phase_event(self, name: StageName) -> None:
573
+ with self._lock:
574
+ phase = self._phases[name]
575
+ index = list(self._phases).index(name) + 1
576
+ status, style = _STATES[phase.state]
577
+ end = phase.ended if phase.ended is not None else self._clock()
578
+ duration = _elapsed(phase.started, end)
579
+ line = Text(f"[{index:02}/{len(_STAGES)}] ", style="dim")
580
+ line.append(f"{status:4} ", style=f"bold {style}")
581
+ line.append(phase.label, style=style)
582
+ line.append(f" ({duration})", style="dim")
583
+ self._emit(line)
584
+
585
+
586
+ def _checkpoint_style(value: FoundationCheckpoint) -> str:
587
+ if value.state in {"BLOCKED", "FAILED"}:
588
+ return "red"
589
+ return "yellow" if value.state == "WAITING" else "cyan"
590
+
591
+
592
+ def _elapsed(start: float | None, end: float) -> str:
593
+ seconds = max(0, int(end - start)) if start is not None else 0
594
+ hours, remainder = divmod(seconds, 3600)
595
+ minutes, seconds = divmod(remainder, 60)
596
+ return f"{hours:02}:{minutes:02}:{seconds:02}" if hours else f"{minutes:02}:{seconds:02}"
597
+
598
+
599
+ def begin_stage(name: StageName) -> None:
600
+ """Advance the active local display, or do nothing outside an interactive coordinator."""
601
+
602
+ if (progress := _CURRENT.get()) is not None:
603
+ progress.begin(name)
604
+
605
+
606
+ def progress_detail(text: str) -> None:
607
+ """Publish one application-owned presentation detail without persisting it."""
608
+
609
+ if (progress := _CURRENT.get()) is not None:
610
+ progress.detail(text)
611
+
612
+
613
+ def skip_stage(name: StageName, reason: str) -> None:
614
+ """Record one presentation-only stage as inapplicable to this deployment route."""
615
+
616
+ if (progress := _CURRENT.get()) is not None:
617
+ progress.skip(name, reason)
618
+
619
+
620
+ def downloaded_bytes(count: int) -> None:
621
+ """Publish an observed byte count without changing artifact verification."""
622
+
623
+ if (progress := _CURRENT.get()) is not None:
624
+ progress.downloaded(count)
625
+
626
+
627
+ @contextmanager
628
+ def installer_activity(label: str, *, heartbeat_seconds: float = 10.0) -> Iterator[None]:
629
+ """Show append-only activity while a local preparation boundary is silent."""
630
+
631
+ progress = _CURRENT.get()
632
+ if progress is None:
633
+ yield
634
+ else:
635
+ with progress.installer_activity(label, heartbeat_seconds=heartbeat_seconds):
636
+ yield
637
+
638
+
639
+ @contextmanager
640
+ def terminal_output(label: str, *, approval: bool = False) -> Iterator[None]:
641
+ """Pause the active display around existing subprocess output or human confirmation."""
642
+
643
+ progress = _CURRENT.get()
644
+ if progress is None:
645
+ yield
646
+ else:
647
+ with progress.terminal_output(label, approval=approval):
648
+ yield