steppy-dev 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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/coordinator.py ADDED
@@ -0,0 +1,1352 @@
1
+ """``steppy run --workers N >= 2`` host-side orchestrator.
2
+
3
+ What lives here:
4
+
5
+ - :func:`run_parallel` — entry point dispatched from :mod:`steppy.cli`. Same
6
+ argument set as the existing single-worker run path, plus ``workers`` and
7
+ ``no_build``. Returns the process exit code.
8
+ - :func:`cleanup_stacks` — entry point for ``steppy stacks cleanup``. Lists
9
+ any orphaned ``steppy-run-*`` Compose projects and tears them down.
10
+
11
+ The single-worker path in :func:`steppy.cli.run` does NOT import this
12
+ module. The dispatch in cli.run is a lazy ``from steppy.coordinator import
13
+ run_parallel`` so default invocations never load anything from here. See
14
+ ``tests/test_no_parallel_import_on_default_path.py`` for the guard test.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+ import time
25
+ import uuid
26
+ from concurrent.futures import ThreadPoolExecutor, as_completed
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+ from rich.console import Console
31
+
32
+ from . import exclusive, selection
33
+ from .captures import DEFAULT_SCREENSHOTS_DIR, resolve_output_dir
34
+ from .config import ConfigError, load_config
35
+ from .models import SteppyConfig
36
+ from .runmeta import EVENTS_SUBDIR, JUNIT_SUBDIR, new_run_id, write_run_sidecar
37
+ from .parser import ParseError, parse_scenarios_from_directory
38
+ from ._parallel.compose import (
39
+ ComposeError,
40
+ check_compose_version,
41
+ introspect_user_compose,
42
+ render_override,
43
+ )
44
+ from ._parallel.event_bus import EventBus, RawLogChannel
45
+ from ._parallel.image import ensure_worker_image
46
+ from ._parallel.lifecycle import CoordinatorLifecycle
47
+ from ._parallel.progress import WorkerLogTailer
48
+ from ._parallel.queue import Queue
49
+ from ._parallel.renderer import make_renderer, render_summary
50
+
51
+
52
+ _PROJECT_NAME_PREFIX = "steppy-run-"
53
+ _DEFAULT_AUTO_CAP = 8
54
+
55
+ # How ``ensure_worker_image`` resolved the image, rendered for the run header.
56
+ _IMAGE_MODE_LABELS = {
57
+ "cached": "cached",
58
+ "config": "built from parallel.dockerfile",
59
+ "repo": "built from source checkout",
60
+ "installed": "built from installed package",
61
+ }
62
+
63
+
64
+ def _finalize_run(
65
+ *,
66
+ queue,
67
+ bus,
68
+ console,
69
+ tmp_dir,
70
+ project_root,
71
+ tests_dir,
72
+ retry_passes=None,
73
+ run_id=None,
74
+ workers=None,
75
+ verbose=False,
76
+ screenshots_dir=None,
77
+ never_started=None,
78
+ ):
79
+ """Collect results, persist artifacts, refresh lastfailed, print the summary.
80
+
81
+ The shutdown contract, run identically whether the workers drained normally
82
+ or the user hit Ctrl-C — previously spelled out twice, so any new artifact
83
+ writer or summary argument had to be added to both or Ctrl-C'd runs would
84
+ silently lose it.
85
+
86
+ Ordering is load-bearing: artifacts are persisted BEFORE the summary so the
87
+ summary's path references are valid, and well before tmp_dir cleanup wipes
88
+ the per-scenario logs.
89
+
90
+ ``retry_passes`` is the list of :class:`~steppy._parallel.retry.PassResult`
91
+ for attempts 2..N (the main pass is reconstructed here from ``queue``).
92
+ Empty/None ⇒ single-pass behaviour, byte-identical to pre-retry.
93
+
94
+ The caller stops the renderer first (the Live overlay would otherwise
95
+ re-paint over the summary). Returns ``(results, orphans, verdict)`` —
96
+ ``verdict`` is None when no retry pass ran.
97
+ """
98
+ results = queue.collect_results()
99
+ orphans = queue.orphaned_running_tasks()
100
+ never_started = set(never_started or ())
101
+
102
+ from ._parallel.failures import persist_failure_artifacts
103
+ from ._parallel.lastfailed import write_lastfailed_cache
104
+ from ._parallel.retry import PassResult, classify, log_roots_by_slug
105
+
106
+ # Fold the retry attempts in. The main pass is attempt 1.
107
+ main_pass = PassResult(
108
+ attempt=1,
109
+ coord_root=tmp_dir,
110
+ results=results,
111
+ orphans=orphans,
112
+ bus=bus,
113
+ never_started=never_started,
114
+ )
115
+ all_passes = [main_pass, *(retry_passes or [])]
116
+ verdict = classify(all_passes) if retry_passes else None
117
+
118
+ if retry_passes:
119
+ # The authoritative view is the LAST attempt's outcome per scenario, so
120
+ # a flaky scenario shows up green and its retry log is the one persisted.
121
+ results = _merge_results(all_passes)
122
+ orphans = _final_orphans(all_passes)
123
+
124
+ persist_failure_artifacts(
125
+ project_root=project_root,
126
+ tmp_dir=tmp_dir,
127
+ bus=bus,
128
+ scenario_results=results,
129
+ orphans=orphans,
130
+ log_roots=log_roots_by_slug(all_passes) if retry_passes else None,
131
+ screenshots_dir=screenshots_dir,
132
+ )
133
+
134
+ # Parallel workers never populate .pytest_cache themselves; without this,
135
+ # `steppy rerun` would pick up a stale single-worker run's failure set.
136
+ write_lastfailed_cache(
137
+ project_root=project_root,
138
+ tests_dir=tests_dir,
139
+ scenario_results=results,
140
+ orphans=orphans,
141
+ bus=bus,
142
+ # Flaky scenarios ended green — re-running them would waste a rerun on
143
+ # known-good tests and bury the real failures.
144
+ failed_slugs=set(verdict.failed) if verdict else None,
145
+ )
146
+
147
+ render_summary(
148
+ console=console,
149
+ bus=bus,
150
+ scenario_results=results,
151
+ orphans=orphans,
152
+ tmp_dir=tmp_dir,
153
+ project_root=project_root,
154
+ verdict=verdict,
155
+ retry_buses=[p.bus for p in (retry_passes or []) if p.bus is not None],
156
+ verbose=verbose,
157
+ )
158
+
159
+ _record_history(
160
+ project_root=project_root,
161
+ run_id=run_id,
162
+ workers=workers,
163
+ passes=all_passes,
164
+ verdict=verdict,
165
+ bus=bus,
166
+ console=console,
167
+ )
168
+ return results, orphans, verdict
169
+
170
+
171
+ def _merge_results(all_passes) -> list:
172
+ """One :class:`ScenarioResult` per slug, from its LAST attempt.
173
+
174
+ Later attempts overwrite earlier ones, so a scenario that failed under load
175
+ and passed alone reports as a pass (with the retry's worker + duration)."""
176
+ merged: dict[str, Any] = {}
177
+ for p in all_passes:
178
+ for r in p.results:
179
+ merged[r.slug] = r
180
+ return list(merged.values())
181
+
182
+
183
+ def _final_orphans(all_passes) -> dict[str, list[str]]:
184
+ """Orphans that survived every attempt.
185
+
186
+ An orphan retried successfully is no longer an orphan; without this filter
187
+ a recovered worker-death would still be reported as one."""
188
+ resolved = {r.slug for p in all_passes for r in p.results}
189
+ out: dict[str, list[str]] = {}
190
+ for p in all_passes:
191
+ for worker_id, slugs in p.orphans.items():
192
+ remaining = [s for s in slugs if s not in resolved]
193
+ if remaining:
194
+ out.setdefault(worker_id, []).extend(remaining)
195
+ return {wid: sorted(set(slugs)) for wid, slugs in out.items()}
196
+
197
+
198
+ def _record_history(*, project_root, run_id, workers, passes, verdict, bus, console) -> None:
199
+ """Append this run to ``.steppy/history.jsonl``. Best-effort and lazy —
200
+ a history failure must never affect the run's outcome."""
201
+ if run_id is None:
202
+ return
203
+ try:
204
+ from .history import append_run
205
+
206
+ append_run(
207
+ project_root=project_root,
208
+ run_id=run_id,
209
+ workers=workers or 0,
210
+ passes=passes,
211
+ verdict=verdict,
212
+ bus=bus,
213
+ )
214
+ except Exception as e: # noqa: BLE001 — telemetry is never worth a failed run
215
+ console.print(f"[dim]could not write run history: {e}[/dim]")
216
+
217
+
218
+ def run_parallel(
219
+ *,
220
+ workers: int,
221
+ scenario: Optional[str],
222
+ tag: Optional[list[str]],
223
+ headed: bool,
224
+ screenshots: bool,
225
+ guide: bool,
226
+ no_asserts: bool,
227
+ no_guide: bool,
228
+ locales: Optional[str],
229
+ layouts: Optional[str],
230
+ tests_dir: Path,
231
+ scenarios_dir: Path,
232
+ pytest_passthrough: list[str],
233
+ no_build: bool = False,
234
+ verbose: bool = False,
235
+ coverage: bool = False,
236
+ trace_mode: str = "off",
237
+ video_mode: str = "off",
238
+ retries: int = 0,
239
+ retry_pass: Optional[int] = None,
240
+ strict_flaky: bool = False,
241
+ fail_fast: Optional[bool] = None,
242
+ bundle: bool = False,
243
+ ) -> int:
244
+ """Top-level coordinator. Returns the process exit code (0 on success).
245
+
246
+ Mirrors the user-facing flag dispatch from cli.run for ``--guide`` /
247
+ ``--no-guide`` etc. so a parallel run produces the same scenario
248
+ selection as the equivalent single-worker run would.
249
+
250
+ ``trace_mode`` / ``video_mode`` / ``retries`` are resolved host-side by
251
+ ``cli._resolve_report_modes`` (they need config.yaml) and travel to the
252
+ worker containers as env vars — the coordinator has no pytest process of
253
+ its own. ``pytest_passthrough`` (the user's ``--`` argv) rides along the
254
+ same way.
255
+
256
+ ``retry_pass`` / ``fail_fast`` are tri-state: None ⇒ take ``config.parallel``,
257
+ otherwise the CLI flag wins. ``strict_flaky`` makes a pass-on-retry fail the
258
+ build (the default is lenient — see the exit-code block below).
259
+
260
+ ``bundle`` turns on the two extra artifact sinks ``steppy bundle create``
261
+ reads. Both land under the shared ``output/`` mount and are keyed by
262
+ scenario, which is safe precisely because the queue guarantees a scenario is
263
+ claimed by exactly one worker.
264
+ """
265
+ console = Console()
266
+
267
+ try:
268
+ config = load_config()
269
+ except ConfigError as e:
270
+ console.print(f"[red]ERROR[/red] {e}")
271
+ return 1
272
+
273
+ # Validate parallel block + per-app internal_url for selected scenarios.
274
+ parallel_cfg = config.parallel
275
+ if parallel_cfg is None:
276
+ console.print(
277
+ "[red]ERROR[/red] --workers >= 2 requires a `parallel:` block in config.yaml. See README for the schema."
278
+ )
279
+ return 1
280
+
281
+ project_root = Path.cwd()
282
+
283
+ # The capture root the workers' conftest will write to. Only `output/` is
284
+ # bind-mounted back to the host (compose.py) and the project itself is
285
+ # mounted read-only, so a screenshots_dir pointing elsewhere cannot survive
286
+ # a worker — say so rather than let the run finish with nothing captured.
287
+ screenshots_dir = resolve_output_dir(project_root, config.output.screenshots_dir, DEFAULT_SCREENSHOTS_DIR)
288
+ output_root = (project_root / "output").resolve()
289
+ if not screenshots_dir.is_relative_to(output_root):
290
+ console.print(
291
+ f"[yellow]WARNING[/yellow] output.screenshots_dir ({config.output.screenshots_dir}) resolves outside "
292
+ f"{output_root}, but --workers only mounts output/ back to the host — captures written there will "
293
+ "stay inside the worker container. Move it under output/ or run single-worker."
294
+ )
295
+
296
+ compose_file = (project_root / parallel_cfg.compose_file).resolve()
297
+ if not compose_file.exists():
298
+ console.print(f"[red]ERROR[/red] compose file not found: {compose_file} (parallel.compose_file in config.yaml)")
299
+ return 1
300
+
301
+ # Validate Docker Compose version (need 2.20+ for !reset).
302
+ try:
303
+ check_compose_version()
304
+ except ComposeError as e:
305
+ console.print(f"[red]ERROR[/red] {e}")
306
+ return 1
307
+
308
+ # Discover and filter scenarios — same logic as cli.run for guide /
309
+ # no_guide / scenario / tag, applied here so the worker fan-out matches
310
+ # what the user would have got with --workers 1.
311
+ try:
312
+ slugs = _select_scenario_slugs(
313
+ scenarios_dir=scenarios_dir,
314
+ tests_dir=tests_dir,
315
+ scenario=scenario,
316
+ tag=tag,
317
+ guide=guide,
318
+ no_guide=no_guide,
319
+ console=console,
320
+ )
321
+ except _SelectionExit as e:
322
+ return e.code
323
+ if not slugs:
324
+ console.print("[yellow]INFO[/yellow] no scenarios selected — nothing to do")
325
+ return 0
326
+
327
+ # Validate every role used by selected scenarios has internal_url set.
328
+ missing_internal = _missing_internal_urls(config, scenarios_dir, slugs)
329
+ if missing_internal:
330
+ roles = ", ".join(sorted(missing_internal))
331
+ console.print(
332
+ f"[red]ERROR[/red] the following roles lack `internal_url` in config.yaml "
333
+ f"but are used by selected scenarios: {roles}\n"
334
+ f"Add `apps.<role>.internal_url: http://<service>:<port>` for each."
335
+ )
336
+ return 1
337
+
338
+ # Exclusion groups. Each declared group collapses into ONE queue task holding
339
+ # its members, decided here — once, single-threaded, on the host, before any
340
+ # container exists. That partition IS the mutual-exclusion mechanism: a worker
341
+ # claims the whole group and runs its members sequentially, so there is no
342
+ # lock to leak and no worker ever waits on another. See steppy.exclusive.
343
+ try:
344
+ group_of = selection.exclusive_groups_for(scenarios_dir, slugs)
345
+ except selection.SelectionError as e:
346
+ console.print(f"[red]ERROR[/red] {e.message}")
347
+ if e.hint:
348
+ console.print(f"[yellow]Hint:[/yellow] {e.hint}")
349
+ return e.exit_code
350
+ tasks = exclusive.partition(slugs, group_of)
351
+ collisions = exclusive.duplicate_task_ids(tasks)
352
+ if collisions:
353
+ for first, dup in collisions:
354
+ console.print(
355
+ f"[red]ERROR[/red] exclusion group / scenario name collision: {first!r} and "
356
+ f"{dup!r} resolve to the same queue entry."
357
+ )
358
+ console.print(
359
+ "[yellow]Hint:[/yellow] task names must be unique ignoring case — the run's "
360
+ "coordination directory lives on the host, where NTFS and APFS treat them as "
361
+ "one file and a whole task would silently never run."
362
+ )
363
+ return 1
364
+ _report_exclusion_groups(tasks, console)
365
+
366
+ # Resolve --guide auto-enables (shared with cli.run / cli.rerun).
367
+ screenshots, no_asserts = selection.apply_guide_defaults(guide, screenshots, no_asserts)
368
+
369
+ # Resolve locales / layouts (mirrors cli.run).
370
+ locale_list = _resolve_locales(locales, guide, config, console)
371
+ layout_list = _resolve_layouts(layouts, guide, config, console)
372
+
373
+ # Resolve the retry pass (CLI flag wins over config, as everywhere else).
374
+ retry_passes_count = parallel_cfg.retry.passes if retry_pass is None else retry_pass
375
+ retry_worker_count = parallel_cfg.retry.workers
376
+
377
+ # Resolve effective worker count.
378
+ effective_workers = _resolve_effective_workers(workers, parallel_cfg, slugs)
379
+ # Parallelism is bounded by the number of TASKS, not scenarios: exclusion
380
+ # groups collapse several scenarios into one claimable unit, and a stack that
381
+ # can never claim anything is a full app copy of wasted RAM.
382
+ effective_workers = min(effective_workers, max(1, len(tasks)))
383
+ if effective_workers < 2:
384
+ if len(tasks) < len(slugs):
385
+ # The collapse — not the selection — is what dropped us below two.
386
+ # Returning 0 here would report success having executed nothing, and
387
+ # "every scenario hits one shared database" is the most obvious first
388
+ # use of this feature. One stack running the group serially IS the
389
+ # correct execution of that declaration, so fall through and run it.
390
+ console.print(
391
+ f"[yellow]INFO[/yellow] exclusion groups collapsed {len(slugs)} scenario(s) "
392
+ f"into {len(tasks)} task(s); running on a single stack."
393
+ )
394
+ effective_workers = 1
395
+ else:
396
+ # Should never happen — cli.run only dispatches here for workers >= 2,
397
+ # and "auto" always resolves to >= 1. If we somehow get here with 1
398
+ # (e.g. only one scenario), fall back to single-worker mode by NOT
399
+ # spinning up Docker — surface a clear message and let the user re-run.
400
+ console.print(
401
+ f"[yellow]INFO[/yellow] only {len(slugs)} scenario(s) selected; "
402
+ f"single stack would be used. Re-run without --workers (or with --workers 1) "
403
+ f"to skip Docker entirely."
404
+ )
405
+ return 0
406
+
407
+ # Build the per-app internal_url JSON map workers will consume.
408
+ base_url_override_json = json.dumps(
409
+ {role: app.internal_url for role, app in config.apps.items() if app.internal_url}
410
+ )
411
+
412
+ # Two ids, deliberately. ``stack_id`` names the Compose projects and the tmp
413
+ # dir, where short and opaque is all that is wanted. ``run_id`` is the run's
414
+ # IDENTITY — it names ``output/coverage/raw/<run_id>/`` and the ``--bundle``
415
+ # sinks, and both are resolved elsewhere by taking the lexicographically last
416
+ # directory (``coverage.merge.latest_run_id``). A bare uuid cannot be sorted,
417
+ # so this path used to make that resolution arbitrary; the sortable form is
418
+ # the same one ``cli._new_run_id`` produces for single-worker runs.
419
+ stack_id = uuid.uuid4().hex[:8]
420
+ run_id = new_run_id()
421
+ tmp_dir = Path(tempfile.mkdtemp(prefix=f"steppy-run-{stack_id}-"))
422
+ console.print(f"\n[bold]steppy parallel run[/bold] (runId={run_id}, workers={effective_workers})")
423
+ console.print(f"[dim]tmp dir: {tmp_dir}[/dim]")
424
+
425
+ # Resource warning — heuristic, not a hard block.
426
+ _maybe_warn_resources(effective_workers, console)
427
+
428
+ # Seed the shared queue, slowest scenario first. Workers claim in seed
429
+ # order, so putting the long ones up front stops a 3-minute scenario from
430
+ # being claimed last and leaving one worker running alone while the rest
431
+ # idle (classic LPT scheduling). No history ⇒ unchanged order.
432
+ queue = Queue(root=tmp_dir)
433
+ tasks = _order_tasks_longest_first(tasks, project_root, console)
434
+ # The flattened task order is the real execution order, which is what the
435
+ # --bundle sidecar wants to record.
436
+ ordered_slugs = [slug for task in tasks for slug in task.slugs]
437
+ batches = {t.task_id: list(t.slugs) for t in tasks if t.is_batch}
438
+ if batches:
439
+ queue.seed([t.task_id for t in tasks], batches=batches)
440
+ else:
441
+ # Pass no keyword at all when nothing is grouped, so the call — and the
442
+ # queue directory it writes — is byte-identical to the pre-feature one.
443
+ queue.seed([t.task_id for t in tasks])
444
+ queued_note = f"queued {len(slugs)} scenario(s)"
445
+ if batches:
446
+ queued_note += f" in {len(tasks)} task(s)"
447
+ console.print(f"[dim]{queued_note} across {effective_workers} worker(s)[/dim]")
448
+
449
+ # Introspect user compose (services with ports, networks, healthcheck check).
450
+ try:
451
+ introspection = introspect_user_compose(compose_file)
452
+ except ComposeError as e:
453
+ console.print(f"[red]ERROR[/red] {e}")
454
+ shutil.rmtree(tmp_dir, ignore_errors=True)
455
+ return 1
456
+
457
+ # Ensure worker image (auto-build on first run unless --no-build). Build
458
+ # inputs are resolved by _parallel.image: explicit config → source checkout
459
+ # → a context synthesized from the installed package (so parallel mode
460
+ # works off a plain `pip install`, with no steppy source tree on the box).
461
+ package_root = Path(__file__).resolve().parent
462
+ try:
463
+ image_ref, image_mode = ensure_worker_image(
464
+ image_prefix=parallel_cfg.worker_image,
465
+ package_root=package_root,
466
+ project_root=project_root,
467
+ parallel_cfg=parallel_cfg,
468
+ staging_dir=tmp_dir,
469
+ build=(parallel_cfg.build_image and not no_build),
470
+ )
471
+ except RuntimeError as e:
472
+ console.print(f"[red]ERROR[/red] {e}")
473
+ shutil.rmtree(tmp_dir, ignore_errors=True)
474
+ return 1
475
+ console.print(f"[dim]worker image: {image_ref} ({_IMAGE_MODE_LABELS.get(image_mode, image_mode)})[/dim]")
476
+
477
+ # Echo the report settings that now actually reach the workers, so it is
478
+ # obvious when --trace/--video/--retries took effect (they used to be
479
+ # silently dropped in parallel mode).
480
+ if trace_mode and trace_mode != "off":
481
+ console.print(f"[dim]Playwright trace: {trace_mode}[/dim]")
482
+ if video_mode and video_mode != "off":
483
+ console.print(f"[dim]Playwright video: {video_mode}[/dim]")
484
+ if retries:
485
+ console.print(f"[dim]Flaky-retry: up to {retries} rerun(s) per failure (in-process)[/dim]")
486
+ if pytest_passthrough:
487
+ console.print(f"[dim]pytest passthrough: {' '.join(pytest_passthrough)}[/dim]")
488
+
489
+ # Optional per-worker `.auth` isolation. The shared dir is bind-mounted RW
490
+ # into every worker, so N workers race on one storage-state file; giving
491
+ # each a private (pre-seeded) copy trades one login for N.
492
+ auth_mounts = _prepare_isolated_auth(
493
+ enabled=parallel_cfg.isolate_auth,
494
+ worker_count=effective_workers,
495
+ project_root=project_root,
496
+ tmp_dir=tmp_dir,
497
+ console=console,
498
+ )
499
+
500
+ # Render per-worker overrides. The kwargs are kept as a dict so the retry
501
+ # pass can re-render with exactly the same settings (only `coord_subdir`
502
+ # differs) — a drifted setting there would be invisible until a retry
503
+ # behaved differently from the run that spawned it.
504
+ overrides: list[Path] = []
505
+ locales_csv = ",".join(locale_list)
506
+ layouts_csv = ",".join(layout_list)
507
+ image_prefix, _, image_tag = image_ref.partition(":")
508
+ base_override_kwargs = dict(
509
+ introspection=introspection,
510
+ worker_image=image_prefix,
511
+ image_tag=image_tag,
512
+ run_id=run_id,
513
+ project_root=project_root,
514
+ tmp_dir=tmp_dir,
515
+ base_url_override=base_url_override_json,
516
+ locales=locales_csv,
517
+ layouts=layouts_csv,
518
+ guide_mode="1" if guide else "",
519
+ no_asserts="1" if no_asserts else "",
520
+ screenshots="1" if screenshots else "",
521
+ coverage="1" if coverage else "",
522
+ pre_auth_command=(parallel_cfg.pre_auth.command if parallel_cfg.pre_auth else None),
523
+ pre_auth_image=(parallel_cfg.pre_auth.image if parallel_cfg.pre_auth else None),
524
+ pre_auth_timeout=(parallel_cfg.pre_auth.timeout if parallel_cfg.pre_auth else 600),
525
+ trace_mode=trace_mode,
526
+ video_mode=video_mode,
527
+ retries=retries,
528
+ pytest_extra=pytest_passthrough,
529
+ # In-container paths: /steppy/project is the RO project mount and
530
+ # /steppy/project/output the RW overlay onto the host's output/.
531
+ events_dir=(f"/steppy/project/output/{EVENTS_SUBDIR}/{run_id}" if bundle else ""),
532
+ junit_dir=(f"/steppy/project/output/{JUNIT_SUBDIR}/{run_id}" if bundle else ""),
533
+ # The tour replays' build-id gate, read inside the container.
534
+ tour_expect_build_id=os.environ.get("STEPPY_TOUR_EXPECT_BUILD_ID", ""),
535
+ )
536
+ for worker_index in range(1, effective_workers + 1):
537
+ override_yml = render_override(
538
+ worker_index=worker_index,
539
+ auth_mount=auth_mounts[worker_index - 1] if auth_mounts else "",
540
+ **base_override_kwargs,
541
+ )
542
+ override_path = tmp_dir / f"override-w{worker_index}.yml"
543
+ override_path.write_text(override_yml, encoding="utf-8")
544
+ overrides.append(override_path)
545
+
546
+ # Pre-create overlay dirs the workers will write to. Without this, Docker
547
+ # bind-mounts create them owned by root, which breaks subsequent host-side
548
+ # cleanup on Linux.
549
+ (project_root / ".auth").mkdir(exist_ok=True)
550
+ (project_root / "output").mkdir(exist_ok=True)
551
+
552
+ if bundle:
553
+ write_run_sidecar(
554
+ project_root,
555
+ run_id=run_id,
556
+ spec={
557
+ "guide": guide,
558
+ "no_guide": no_guide,
559
+ "no_asserts": no_asserts,
560
+ "screenshots": screenshots,
561
+ "coverage": coverage,
562
+ "locales": list(locale_list),
563
+ "layouts": list(layout_list),
564
+ "workers": effective_workers,
565
+ "tag": list(tag) if tag else None,
566
+ "scenario": scenario,
567
+ },
568
+ slugs=ordered_slugs,
569
+ )
570
+ console.print(f"[dim]bundle capture: enabled — create it with: steppy bundle create --run-id {run_id}[/dim]")
571
+
572
+ exit_code = 0
573
+ project_names: list[str] = []
574
+ log_tailers: list[WorkerLogTailer] = []
575
+
576
+ # EventBus + RawLogChannel wire worker stdout into the renderer.
577
+ # The raw channel always writes to a per-worker file; it also prints
578
+ # ``[wN] <line>`` to the console when --verbose is set.
579
+ raw_channel = RawLogChannel(console=console, verbose=verbose, log_dir=tmp_dir)
580
+ bus = EventBus(raw_line_sink=raw_channel)
581
+ for i in range(1, effective_workers + 1):
582
+ bus.register_worker(f"w{i}")
583
+ renderer = make_renderer(bus=bus, console=console, total_scenarios=len(slugs))
584
+
585
+ with CoordinatorLifecycle() as lc:
586
+ # 1. Spin up all stacks in parallel via `compose up -d --wait`.
587
+ try:
588
+ project_names = _bring_up_all_stacks(
589
+ stack_id=stack_id,
590
+ worker_count=effective_workers,
591
+ compose_file=compose_file,
592
+ overrides=overrides,
593
+ healthcheck_timeout=parallel_cfg.healthcheck_timeout,
594
+ lifecycle=lc,
595
+ console=console,
596
+ )
597
+ except _StackError as e:
598
+ console.print(f"[red]ERROR[/red] failed to bring up stack: {e}")
599
+ return 1
600
+
601
+ # 1b. Resolve each worker's container ID once so the wait loop can
602
+ # poll via `docker inspect` directly — `compose ps -q` (even with
603
+ # -a) is slower per call and unnecessary once we have the ID.
604
+ try:
605
+ worker_container_ids = _resolve_worker_container_ids(project_names)
606
+ except _StackError as e:
607
+ console.print(f"[red]ERROR[/red] {e}")
608
+ return 1
609
+
610
+ # 2. Start the renderer and log multiplexers. The renderer is
611
+ # registered for teardown first so it stops painting BEFORE the
612
+ # final summary call (otherwise the Live overlay would clobber it).
613
+ renderer.start()
614
+ lc.on_teardown(renderer.stop)
615
+ lc.on_teardown(raw_channel.close)
616
+
617
+ for i, project in enumerate(project_names, start=1):
618
+ tailer = WorkerLogTailer(
619
+ project=project,
620
+ worker_id=f"w{i}",
621
+ bus=bus,
622
+ app_log_file=tmp_dir / f"w{i}-stack.log",
623
+ )
624
+ tailer.start()
625
+ log_tailers.append(tailer)
626
+ lc.on_teardown(tailer.stop)
627
+
628
+ # 3. Wait for every worker container to exit (queue drains naturally).
629
+ # The circuit breaker rides on the wait loop's poll cycle: it watches
630
+ # results land and aborts once nearly everything is failing, so a down
631
+ # app doesn't cost the full wall clock.
632
+ breaker = _make_circuit_breaker(parallel_cfg.circuit_breaker, fail_fast, queue, console)
633
+ breaker_tripped = False
634
+ try:
635
+ worker_exit_codes = _wait_for_workers(worker_container_ids, console, on_poll=breaker)
636
+ except KeyboardInterrupt:
637
+ console.print("\n[yellow]Ctrl-C received — tearing down stacks…[/yellow]")
638
+ for i in range(1, effective_workers + 1):
639
+ bus.mark_worker_finished(f"w{i}", interrupted=True)
640
+ renderer.stop()
641
+ # Same shutdown as the success path, over the partial results — so
642
+ # a Ctrl-C'd run still persists artifacts and refreshes lastfailed.
643
+ _finalize_run(
644
+ queue=queue,
645
+ bus=bus,
646
+ console=console,
647
+ tmp_dir=tmp_dir,
648
+ project_root=project_root,
649
+ tests_dir=tests_dir,
650
+ run_id=run_id,
651
+ workers=effective_workers,
652
+ verbose=verbose,
653
+ screenshots_dir=screenshots_dir,
654
+ )
655
+ if coverage:
656
+ # No auto-merge on Ctrl-C — records already on the output/
657
+ # bind mount; merging is cheap to do once things calm down.
658
+ console.print(f"[dim]coverage records kept — merge with: steppy coverage merge --run-id {run_id}[/dim]")
659
+ return 130
660
+ except _CircuitBreakerTripped as e:
661
+ breaker_tripped = True
662
+ worker_exit_codes = [1] * effective_workers
663
+ renderer.stop()
664
+ console.print()
665
+ console.rule("[bold red]Aborted — circuit breaker[/bold red]")
666
+ console.print(f" [red]{e}[/red]")
667
+ console.print(
668
+ " [dim]This looks like infrastructure, not tests — the app, auth or a\n"
669
+ " seed step is likely broken. Check output/failures/_worker-logs/.[/dim]"
670
+ )
671
+ console.print(" [dim]Disable with --no-fail-fast or parallel.circuit_breaker.enabled: false[/dim]")
672
+
673
+ # 4. Reconcile worker exit codes back into the bus so in-flight
674
+ # tests (worker died mid-test) get a synthetic outcome before the
675
+ # summary renders.
676
+ for i, project_exit in enumerate(worker_exit_codes, start=1):
677
+ bus.mark_worker_finished(f"w{i}", died=project_exit != 0)
678
+
679
+ # 5. Stop the renderer before printing the summary — otherwise
680
+ # the Live overlay re-paints over the summary lines.
681
+ renderer.stop()
682
+
683
+ # 5a. Anything queued and never claimed — every worker died with work
684
+ # left. Captured before the retry pass, which will try to run it.
685
+ never_started = set(queue.unclaimed_slugs())
686
+
687
+ # 5b. Retry pass. Re-runs the failures at reduced concurrency in the
688
+ # still-running stacks, which is what separates a contention flake from
689
+ # a real failure. Skipped when the breaker tripped — that is infra, and
690
+ # retrying it would just take twice as long to reach the same verdict.
691
+ retry_passes = _run_retry_passes(
692
+ enabled=(not breaker_tripped) and retry_passes_count > 0,
693
+ passes=retry_passes_count,
694
+ retry_workers=retry_worker_count,
695
+ queue=queue,
696
+ bus=bus,
697
+ group_of=group_of,
698
+ project_names=project_names,
699
+ compose_file=compose_file,
700
+ tmp_dir=tmp_dir,
701
+ base_override_kwargs=base_override_kwargs,
702
+ auth_mounts=auth_mounts,
703
+ console=console,
704
+ verbose=verbose,
705
+ lifecycle=lc,
706
+ )
707
+
708
+ # 6. Aggregate results and render the final summary.
709
+ results, orphans, verdict = _finalize_run(
710
+ queue=queue,
711
+ bus=bus,
712
+ console=console,
713
+ tmp_dir=tmp_dir,
714
+ project_root=project_root,
715
+ tests_dir=tests_dir,
716
+ retry_passes=retry_passes,
717
+ run_id=run_id,
718
+ workers=effective_workers,
719
+ verbose=verbose,
720
+ screenshots_dir=screenshots_dir,
721
+ never_started=never_started,
722
+ )
723
+ failed = sum(1 for r in results if r.exit_code != 0)
724
+ orphan_count = sum(len(v) for v in orphans.values())
725
+ # Anything that was queued and never claimed by anybody. Nothing else in
726
+ # the system observes it — orphan detection walks claims/, result
727
+ # collection walks results/, and a task with neither file is invisible to
728
+ # both — so without this a run whose workers all died could report success
729
+ # having executed nothing.
730
+ abandoned = sorted(never_started - {r.slug for r in results})
731
+ if abandoned:
732
+ _report_abandoned(abandoned, console)
733
+
734
+ # Coverage merge: workers wrote raw records to the shared output/
735
+ # bind mount under this run_id; fold them into the map host-side.
736
+ # Lazy import + best-effort: a merge failure never changes the run's
737
+ # exit code (records persist; `steppy coverage merge --run-id` retries).
738
+ if coverage:
739
+ try:
740
+ from .coverage.merge import CoverageSettings, merge_run
741
+
742
+ settings = CoverageSettings.from_config(config, project_root)
743
+ merge_summary = merge_run(settings, run_id)
744
+ console.print(
745
+ f"[green]OK[/green] Coverage: merged "
746
+ f"{merge_summary['totals']['scenarios']} scenario(s), "
747
+ f"{merge_summary['totals']['nodes']} map node(s) → {settings.store_path}"
748
+ )
749
+ except Exception as e:
750
+ console.print(f"[yellow]WARNING[/yellow] Coverage merge failed: {e}")
751
+ console.print(f"[dim]Raw records kept — retry with: steppy coverage merge --run-id {run_id}[/dim]")
752
+
753
+ # Exit code. Without a retry pass this is unchanged: any failure
754
+ # (worker container exit != 0, scenario fail, or orphan) → 1.
755
+ #
756
+ # With a retry pass, FLAKY (failed then passed) is LENIENT by default —
757
+ # a pass is a pass, so only scenarios that failed every attempt fail the
758
+ # build. `--strict-flaky` restores the strict reading for teams that
759
+ # want a flake to break CI. Worker exit codes are ignored once a verdict
760
+ # exists: a worker that reported "1" because a scenario failed, which
761
+ # the retry then passed, must not veto that.
762
+ if verdict is not None:
763
+ any_failure = bool(verdict.failed) or orphan_count > 0 or (strict_flaky and bool(verdict.flaky))
764
+ else:
765
+ any_failure = failed > 0 or orphan_count > 0 or any(c != 0 for c in worker_exit_codes)
766
+ # On BOTH branches, including the verdict one — a scenario that never ran
767
+ # is not a flake to be forgiven, and the verdict branch drops worker exit
768
+ # codes by design.
769
+ if abandoned:
770
+ any_failure = True
771
+ if breaker_tripped:
772
+ any_failure = True
773
+ exit_code = 1 if any_failure else 0
774
+
775
+ # 7. Teardown happens automatically via ExitStack callbacks here.
776
+
777
+ # Clean up tmp dir unless STEPPY_KEEP_RUN_DIR=1 (debugging escape hatch).
778
+ if os.environ.get("STEPPY_KEEP_RUN_DIR") != "1":
779
+ shutil.rmtree(tmp_dir, ignore_errors=True)
780
+ else:
781
+ console.print(f"[dim]preserved tmp dir: {tmp_dir}[/dim]")
782
+
783
+ return exit_code
784
+
785
+
786
+ # ── Retry pass / circuit breaker / auth isolation ──────────────────────────
787
+
788
+
789
+ class _CircuitBreakerTripped(RuntimeError):
790
+ """Raised from the wait loop's poll callback to abort a doomed run."""
791
+
792
+
793
+ def _make_circuit_breaker(cfg, fail_fast: Optional[bool], queue, console):
794
+ """Build the ``on_poll`` callback for :func:`_wait_for_workers`, or None.
795
+
796
+ Fires when ``min_completed`` scenarios have reported AND at least
797
+ ``fail_ratio`` of them failed. Both thresholds matter: the ratio alone would
798
+ trip on the first failed scenario of a run, and the count alone would trip
799
+ on a suite that is merely red.
800
+
801
+ ``fail_fast`` is the tri-state CLI override (None ⇒ use config).
802
+ """
803
+ enabled = cfg.enabled if fail_fast is None else fail_fast
804
+ if not enabled:
805
+ return None
806
+
807
+ def _check() -> None:
808
+ results = queue.collect_results()
809
+ completed = len(results)
810
+ if completed < cfg.min_completed:
811
+ return
812
+ failed = sum(1 for r in results if r.exit_code != 0)
813
+ if failed / completed >= cfg.fail_ratio:
814
+ raise _CircuitBreakerTripped(
815
+ f"{failed}/{completed} completed scenarios failed "
816
+ f"({failed / completed:.0%} >= {cfg.fail_ratio:.0%} threshold)."
817
+ )
818
+
819
+ return _check
820
+
821
+
822
+ def _report_exclusion_groups(tasks: list, console: Console) -> None:
823
+ """Print the exclusion-group census, or nothing when none are declared.
824
+
825
+ Every group is named with its member count, on one line, because that is what
826
+ makes a typo visible: ``stripe-sandbox[1], stripe-sandbx[1]`` reads as a
827
+ mistake at a glance, whereas two silently-independent groups do not.
828
+
829
+ A single-member group gets its own warning, since it serialises nothing. It
830
+ is only a warning: ``--tag`` legitimately narrows a group to one selected
831
+ member — though that is worth noticing too, because the guarantee holds
832
+ within one run, so a group split across two shard runs has lost it.
833
+ """
834
+ batches = [t for t in tasks if t.is_batch]
835
+ if not batches:
836
+ return
837
+ census = ", ".join(f"{t.group}[{len(t.slugs)}]" for t in batches)
838
+ grouped = sum(len(t.slugs) for t in batches)
839
+ console.print(
840
+ f"[dim]exclusion groups: {census} — {len(batches)} group(s) serialising "
841
+ f"{grouped} scenario(s) into {len(batches)} task(s)[/dim]"
842
+ )
843
+ singletons = [t.group for t in batches if len(t.slugs) == 1]
844
+ if singletons:
845
+ console.print(
846
+ f"[yellow]WARNING[/yellow] exclusion group(s) with a single selected scenario: "
847
+ f"{', '.join(singletons)}. A narrowed selection explains this; otherwise it is "
848
+ f"a typo, and the group is serialising nothing."
849
+ )
850
+
851
+
852
+ _ABANDONED_LIST_CAP = 10
853
+
854
+
855
+ def _report_abandoned(slugs: list[str], console: Console) -> None:
856
+ """Print the scenarios that were queued and never started.
857
+
858
+ Capped, because a tripped circuit breaker abandons the whole remaining queue
859
+ and two hundred lines is not a summary. Printed here rather than through
860
+ ``render_summary`` so the renderer's contract is untouched.
861
+ """
862
+ shown = slugs[:_ABANDONED_LIST_CAP]
863
+ console.print()
864
+ console.rule("[bold red]Never started[/bold red]")
865
+ console.print(f" [red]{len(slugs)} scenario(s) were queued but never claimed by any worker.[/red]")
866
+ for slug in shown:
867
+ console.print(f" [dim]{slug}[/dim]")
868
+ if len(slugs) > len(shown):
869
+ console.print(f" [dim]… and {len(slugs) - len(shown)} more[/dim]")
870
+ console.print(
871
+ " [dim]This means every worker exited with work left — check "
872
+ "output/failures/_worker-logs/. The run is reported as failed.[/dim]"
873
+ )
874
+
875
+
876
+ def _order_tasks_longest_first(tasks: list, project_root: Path, console: Console) -> list:
877
+ """Reorder the queue so recorded-slow tasks are claimed first.
878
+
879
+ A batch is one scheduling unit, so its weight is the **sum** of its members'
880
+ medians — otherwise a large exclusion group claimed last becomes the run's
881
+ tail while every other worker idles. Tasks with no recorded member sort last
882
+ and ties keep declaration order, mirroring ``history.order_by_duration_desc``;
883
+ for an all-singleton run this reduces to it element for element.
884
+
885
+ Best-effort, like the ordering it replaces: never fatal.
886
+ """
887
+ try:
888
+ from .history import median_durations
889
+
890
+ durations = median_durations(project_root)
891
+ if not durations:
892
+ return tasks
893
+ weights: dict[str, float] = {}
894
+ known: list = []
895
+ unknown: list = []
896
+ for index, task in enumerate(tasks):
897
+ total = sum(durations[s] for s in task.slugs if s in durations)
898
+ if total > 0:
899
+ weights[task.task_id] = total
900
+ known.append((index, task))
901
+ else:
902
+ unknown.append(task)
903
+ known.sort(key=lambda pair: (-weights[pair[1].task_id], pair[0]))
904
+ ordered = [task for _, task in known] + unknown
905
+ console.print(
906
+ f"[dim]queue order: longest-first from run history ({len(known)}/{len(tasks)} with timings)[/dim]"
907
+ )
908
+ return ordered
909
+ except Exception: # noqa: BLE001 — ordering is an optimisation, never fatal
910
+ return tasks
911
+
912
+
913
+ def _prepare_isolated_auth(*, enabled, worker_count, project_root, tmp_dir, console) -> list[str]:
914
+ """Create a private, pre-seeded ``.auth`` dir per worker. Returns the list of
915
+ absolute host paths (posix), or ``[]`` when the feature is off.
916
+
917
+ Seeding from the project's shared ``.auth`` means a still-valid cached
918
+ session is reused instead of forcing N cold logins on every run; each worker
919
+ then owns its copy, so re-auth writes never race.
920
+ """
921
+ if not enabled:
922
+ return []
923
+ shared = project_root / ".auth"
924
+ mounts: list[str] = []
925
+ for i in range(1, worker_count + 1):
926
+ worker_auth = tmp_dir / "auth" / f"w{i}"
927
+ worker_auth.mkdir(parents=True, exist_ok=True)
928
+ if shared.is_dir():
929
+ for src in shared.glob("*.json"):
930
+ try:
931
+ shutil.copy2(src, worker_auth / src.name)
932
+ except OSError:
933
+ pass
934
+ mounts.append(worker_auth.resolve().as_posix())
935
+ console.print(f"[dim]auth isolation: {worker_count} private .auth dir(s) (parallel.isolate_auth)[/dim]")
936
+ return mounts
937
+
938
+
939
+ def _run_retry_passes(
940
+ *,
941
+ enabled,
942
+ passes,
943
+ retry_workers,
944
+ queue,
945
+ bus,
946
+ group_of,
947
+ project_names,
948
+ compose_file,
949
+ tmp_dir,
950
+ base_override_kwargs,
951
+ auth_mounts,
952
+ console,
953
+ verbose,
954
+ lifecycle,
955
+ ) -> list:
956
+ """Drive up to ``passes`` retry attempts. Returns the list of PassResults.
957
+
958
+ Each attempt re-runs only what the previous one left failing (including
959
+ orphans, so a dead worker's scenario gets a second chance instead of being
960
+ a hard loss). Stops early once nothing is failing.
961
+
962
+ Never raises: a retry pass that cannot start degrades to "no retry" and the
963
+ main pass's verdict stands.
964
+ """
965
+ if not enabled:
966
+ return []
967
+ from ._parallel.retry import PassResult, run_retry_pass
968
+
969
+ prior = PassResult(
970
+ attempt=1,
971
+ coord_root=tmp_dir,
972
+ results=queue.collect_results(),
973
+ # Reads the batch map off the Queue that seeded this directory, so orphans
974
+ # are always member slugs — never an exclusion group's NAME, which would
975
+ # seed a retry task for a test file that does not exist and leave the
976
+ # group's real members un-retried.
977
+ orphans=queue.orphaned_running_tasks(),
978
+ bus=bus,
979
+ never_started=set(queue.unclaimed_slugs()),
980
+ )
981
+ pending = sorted(prior.pending_slugs)
982
+ if not pending:
983
+ return []
984
+
985
+ # Slice the stacks down to the configured retry width. The unused stacks
986
+ # keep running (they're torn down with the rest at the end) — we just don't
987
+ # give them a worker.
988
+ projects = project_names[: max(1, min(retry_workers, len(project_names)))]
989
+
990
+ out: list = []
991
+ for attempt in range(2, 2 + passes):
992
+ # Regroup INSIDE the loop: ``pending`` is reassigned per attempt, so
993
+ # partitioning once outside would let attempt 3 run members of one
994
+ # exclusion group concurrently whenever retry.workers is above 1.
995
+ retry_tasks = exclusive.partition(pending, group_of or {})
996
+ retry_batches = {t.task_id: list(t.slugs) for t in retry_tasks if t.is_batch}
997
+ result = run_retry_pass(
998
+ attempt=attempt,
999
+ slugs=[t.task_id for t in retry_tasks],
1000
+ batches=retry_batches or None,
1001
+ projects=projects,
1002
+ compose_file=compose_file,
1003
+ tmp_dir=tmp_dir,
1004
+ override_kwargs={**base_override_kwargs, "auth_mount_by_worker": auth_mounts},
1005
+ console=console,
1006
+ verbose=verbose,
1007
+ resolve_container_ids=_resolve_worker_container_ids,
1008
+ wait_for_workers=_wait_for_workers,
1009
+ register_teardown=lifecycle.on_teardown,
1010
+ )
1011
+ if result is None:
1012
+ break
1013
+ out.append(result)
1014
+ pending = sorted(result.pending_slugs)
1015
+ if not pending:
1016
+ break
1017
+ return out
1018
+
1019
+
1020
+ # ── Selection helpers (mirror cli.run's filter logic) ──────────────────────
1021
+
1022
+
1023
+ class _SelectionExit(Exception):
1024
+ """Raised by _select_scenario_slugs to short-circuit on user errors. The
1025
+ code attribute is the exit code we should return from run_parallel."""
1026
+
1027
+ def __init__(self, code: int):
1028
+ super().__init__()
1029
+ self.code = code
1030
+
1031
+
1032
+ def _select_scenario_slugs(
1033
+ *,
1034
+ scenarios_dir: Path,
1035
+ tests_dir: Path,
1036
+ scenario: Optional[str],
1037
+ tag: Optional[list[str]],
1038
+ guide: bool,
1039
+ no_guide: bool,
1040
+ console: Console,
1041
+ ) -> list[str]:
1042
+ """Mirror of the filter logic in cli.run, returning the list of scenario
1043
+ slugs to enqueue. We only need slugs (not test_file paths) since workers
1044
+ re-derive the test_file from the slug. Delegates to the shared
1045
+ ``selection.select_scenario_slugs`` (FLAWS §8.3) so this can't drift from the
1046
+ single-worker path; translates its ``SelectionError`` to the ``_SelectionExit``
1047
+ that ``run_parallel`` already catches."""
1048
+ try:
1049
+ return selection.select_scenario_slugs(
1050
+ scenarios_dir=scenarios_dir,
1051
+ tests_dir=tests_dir,
1052
+ scenario=scenario,
1053
+ tag=tag,
1054
+ guide=guide,
1055
+ no_guide=no_guide,
1056
+ )
1057
+ except selection.SelectionError as e:
1058
+ console.print(f"[red]ERROR[/red] {e.message}")
1059
+ if e.hint:
1060
+ console.print(f"[yellow]Hint:[/yellow] {e.hint}")
1061
+ raise _SelectionExit(e.exit_code)
1062
+
1063
+
1064
+ def _missing_internal_urls(config: SteppyConfig, scenarios_dir: Path, slugs: list[str]) -> set[str]:
1065
+ """Return the set of role names that are used by selected scenarios but
1066
+ have no `internal_url` configured. We re-parse to learn which roles each
1067
+ scenario touches."""
1068
+ if not scenarios_dir.exists():
1069
+ return set()
1070
+ try:
1071
+ scenarios = parse_scenarios_from_directory(scenarios_dir)
1072
+ except ParseError:
1073
+ return set()
1074
+ selected = {s.slug: s for s in scenarios if s.slug in set(slugs)}
1075
+ used_roles: set[str] = set()
1076
+ for s in selected.values():
1077
+ for step in s.steps:
1078
+ role = getattr(step, "role", None)
1079
+ if isinstance(role, str) and role and role != "all":
1080
+ used_roles.add(role)
1081
+ missing: set[str] = set()
1082
+ for role in used_roles:
1083
+ app = config.apps.get(role)
1084
+ if app is None:
1085
+ # Role not in config — that's a different validation problem,
1086
+ # surface it as missing internal_url to fail fast.
1087
+ missing.add(role)
1088
+ elif not app.internal_url:
1089
+ missing.add(role)
1090
+ return missing
1091
+
1092
+
1093
+ def _resolve_locales(locales: Optional[str], guide: bool, config: SteppyConfig, console: Console) -> list[str]:
1094
+ try:
1095
+ return selection.resolve_locales(locales, guide, config)
1096
+ except selection.SelectionError as e:
1097
+ console.print(f"[red]ERROR[/red] {e.message}")
1098
+ raise _SelectionExit(e.exit_code)
1099
+
1100
+
1101
+ def _resolve_layouts(layouts: Optional[str], guide: bool, config: SteppyConfig, console: Console) -> list[str]:
1102
+ try:
1103
+ return selection.resolve_layouts(layouts, guide, config)
1104
+ except selection.SelectionError as e:
1105
+ console.print(f"[red]ERROR[/red] {e.message}")
1106
+ raise _SelectionExit(e.exit_code)
1107
+
1108
+
1109
+ def _resolve_effective_workers(workers: int | str, parallel_cfg, slugs: list[str]) -> int:
1110
+ """Resolve auto / explicit workers count, capped at scenario count."""
1111
+ if workers == "auto" or (isinstance(workers, str) and workers.lower() == "auto"):
1112
+ cpu = os.cpu_count() or 2
1113
+ return max(1, min(cpu // 2, len(slugs), _DEFAULT_AUTO_CAP))
1114
+ return min(int(workers), len(slugs))
1115
+
1116
+
1117
+ def _maybe_warn_resources(workers: int, console: Console) -> None:
1118
+ """Print a soft warning if the worker count looks high for this host.
1119
+ Heuristic: > cpu_count / 2 OR > 8 workers triggers the warning. Doesn't
1120
+ block — users may have headroom we can't detect."""
1121
+ cpu = os.cpu_count() or 2
1122
+ if workers > max(1, cpu // 2) or workers > _DEFAULT_AUTO_CAP:
1123
+ console.print(
1124
+ f"[yellow]WARNING[/yellow] requesting {workers} parallel stacks on "
1125
+ f"{cpu}-CPU host — make sure your machine has enough RAM/CPU "
1126
+ f"(each stack runs your full app + chromium)."
1127
+ )
1128
+
1129
+
1130
+ # ── Compose lifecycle helpers ──────────────────────────────────────────────
1131
+
1132
+
1133
+ class _StackError(RuntimeError):
1134
+ pass
1135
+
1136
+
1137
+ def _bring_up_all_stacks(
1138
+ *,
1139
+ stack_id: str,
1140
+ worker_count: int,
1141
+ compose_file: Path,
1142
+ overrides: list[Path],
1143
+ healthcheck_timeout: int,
1144
+ lifecycle: CoordinatorLifecycle,
1145
+ console: Console,
1146
+ ) -> list[str]:
1147
+ """Spin up every stack in parallel via `compose up -d --wait`. Registers
1148
+ a teardown callback for each stack as soon as up is invoked. Returns the
1149
+ list of project names.
1150
+
1151
+ Keyed on ``stack_id`` (short, opaque) rather than the run's identity —
1152
+ Compose project names have their own charset rules and end up in
1153
+ ``docker ps`` output, and ``steppy stacks cleanup`` matches them by the
1154
+ ``steppy-run-`` prefix alone."""
1155
+ project_names = [f"{_PROJECT_NAME_PREFIX}{stack_id}-w{i}" for i in range(1, worker_count + 1)]
1156
+
1157
+ def _up_one(idx: int) -> tuple[int, Optional[str]]:
1158
+ project = project_names[idx]
1159
+ override = overrides[idx]
1160
+ # Register teardown BEFORE invoking up so a crash mid-up still cleans
1161
+ # up partial state. The compose down call is idempotent.
1162
+ lifecycle.on_teardown(lambda p=project: _down(p, console))
1163
+ cmd = [
1164
+ "docker",
1165
+ "compose",
1166
+ "-p",
1167
+ project,
1168
+ "-f",
1169
+ str(compose_file),
1170
+ "-f",
1171
+ str(override),
1172
+ "up",
1173
+ "-d",
1174
+ "--wait",
1175
+ "--wait-timeout",
1176
+ str(healthcheck_timeout),
1177
+ ]
1178
+ result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
1179
+ if result.returncode != 0:
1180
+ return result.returncode, f"{project}: {result.stderr.strip()}"
1181
+ return 0, None
1182
+
1183
+ failures: list[str] = []
1184
+ with ThreadPoolExecutor(max_workers=worker_count) as pool:
1185
+ futures = [pool.submit(_up_one, i) for i in range(worker_count)]
1186
+ for fut in as_completed(futures):
1187
+ code, err = fut.result()
1188
+ if code != 0:
1189
+ failures.append(err or f"unknown error (exit {code})")
1190
+
1191
+ if failures:
1192
+ raise _StackError("; ".join(failures))
1193
+
1194
+ console.print(f"[green]✓[/green] all {worker_count} stack(s) healthy")
1195
+ return project_names
1196
+
1197
+
1198
+ def _resolve_worker_container_ids(project_names: list[str]) -> dict[str, str]:
1199
+ """Look up each project's steppy-worker container ID once, right after
1200
+ ``compose up -d --wait`` returns. Raises ``_StackError`` if any project
1201
+ can't be resolved — better to fail fast than have ``_wait_for_workers``
1202
+ spin in a silent loop later.
1203
+
1204
+ The ``-a`` flag in :func:`_get_worker_container_id` is required: a worker
1205
+ that fails fast on a misconfig may already be in ``exited`` state by the
1206
+ time we get here.
1207
+ """
1208
+ container_ids: dict[str, str] = {}
1209
+ for project in project_names:
1210
+ cid = _get_worker_container_id(project)
1211
+ if cid is None:
1212
+ raise _StackError(
1213
+ f"could not resolve steppy-worker container for {project} "
1214
+ f"(docker compose ps -a -q steppy-worker returned nothing)"
1215
+ )
1216
+ container_ids[project] = cid
1217
+ return container_ids
1218
+
1219
+
1220
+ def _wait_for_workers(container_ids: dict[str, str], console: Console, on_poll=None) -> list[int]:
1221
+ """Block until every worker container exits. Returns each project's
1222
+ steppy-worker container exit code, in the same order as the input dict.
1223
+
1224
+ ``container_ids`` is the map produced by :func:`_resolve_worker_container_ids`
1225
+ right after ``compose up -d --wait``. Polling uses ``docker inspect``
1226
+ directly on the cached ID — no per-poll ``compose ps`` round-trip.
1227
+
1228
+ ``on_poll`` is called once per cycle while workers are still running. It is
1229
+ the circuit breaker's hook: raising :class:`_CircuitBreakerTripped` from it
1230
+ aborts the wait (and, via the caller's ExitStack, the whole run). It is
1231
+ deliberately called only while something is still pending — there is nothing
1232
+ to abort once every worker has exited.
1233
+ """
1234
+ pending = dict(container_ids)
1235
+ exit_codes: dict[str, int] = {}
1236
+ poll_interval = 1.5
1237
+
1238
+ while pending:
1239
+ for project, container_id in list(pending.items()):
1240
+ state = _inspect_container_state(container_id)
1241
+ if state and state.get("Status") == "exited":
1242
+ exit_codes[project] = int(state.get("ExitCode", 1))
1243
+ del pending[project]
1244
+ console.print(f"[dim]{project}: worker exited (code {exit_codes[project]})[/dim]")
1245
+ if pending:
1246
+ if on_poll is not None:
1247
+ on_poll()
1248
+ time.sleep(poll_interval)
1249
+
1250
+ return [exit_codes.get(p, 1) for p in container_ids.keys()]
1251
+
1252
+
1253
+ def _get_worker_container_id(project: str) -> Optional[str]:
1254
+ """Return the docker container ID of the steppy-worker service in this
1255
+ project, or None if it doesn't exist.
1256
+
1257
+ ``-a`` is load-bearing: without it ``docker compose ps -q`` hides
1258
+ containers whose status is ``exited``. After a worker drains the
1259
+ queue and the container exits cleanly, the wait loop would otherwise
1260
+ see ``None`` forever and hang. See ``tests/test_parallel_coordinator_wait``.
1261
+ """
1262
+ try:
1263
+ result = subprocess.run(
1264
+ ["docker", "compose", "-p", project, "ps", "-a", "-q", "steppy-worker"],
1265
+ capture_output=True,
1266
+ text=True,
1267
+ encoding="utf-8",
1268
+ errors="replace",
1269
+ timeout=10,
1270
+ )
1271
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1272
+ return None
1273
+ cid = result.stdout.strip()
1274
+ return cid or None
1275
+
1276
+
1277
+ def _inspect_container_state(container_id: str) -> Optional[dict]:
1278
+ try:
1279
+ result = subprocess.run(
1280
+ ["docker", "inspect", "--format", "{{json .State}}", container_id],
1281
+ capture_output=True,
1282
+ text=True,
1283
+ encoding="utf-8",
1284
+ errors="replace",
1285
+ timeout=10,
1286
+ )
1287
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1288
+ return None
1289
+ if result.returncode != 0:
1290
+ return None
1291
+ try:
1292
+ return json.loads(result.stdout)
1293
+ except json.JSONDecodeError:
1294
+ return None
1295
+
1296
+
1297
+ def _down(project: str, console: Optional[Console] = None) -> None:
1298
+ """Tear down one stack via `docker compose -p <project> down -v`. Best
1299
+ effort — never raises; teardown errors are logged to stderr by the
1300
+ lifecycle's _safe_call."""
1301
+ cmd = ["docker", "compose", "-p", project, "down", "-v", "--timeout", "10"]
1302
+ try:
1303
+ result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
1304
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
1305
+ if console:
1306
+ console.print(f"[yellow]teardown[/yellow] {project}: {e}")
1307
+ return
1308
+ if result.returncode != 0 and console:
1309
+ console.print(f"[yellow]teardown[/yellow] {project}: {result.stderr.strip()}")
1310
+
1311
+
1312
+ # ── Rescue command (steppy stacks cleanup) ─────────────────────────────────
1313
+
1314
+
1315
+ def cleanup_stacks() -> int:
1316
+ """List orphaned ``steppy-run-*`` Compose projects and tear them down.
1317
+ Returns the number of stacks cleaned (for the CLI to surface)."""
1318
+ console = Console()
1319
+ try:
1320
+ result = subprocess.run(
1321
+ ["docker", "compose", "ls", "--all", "--format", "json"],
1322
+ capture_output=True,
1323
+ text=True,
1324
+ encoding="utf-8",
1325
+ errors="replace",
1326
+ timeout=15,
1327
+ )
1328
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
1329
+ console.print(f"[red]ERROR[/red] could not list compose projects: {e}")
1330
+ return 1
1331
+ if result.returncode != 0:
1332
+ console.print(f"[red]ERROR[/red] docker compose ls failed: {result.stderr.strip()}")
1333
+ return 1
1334
+
1335
+ try:
1336
+ projects = json.loads(result.stdout) or []
1337
+ except json.JSONDecodeError:
1338
+ console.print("[red]ERROR[/red] could not parse compose ls output")
1339
+ return 1
1340
+
1341
+ targets = [
1342
+ p["Name"] for p in projects if isinstance(p, dict) and p.get("Name", "").startswith(_PROJECT_NAME_PREFIX)
1343
+ ]
1344
+ if not targets:
1345
+ console.print("[green]✓[/green] no orphaned steppy-run-* stacks found")
1346
+ return 0
1347
+
1348
+ console.print(f"[bold]Cleaning up {len(targets)} orphaned stack(s):[/bold] {', '.join(targets)}")
1349
+ for project in targets:
1350
+ _down(project, console)
1351
+ console.print("[green]✓[/green] cleanup complete")
1352
+ return 0