testo-core 1.1.1__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 (141) hide show
  1. testo_api/__init__.py +3 -0
  2. testo_api/cycle_execution_manager.py +539 -0
  3. testo_api/dependencies.py +21 -0
  4. testo_api/main.py +162 -0
  5. testo_api/models.py +408 -0
  6. testo_api/routes/__init__.py +1 -0
  7. testo_api/routes/ai.py +100 -0
  8. testo_api/routes/analytics.py +141 -0
  9. testo_api/routes/cycles.py +214 -0
  10. testo_api/routes/dashboard.py +121 -0
  11. testo_api/routes/health.py +46 -0
  12. testo_api/routes/history.py +149 -0
  13. testo_api/security.py +77 -0
  14. testo_core/__init__.py +65 -0
  15. testo_core/cli/__init__.py +5 -0
  16. testo_core/cli/app.py +161 -0
  17. testo_core/cli/cleanup.py +42 -0
  18. testo_core/cli/commands/__init__.py +6 -0
  19. testo_core/cli/commands/clean.py +75 -0
  20. testo_core/cli/commands/config.py +127 -0
  21. testo_core/cli/commands/config_db.py +140 -0
  22. testo_core/cli/commands/diff_cli.py +112 -0
  23. testo_core/cli/commands/doctor.py +161 -0
  24. testo_core/cli/commands/init_cmd.py +65 -0
  25. testo_core/cli/commands/plans.py +92 -0
  26. testo_core/cli/commands/report.py +424 -0
  27. testo_core/cli/commands/run.py +100 -0
  28. testo_core/cli/commands/version.py +18 -0
  29. testo_core/cli/commands/watch.py +135 -0
  30. testo_core/cli/runner.py +211 -0
  31. testo_core/cli/ui/__init__.py +5 -0
  32. testo_core/cli/ui/ci_renderer.py +19 -0
  33. testo_core/cli/ui/console.py +65 -0
  34. testo_core/cli/ui/panels.py +128 -0
  35. testo_core/cli/ui/progress.py +29 -0
  36. testo_core/cli/ui/renderers.py +178 -0
  37. testo_core/cli/ui/summary_dashboard.py +345 -0
  38. testo_core/config/__init__.py +28 -0
  39. testo_core/config/database_section.py +165 -0
  40. testo_core/config/errors.py +29 -0
  41. testo_core/config/loader.py +458 -0
  42. testo_core/config/resolver.py +95 -0
  43. testo_core/config/schema.py +117 -0
  44. testo_core/config/triggers.py +294 -0
  45. testo_core/engine/__init__.py +30 -0
  46. testo_core/engine/events.py +46 -0
  47. testo_core/engine/executor.py +256 -0
  48. testo_core/engine/exit_codes.py +45 -0
  49. testo_core/engine/log_buffer.py +144 -0
  50. testo_core/engine/orchestrator.py +246 -0
  51. testo_core/engine/result.py +51 -0
  52. testo_core/frameworks/__init__.py +21 -0
  53. testo_core/frameworks/base.py +73 -0
  54. testo_core/frameworks/behave_adapter.py +49 -0
  55. testo_core/frameworks/behavex_adapter.py +82 -0
  56. testo_core/frameworks/command_adapter.py +41 -0
  57. testo_core/frameworks/pytest_adapter.py +121 -0
  58. testo_core/history/__init__.py +16 -0
  59. testo_core/history/maintenance.py +46 -0
  60. testo_core/history/read_model.py +52 -0
  61. testo_core/history/report_links.py +45 -0
  62. testo_core/history/snapshots.py +21 -0
  63. testo_core/history/views.py +122 -0
  64. testo_core/mascot/__init__.py +422 -0
  65. testo_core/mascot/__main__.py +30 -0
  66. testo_core/mascot/export_ts.py +51 -0
  67. testo_core/mascot/render.py +114 -0
  68. testo_core/paths.py +102 -0
  69. testo_core/persistence/__init__.py +23 -0
  70. testo_core/persistence/backend.py +21 -0
  71. testo_core/persistence/composite.py +50 -0
  72. testo_core/persistence/db_backend.py +128 -0
  73. testo_core/persistence/failure_context.py +112 -0
  74. testo_core/persistence/health.py +109 -0
  75. testo_core/persistence/json_backend.py +63 -0
  76. testo_core/reporting/__init__.py +11 -0
  77. testo_core/reporting/allure.py +84 -0
  78. testo_core/reporting/allure_cli.py +212 -0
  79. testo_core/reporting/allure_results.py +232 -0
  80. testo_core/reporting/collector.py +144 -0
  81. testo_core/reporting/entry.py +129 -0
  82. testo_core/reporting/exporter.py +151 -0
  83. testo_core/reporting/history_inject.py +76 -0
  84. testo_core/reporting/integrations.py +232 -0
  85. testo_core/reporting/junit_import.py +161 -0
  86. testo_core/reporting/metrics.py +191 -0
  87. testo_core/reporting/metrics_extractor.py +187 -0
  88. testo_core/reporting/native_reports.py +277 -0
  89. testo_core/reporting/paths.py +81 -0
  90. testo_core/reporting/pyramid_data.py +33 -0
  91. testo_core/reporting/pyramid_viz.py +120 -0
  92. testo_core/reporting/report_generator.py +537 -0
  93. testo_core/reporting/reporters/__init__.py +14 -0
  94. testo_core/reporting/reporters/allure_reporter.py +137 -0
  95. testo_core/reporting/reporters/base.py +63 -0
  96. testo_core/reporting/reporters/extent_builder.py +106 -0
  97. testo_core/reporting/reporters/extent_reporter.py +61 -0
  98. testo_core/reporting/reporters/factory.py +122 -0
  99. testo_core/reporting/reporters/orchestrate.py +93 -0
  100. testo_core/reporting/reporters/reportportal_client.py +367 -0
  101. testo_core/reporting/reporters/reportportal_reporter.py +97 -0
  102. testo_core/reporting/reporters/templates/extent_dashboard.html.j2 +244 -0
  103. testo_core/reporting/reporters/testbeats_payloads.py +197 -0
  104. testo_core/reporting/reporters/testbeats_reporter.py +115 -0
  105. testo_core/reporting/server.py +140 -0
  106. testo_core/repository/__init__.py +15 -0
  107. testo_core/repository/adapters.py +137 -0
  108. testo_core/repository/base.py +62 -0
  109. testo_core/repository/db.py +31 -0
  110. testo_core/repository/db_config.py +117 -0
  111. testo_core/repository/factory.py +115 -0
  112. testo_core/repository/models.py +87 -0
  113. testo_core/repository/report_archive_repository.py +90 -0
  114. testo_core/security/__init__.py +15 -0
  115. testo_core/security/redaction.py +53 -0
  116. testo_core/services/__init__.py +86 -0
  117. testo_core/services/ai/__init__.py +33 -0
  118. testo_core/services/ai/config.py +54 -0
  119. testo_core/services/ai/factory.py +18 -0
  120. testo_core/services/ai/integration_settings.py +66 -0
  121. testo_core/services/ai/provider_base.py +54 -0
  122. testo_core/services/ai/providers/__init__.py +4 -0
  123. testo_core/services/ai/providers/anthropic_provider.py +76 -0
  124. testo_core/services/ai/providers/openai_provider.py +78 -0
  125. testo_core/services/ci_provenance.py +84 -0
  126. testo_core/services/cycle_run.py +355 -0
  127. testo_core/services/dashboard_service.py +341 -0
  128. testo_core/services/delta_models.py +61 -0
  129. testo_core/services/delta_service.py +284 -0
  130. testo_core/services/failure_analysis_service.py +192 -0
  131. testo_core/services/failure_context_builder.py +105 -0
  132. testo_core/services/metrics_service.py +41 -0
  133. testo_core/services/report_archive.py +160 -0
  134. testo_core/services/report_archive_diff.py +191 -0
  135. testo_core/services/report_service.py +121 -0
  136. testo_core/services/run_snapshot_diff.py +37 -0
  137. testo_core-1.1.1.dist-info/METADATA +310 -0
  138. testo_core-1.1.1.dist-info/RECORD +141 -0
  139. testo_core-1.1.1.dist-info/WHEEL +4 -0
  140. testo_core-1.1.1.dist-info/entry_points.txt +3 -0
  141. testo_core-1.1.1.dist-info/licenses/LICENSE +21 -0
testo_api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from testo_api.main import app, create_app
2
+
3
+ __all__ = ["app", "create_app"]
@@ -0,0 +1,539 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ import threading
9
+ import time
10
+ from collections import deque
11
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Literal
15
+ from uuid import UUID, uuid4
16
+
17
+ from testo_core.config.errors import ConfigDiscoveryError
18
+ from testo_core.config.loader import discover_and_load
19
+ from testo_core.config.resolver import resolve_plan
20
+ from testo_core.config.schema import Defaults, Plan, TestosteroneConfig
21
+ from testo_core.config.triggers import TriggerResult
22
+ from testo_core.services.cycle_run import (
23
+ ADHOC_PLAN_NAME,
24
+ CycleRunOptions,
25
+ CycleRunService,
26
+ single_stage_plan,
27
+ )
28
+
29
+ CycleExecutionStatus = Literal["queued", "running", "completed", "failed"]
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # The registry only exists so clients can follow a run while it is live; the durable
34
+ # record of every run is the run history DB. Running executions are always kept;
35
+ # finished ones are kept up to this many (oldest evicted first, minimum 1 so a run's
36
+ # final status can always be read), so memory stays bounded however many runs happen.
37
+ MAX_FINISHED_EXECUTIONS_ENV = "TESTO_MAX_FINISHED_EXECUTIONS"
38
+ DEFAULT_MAX_FINISHED_EXECUTIONS = 200
39
+
40
+
41
+ def _max_finished_from_env() -> int:
42
+ raw = os.environ.get(MAX_FINISHED_EXECUTIONS_ENV)
43
+ if raw is None or not raw.strip():
44
+ return DEFAULT_MAX_FINISHED_EXECUTIONS
45
+ try:
46
+ return max(1, int(raw))
47
+ except ValueError:
48
+ logger.warning(
49
+ "%s=%r is not an integer; using %d",
50
+ MAX_FINISHED_EXECUTIONS_ENV,
51
+ raw,
52
+ DEFAULT_MAX_FINISHED_EXECUTIONS,
53
+ )
54
+ return DEFAULT_MAX_FINISHED_EXECUTIONS
55
+
56
+
57
+ # Resolves (config, plan) on the worker thread, so config errors surface as a failed execution.
58
+ PlanLoader = Callable[[], tuple[TestosteroneConfig, Plan]]
59
+
60
+
61
+ @dataclass
62
+ class CycleExecutionState:
63
+ execution_id: str
64
+ cycle: str
65
+ created_at: float
66
+ status: CycleExecutionStatus = "queued"
67
+ artifacts_root: Path | None = None
68
+ plan_result_path: Path | None = None
69
+ events_path: Path | None = None
70
+ events_start_offset_bytes: int = 0
71
+ # Where this execution's events end in the (per-cycle, shared) events file; set on finish.
72
+ events_end_offset_bytes: int | None = None
73
+ done: bool = False
74
+ error: str | None = None
75
+ lock: threading.Lock = field(default_factory=threading.Lock)
76
+
77
+ def mark_done(self, *, status: CycleExecutionStatus, error: str | None = None) -> None:
78
+ with self.lock:
79
+ self.status = status
80
+ self.error = error
81
+ self.done = True
82
+
83
+
84
+ class _NullRenderer:
85
+ wants_streaming = False
86
+
87
+ def handle(self, _event) -> None: # noqa: ANN001
88
+ return
89
+
90
+
91
+ class _NdjsonCycleListener:
92
+ """Writes the trigger verdict into the cycle's events.ndjson so the SSE stream carries it."""
93
+
94
+ def __init__(self, events_path: Path, *, ci: bool) -> None:
95
+ self._events_path = events_path
96
+ self._ci = ci
97
+
98
+ def trigger_evaluated(self, plan: Plan, result: TriggerResult) -> None:
99
+ if not self._ci:
100
+ return
101
+ _append_ndjson_line(
102
+ self._events_path,
103
+ {
104
+ "event": "cycle_trigger",
105
+ "cycle": plan.name,
106
+ "status": "activated" if result.stimulus else "resting",
107
+ "reason": result.reason,
108
+ "matched": list(result.matched_paths),
109
+ "mode": result.mode,
110
+ },
111
+ )
112
+
113
+ def trigger_bypassed(self, plan: Plan) -> None:
114
+ return
115
+
116
+ def report_archived(self, report_id: UUID | None, *, background: bool) -> None:
117
+ return
118
+
119
+
120
+ def _append_ndjson_line(path: Path, payload: dict[str, object]) -> None:
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ with path.open("a", encoding="utf-8") as fh:
123
+ fh.write(json.dumps(payload, separators=(",", ":"), ensure_ascii=True))
124
+ fh.write("\n")
125
+ fh.flush()
126
+
127
+
128
+ class CycleExecutionManager:
129
+ """
130
+ Manage plan/cycle executions using the modern engine lifecycle:
131
+
132
+ - `discover_and_load` config discovery + `resolve_plan` (a named cycle), or
133
+ `single_stage_plan` (an ad-hoc run of one framework, see `create_adhoc_execution`)
134
+ - `CycleRunService.run()`, the same use case as `testo run`: trigger gate
135
+ (`cycle_trigger` NDJSON event), `orchestrator.run_plan()` writing durable NDJSON
136
+ into `artifacts/<cycle>/events.ndjson`, reporters and the report archive
137
+
138
+ Streaming is done by tailing `events.ndjson` from a recorded byte offset.
139
+
140
+ The registry is bounded: every running execution is kept, plus the last
141
+ `max_finished` finished ones (default from `TESTO_MAX_FINISHED_EXECUTIONS`).
142
+ Older finished executions are evicted and their status/events endpoints answer
143
+ 404; their results stay in the run history (`/api/v1/runs`).
144
+ """
145
+
146
+ def __init__(self, *, max_finished: int | None = None) -> None:
147
+ self._states: dict[str, CycleExecutionState] = {}
148
+ self._states_lock = threading.Lock()
149
+ self._active_by_cycle: dict[str, str] = {}
150
+ configured = _max_finished_from_env() if max_finished is None else max_finished
151
+ self.max_finished = max(1, configured)
152
+ # Finished execution ids, oldest first: the eviction order.
153
+ self._finished: deque[str] = deque()
154
+
155
+ def create_execution(
156
+ self,
157
+ *,
158
+ cycle: str,
159
+ config_path: Path | None,
160
+ artifacts_root_override: Path | None = None,
161
+ persist: bool = True,
162
+ force: bool = False,
163
+ fail_fast: bool = False,
164
+ reporter_override: list[str] | None = None,
165
+ report_db: bool = True,
166
+ async_report_db: bool = False,
167
+ workers_override: int | None = None,
168
+ stream: bool = False,
169
+ ci: bool = True,
170
+ ) -> CycleExecutionState:
171
+ def load_plan() -> tuple[TestosteroneConfig, Plan]:
172
+ cfg = discover_and_load(config_path=config_path)
173
+ return cfg, resolve_plan(cfg, plan_name=cycle)
174
+
175
+ return self._start(
176
+ cycle=str(cycle),
177
+ load_plan=load_plan,
178
+ artifacts_root_override=artifacts_root_override,
179
+ persist=persist,
180
+ force=force,
181
+ fail_fast=fail_fast,
182
+ reporter_override=reporter_override,
183
+ report_db=report_db,
184
+ async_report_db=async_report_db,
185
+ workers_override=workers_override,
186
+ stream=stream,
187
+ ci=ci,
188
+ )
189
+
190
+ def create_adhoc_execution(
191
+ self,
192
+ *,
193
+ framework: str,
194
+ target_repo: Path,
195
+ args: Sequence[str] = (),
196
+ timeout_s: float | None = None,
197
+ extra_env: Mapping[str, str] | None = None,
198
+ config_path: Path | None = None,
199
+ artifacts_root_override: Path | None = None,
200
+ persist: bool = True,
201
+ report_db: bool = True,
202
+ ) -> CycleExecutionState:
203
+ """Run one framework directly, as a one-stage ``adhoc`` cycle.
204
+
205
+ Validates the stage up front (``ConfigValidationError``). Defaults and
206
+ reporters come from the discovered config when there is one.
207
+ """
208
+ plan = single_stage_plan(
209
+ framework=framework,
210
+ target_repo=target_repo,
211
+ args=args,
212
+ timeout_s=timeout_s,
213
+ extra_env=extra_env,
214
+ )
215
+
216
+ def load_plan() -> tuple[TestosteroneConfig, Plan]:
217
+ try:
218
+ cfg = discover_and_load(config_path=config_path)
219
+ except ConfigDiscoveryError:
220
+ if config_path is not None:
221
+ raise
222
+ cfg = TestosteroneConfig(version=1, defaults=Defaults())
223
+ return cfg, plan
224
+
225
+ return self._start(
226
+ cycle=ADHOC_PLAN_NAME,
227
+ load_plan=load_plan,
228
+ artifacts_root_override=artifacts_root_override,
229
+ persist=persist,
230
+ force=False,
231
+ fail_fast=False,
232
+ reporter_override=None,
233
+ report_db=report_db,
234
+ async_report_db=False,
235
+ workers_override=None,
236
+ stream=True,
237
+ ci=True,
238
+ )
239
+
240
+ def _start(
241
+ self,
242
+ *,
243
+ cycle: str,
244
+ load_plan: PlanLoader,
245
+ artifacts_root_override: Path | None,
246
+ persist: bool,
247
+ force: bool,
248
+ fail_fast: bool,
249
+ reporter_override: list[str] | None,
250
+ report_db: bool,
251
+ async_report_db: bool,
252
+ workers_override: int | None,
253
+ stream: bool,
254
+ ci: bool,
255
+ ) -> CycleExecutionState:
256
+ execution_id = str(uuid4())
257
+ state = CycleExecutionState(execution_id=execution_id, cycle=cycle, created_at=time.time())
258
+
259
+ with self._states_lock:
260
+ existing = self._active_by_cycle.get(str(cycle))
261
+ if existing is not None:
262
+ raise RuntimeError(f"cycle {cycle!r} already running (execution_id={existing})")
263
+ self._states[execution_id] = state
264
+ self._active_by_cycle[str(cycle)] = execution_id
265
+
266
+ threading.Thread(
267
+ target=self._run_execution,
268
+ args=(
269
+ state,
270
+ load_plan,
271
+ artifacts_root_override,
272
+ persist,
273
+ force,
274
+ fail_fast,
275
+ reporter_override,
276
+ report_db,
277
+ async_report_db,
278
+ workers_override,
279
+ stream,
280
+ ci,
281
+ ),
282
+ daemon=True,
283
+ ).start()
284
+ return state
285
+
286
+ def get(self, execution_id: str) -> CycleExecutionState | None:
287
+ with self._states_lock:
288
+ return self._states.get(execution_id)
289
+
290
+ def _run_execution(
291
+ self,
292
+ state: CycleExecutionState,
293
+ load_plan: PlanLoader,
294
+ artifacts_root_override: Path | None,
295
+ persist: bool,
296
+ force: bool,
297
+ fail_fast: bool,
298
+ reporter_override: list[str] | None,
299
+ report_db: bool,
300
+ async_report_db: bool,
301
+ workers_override: int | None,
302
+ stream: bool,
303
+ ci: bool,
304
+ ) -> None:
305
+ status: CycleExecutionStatus = "failed"
306
+ error: str | None = "execution interrupted"
307
+ try:
308
+ with state.lock:
309
+ state.status = "running"
310
+
311
+ cfg, plan = load_plan()
312
+ artifacts_root = (
313
+ (artifacts_root_override or cfg.defaults.artifacts_root).expanduser().resolve()
314
+ )
315
+ plan_artifacts = (artifacts_root / plan.name).resolve()
316
+ events_path = plan_artifacts / "events.ndjson"
317
+ plan_result_path = plan_artifacts / "plan_result.json"
318
+
319
+ plan_artifacts.mkdir(parents=True, exist_ok=True)
320
+ start_offset = 0
321
+ try:
322
+ start_offset = int(events_path.stat().st_size) if events_path.exists() else 0
323
+ except OSError:
324
+ start_offset = 0
325
+
326
+ with state.lock:
327
+ state.artifacts_root = artifacts_root
328
+ state.events_path = events_path
329
+ state.plan_result_path = plan_result_path
330
+ state.events_start_offset_bytes = start_offset
331
+
332
+ # Stage subprocesses resolve tools (pytest/behave/...) via PATH. The API server
333
+ # may be launched without an activated venv, so prepend this interpreter's bin
334
+ # dir to guarantee stages run against the same environment as the engine.
335
+ parent_env = dict(os.environ)
336
+ # Note: do not resolve() — in a venv, sys.executable is a symlink into the
337
+ # base interpreter; resolving it would point PATH at the wrong bin dir.
338
+ exe_bin = str(Path(sys.executable).parent)
339
+ path_entries = parent_env.get("PATH", "").split(os.pathsep)
340
+ if exe_bin not in path_entries:
341
+ parent_env["PATH"] = os.pathsep.join([exe_bin, *path_entries])
342
+
343
+ # Same use case as `testo run`; `run_plan` persists events.ndjson and
344
+ # plan_result.json under artifacts/<cycle>/, which the SSE route tails.
345
+ outcome = CycleRunService(listener=_NdjsonCycleListener(events_path, ci=ci)).run(
346
+ cfg=cfg,
347
+ plan=plan,
348
+ renderer=_NullRenderer(),
349
+ options=CycleRunOptions(
350
+ persist=persist,
351
+ force=force,
352
+ fail_fast=fail_fast,
353
+ workers_override=workers_override,
354
+ reporter_override=reporter_override,
355
+ report_db=report_db,
356
+ async_report_db=async_report_db,
357
+ artifacts_root=artifacts_root,
358
+ parent_env=parent_env,
359
+ ci=ci,
360
+ ),
361
+ )
362
+ if outcome.skipped:
363
+ # Contract: treat resting as success (exit_code 0). Emit a minimal `plan_finished`
364
+ # so UIs relying on a terminal event can close the stream.
365
+ _append_ndjson_line(
366
+ events_path,
367
+ {
368
+ "event": "plan_finished",
369
+ "plan": plan.name,
370
+ "aggregate_returncode": 0,
371
+ "exit_code": 0,
372
+ "duration_s": 0.0,
373
+ "stages": [],
374
+ "error": None,
375
+ },
376
+ )
377
+
378
+ status, error = "completed", None
379
+ except Exception as exc: # pragma: no cover (defensive: surfaces in API)
380
+ # Broad on purpose: a background run's crash must reach the client as an error event.
381
+ # Keep error exposure minimal (redaction happens upstream in API error formatting where needed).
382
+ logger.exception("execution %s of cycle %s crashed", state.execution_id, state.cycle)
383
+ err = str(exc)
384
+ try:
385
+ if state.events_path is not None:
386
+ _append_ndjson_line(
387
+ state.events_path,
388
+ {"event": "error", "code": "internal_error", "message": err},
389
+ )
390
+ except OSError:
391
+ logger.warning(
392
+ "could not write error event for %s", state.execution_id, exc_info=True
393
+ )
394
+ error = err
395
+ finally:
396
+ # Record where this run's events end before freeing the cycle: the next run
397
+ # appends to the same file, and streams of this run must stop here.
398
+ if state.events_path is not None:
399
+ try:
400
+ end = state.events_path.stat().st_size
401
+ except OSError:
402
+ end = state.events_start_offset_bytes
403
+ with state.lock:
404
+ state.events_end_offset_bytes = end
405
+ # Free the cycle and update the registry before reporting `done`, so a client
406
+ # that sees the run finish can start the cycle again right away.
407
+ with self._states_lock:
408
+ if self._active_by_cycle.get(state.cycle) == state.execution_id:
409
+ self._active_by_cycle.pop(state.cycle, None)
410
+ self._finished.append(state.execution_id)
411
+ while len(self._finished) > self.max_finished:
412
+ self._states.pop(self._finished.popleft(), None)
413
+ state.mark_done(status=status, error=error)
414
+
415
+
416
+ def _sse_message(payload: dict[str, object]) -> str:
417
+ event_name = str(payload.get("event") or "unknown")
418
+ data = json.dumps(payload, separators=(",", ":"), ensure_ascii=True)
419
+ return f"event: {event_name}\ndata: {data}\n\n"
420
+
421
+
422
+ def _read_ndjson(path: Path, offset: int, end: int | None) -> tuple[list[dict[str, object]], int]:
423
+ """Parse the NDJSON objects after byte ``offset``; return them and the new offset.
424
+
425
+ While the run is live (``end is None``) a trailing line without its newline is
426
+ still being written, so it is left for the next read. Once the run is done, read
427
+ exactly up to ``end`` (the file is shared by later runs of the same cycle).
428
+ """
429
+ try:
430
+ with path.open("rb") as fh:
431
+ fh.seek(offset)
432
+ chunk = fh.read() if end is None else fh.read(max(0, end - offset))
433
+ except OSError: # not created yet
434
+ return [], offset if end is None else end
435
+ consumed = chunk.rfind(b"\n") + 1 if end is None else len(chunk)
436
+ payloads: list[dict[str, object]] = []
437
+ for raw in chunk[:consumed].splitlines():
438
+ try:
439
+ payload = json.loads(raw)
440
+ except json.JSONDecodeError:
441
+ continue
442
+ if isinstance(payload, dict):
443
+ payloads.append(payload)
444
+ return payloads, offset + consumed if end is None else end
445
+
446
+
447
+ async def iter_sse_from_ndjson_file(
448
+ *,
449
+ events_path: Path,
450
+ start_offset_bytes: int,
451
+ end_offset: Callable[[], int | None],
452
+ min_poll_s: float = 0.1,
453
+ max_poll_s: float = 2.0,
454
+ keepalive_s: float = 15.0,
455
+ ) -> AsyncIterator[str]:
456
+ """
457
+ Tail an NDJSON file and emit each JSON object as an SSE message.
458
+
459
+ The `data:` payload is the full NDJSON object (including top-level `event`),
460
+ aligned to `docs/CLI Commands/Troubleshooting and Error Codes.md`.
461
+
462
+ `end_offset()` is None while the execution runs and, once it is done, the byte
463
+ offset where its events end; the stream drains up to there and closes.
464
+
465
+ Per viewer this is one coroutine and a file offset. It sleeps on the event loop
466
+ (a sync generator would hold a threadpool thread per viewer) and the poll interval
467
+ backs off from `min_poll_s` to `max_poll_s` while nothing new is written. When
468
+ the client disconnects, Starlette cancels the response task, which ends the
469
+ stream at its next `await`.
470
+ """
471
+ offset = max(0, int(start_offset_bytes))
472
+ interval = min_poll_s
473
+ last_sent = time.monotonic()
474
+ while True:
475
+ # Sample the end before reading: it is recorded after the worker's last event,
476
+ # so this read is guaranteed to include that event.
477
+ end = end_offset()
478
+ # Off the event loop: a late viewer may have a large backlog to read.
479
+ payloads, offset = await asyncio.to_thread(_read_ndjson, events_path, offset, end)
480
+ for payload in payloads:
481
+ yield _sse_message(payload)
482
+ if end is not None:
483
+ return
484
+
485
+ now = time.monotonic()
486
+ if payloads:
487
+ interval = min_poll_s
488
+ last_sent = now
489
+ else:
490
+ interval = min(interval * 2, max_poll_s)
491
+ if now - last_sent >= keepalive_s:
492
+ # Comment line: keeps proxies from timing out an idle stream.
493
+ yield ": keep-alive\n\n"
494
+ last_sent = now
495
+ await asyncio.sleep(interval)
496
+
497
+
498
+ async def iter_execution_sse(
499
+ manager: CycleExecutionManager,
500
+ execution_id: str,
501
+ *,
502
+ min_poll_s: float = 0.1,
503
+ max_poll_s: float = 2.0,
504
+ ) -> AsyncIterator[str]:
505
+ """SSE stream for one execution: wait until its events file is known, then tail it."""
506
+ # The worker thread learns the events path only after loading the config.
507
+ interval = min_poll_s
508
+ while True:
509
+ state = manager.get(execution_id)
510
+ if state is None: # evicted: it finished long ago and its result is in run history
511
+ return
512
+ with state.lock:
513
+ events_path, start_offset = state.events_path, state.events_start_offset_bytes
514
+ done, error = state.done, state.error
515
+ if events_path is not None:
516
+ break
517
+ if done:
518
+ # Failed before it had an events file (e.g. a config error): say why.
519
+ message = error or "execution finished without events"
520
+ yield _sse_message({"event": "error", "code": "internal_error", "message": message})
521
+ return
522
+ await asyncio.sleep(interval)
523
+ interval = min(interval * 2, max_poll_s)
524
+
525
+ def end_offset() -> int | None:
526
+ current = manager.get(execution_id)
527
+ if current is None: # evicted mid-stream: nothing more of this run to send
528
+ return 0
529
+ with current.lock:
530
+ return (current.events_end_offset_bytes or 0) if current.done else None
531
+
532
+ async for message in iter_sse_from_ndjson_file(
533
+ events_path=events_path,
534
+ start_offset_bytes=start_offset,
535
+ end_offset=end_offset,
536
+ min_poll_s=min_poll_s,
537
+ max_poll_s=max_poll_s,
538
+ ):
539
+ yield message
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from testo_api.cycle_execution_manager import CycleExecutionManager
4
+ from testo_core.services.ai.integration_settings import InMemoryAiSettingsStore
5
+ from testo_core.services.failure_analysis_service import FailureAnalysisService
6
+
7
+ _CYCLE_MANAGER = CycleExecutionManager()
8
+ _AI_SETTINGS_STORE = InMemoryAiSettingsStore()
9
+ _FAILURE_ANALYSIS_SERVICE = FailureAnalysisService(settings_store=_AI_SETTINGS_STORE)
10
+
11
+
12
+ def get_cycle_execution_manager() -> CycleExecutionManager:
13
+ return _CYCLE_MANAGER
14
+
15
+
16
+ def get_ai_settings_store() -> InMemoryAiSettingsStore:
17
+ return _AI_SETTINGS_STORE
18
+
19
+
20
+ def get_failure_analysis_service() -> FailureAnalysisService:
21
+ return _FAILURE_ANALYSIS_SERVICE