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
@@ -0,0 +1,90 @@
1
+ """Guaranteed-teardown context for the coordinator.
2
+
3
+ Wraps an ``ExitStack`` plus signal handlers (SIGINT/SIGTERM on POSIX,
4
+ SIGINT only on Windows — Windows doesn't deliver SIGTERM to console apps).
5
+ The coordinator pushes a ``compose down -v`` callback for each stack as
6
+ soon as ``compose up`` is invoked. Any of:
7
+
8
+ - normal completion (queue drained, all workers exited 0)
9
+ - user Ctrl-C
10
+ - uncaught exception in coordinator
11
+ - SIGTERM from outside
12
+
13
+ …triggers ``ExitStack.close()`` which fires every registered teardown in
14
+ LIFO order. This is the single place that guarantees no orphaned
15
+ containers, networks, or volumes after a parallel run.
16
+
17
+ The Ctrl-C path raises ``KeyboardInterrupt`` after first calling the
18
+ default signal handler, so subsequent Ctrl-C presses still work normally
19
+ (double Ctrl-C escape hatch — useful if a teardown itself hangs).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import signal
26
+ import sys
27
+ from typing import Callable
28
+
29
+
30
+ class CoordinatorLifecycle:
31
+ """Context manager: ``with CoordinatorLifecycle() as lc: lc.on_teardown(fn)``.
32
+
33
+ Restores the previous signal handlers on exit so this doesn't leak signal
34
+ state to a calling process.
35
+ """
36
+
37
+ def __init__(self):
38
+ self._stack = contextlib.ExitStack()
39
+ self._previous_handlers: dict[int, Callable | int | None] = {}
40
+ self._signals_to_handle: list[int] = [signal.SIGINT]
41
+ if hasattr(signal, "SIGTERM") and sys.platform != "win32":
42
+ # Windows console apps don't receive SIGTERM in the usual sense;
43
+ # skip it to avoid OSError on signal.signal().
44
+ self._signals_to_handle.append(signal.SIGTERM)
45
+
46
+ def __enter__(self) -> "CoordinatorLifecycle":
47
+ self._stack.__enter__()
48
+ for sig in self._signals_to_handle:
49
+ try:
50
+ self._previous_handlers[sig] = signal.getsignal(sig)
51
+ signal.signal(sig, self._on_signal)
52
+ except (ValueError, OSError):
53
+ # signal.signal raises ValueError off the main thread; OSError
54
+ # for unsupported signals on the platform. Either way, skip.
55
+ pass
56
+ return self
57
+
58
+ def __exit__(self, exc_type, exc, tb):
59
+ # Restore signal handlers BEFORE running teardown so a Ctrl-C during
60
+ # teardown gets handled by the default Python interrupt logic.
61
+ for sig, prev in self._previous_handlers.items():
62
+ try:
63
+ signal.signal(sig, prev)
64
+ except (ValueError, OSError):
65
+ pass
66
+ return self._stack.__exit__(exc_type, exc, tb)
67
+
68
+ def on_teardown(self, fn: Callable[[], None]) -> None:
69
+ """Register a callback to run on context exit (LIFO order)."""
70
+ self._stack.callback(self._safe_call, fn)
71
+
72
+ @staticmethod
73
+ def _safe_call(fn: Callable[[], None]) -> None:
74
+ """Run a teardown callback; never let an exception escape and abort
75
+ subsequent teardowns. Logs to stderr instead."""
76
+ try:
77
+ fn()
78
+ except Exception as e: # noqa: BLE001 — teardown must be best-effort
79
+ print(f"steppy teardown error (continuing): {e}", file=sys.stderr)
80
+
81
+ def _on_signal(self, signum, frame):
82
+ """Convert SIGINT/SIGTERM into KeyboardInterrupt so the coordinator's
83
+ outer try/except can react and ExitStack runs teardowns. Restores
84
+ the previous handler immediately so a second Ctrl-C uses the default
85
+ behavior (panic-exit if a teardown hangs)."""
86
+ try:
87
+ signal.signal(signum, self._previous_handlers.get(signum, signal.SIG_DFL))
88
+ except (ValueError, OSError):
89
+ pass
90
+ raise KeyboardInterrupt()
@@ -0,0 +1,109 @@
1
+ """In-network pre-authentication lock-wrapper for parallel mode.
2
+
3
+ Runs INSIDE a one-off ``steppy-preauth`` container. The coordinator renders one
4
+ such service per worker stack (so it can be gated, per-stack, before that
5
+ stack's ``steppy-worker`` claims a scenario), which means N containers would
6
+ otherwise all run the user's login command at once and race on the shared
7
+ ``.auth`` cache. Instead they share ``/steppy/coord`` (the coordinator tmp dir,
8
+ bind-mounted into every stack) and use the SAME O_EXCL claim primitive as the
9
+ work queue (`_parallel/queue.py`) to elect exactly ONE winner:
10
+
11
+ * winner — runs the user's ``pre_auth.command``; on success drops a
12
+ ``preauth.done`` sentinel, on failure a ``preauth.failed`` one.
13
+ * losers — block until they see the winner's sentinel, then exit with the
14
+ matching code (0 on done, 1 on failed / timeout).
15
+
16
+ So the login happens once per run even though every stack starts a preauth
17
+ container. The sentinels live in the per-run coord dir, so a *new* run re-runs
18
+ the command (inter-run freshness is the command's own concern — e.g. the
19
+ capture script skips roles whose session is still fresh).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+
31
+ LOCK_NAME = "preauth.lock"
32
+ DONE_NAME = "preauth.done"
33
+ FAILED_NAME = "preauth.failed"
34
+ _POLL_INTERVAL = 0.5
35
+
36
+
37
+ def run_preauth(
38
+ command: list[str],
39
+ *,
40
+ timeout: int,
41
+ coord_dir: Path | None = None,
42
+ ) -> int:
43
+ """Elect one winner (O_EXCL) to run ``command``; losers wait for its
44
+ sentinel. Returns a process exit code (0 = auth ready for this stack).
45
+
46
+ ``coord_dir`` defaults to ``$STEPPY_COORD_DIR`` (``/steppy/coord`` in the
47
+ container). ``command`` is the argv to execute (a plain shell string is
48
+ wrapped as ``sh -c`` by the caller before it reaches here).
49
+ """
50
+ if not command:
51
+ print("[preauth] no command given — nothing to do", file=sys.stderr, flush=True)
52
+ return 2
53
+
54
+ coord = Path(coord_dir) if coord_dir is not None else Path(os.environ.get("STEPPY_COORD_DIR", "/steppy/coord"))
55
+ coord.mkdir(parents=True, exist_ok=True)
56
+ lock_path = coord / LOCK_NAME
57
+ done_path = coord / DONE_NAME
58
+ failed_path = coord / FAILED_NAME
59
+
60
+ # A previous stack already settled auth this run — short-circuit.
61
+ if done_path.exists():
62
+ return 0
63
+ if failed_path.exists():
64
+ return 1
65
+
66
+ try:
67
+ fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
68
+ except FileExistsError:
69
+ # Another stack is (or was) the winner — wait for its verdict.
70
+ return _wait_for_sentinel(done_path, failed_path, timeout)
71
+
72
+ # We won the claim: run the command exactly once for this run.
73
+ try:
74
+ os.write(fd, str(os.getpid()).encode("utf-8"))
75
+ finally:
76
+ os.close(fd)
77
+
78
+ print(f"[preauth] winner — running: {' '.join(command)}", flush=True)
79
+ try:
80
+ code = subprocess.run(command, timeout=timeout).returncode
81
+ except subprocess.TimeoutExpired:
82
+ print(f"[preauth] command timed out after {timeout}s", file=sys.stderr, flush=True)
83
+ code = 124
84
+ except FileNotFoundError as e:
85
+ print(f"[preauth] command not found: {e}", file=sys.stderr, flush=True)
86
+ code = 127
87
+
88
+ if code == 0:
89
+ done_path.write_text("ok", encoding="utf-8")
90
+ print("[preauth] done — auth cache is ready", flush=True)
91
+ else:
92
+ failed_path.write_text(str(code), encoding="utf-8")
93
+ print(f"[preauth] FAILED (exit {code})", file=sys.stderr, flush=True)
94
+ return code
95
+
96
+
97
+ def _wait_for_sentinel(done_path: Path, failed_path: Path, timeout: int) -> int:
98
+ """Loser path: block until the winner signals done/failed, or timeout."""
99
+ print("[preauth] another stack is authenticating; waiting…", flush=True)
100
+ deadline = time.monotonic() + max(1, timeout)
101
+ while time.monotonic() < deadline:
102
+ if done_path.exists():
103
+ return 0
104
+ if failed_path.exists():
105
+ print("[preauth] auth winner reported failure", file=sys.stderr, flush=True)
106
+ return 1
107
+ time.sleep(_POLL_INTERVAL)
108
+ print("[preauth] timed out waiting for the auth winner", file=sys.stderr, flush=True)
109
+ return 1
@@ -0,0 +1,101 @@
1
+ """Tail each worker container's stdout and dispatch lines to the bus.
2
+
3
+ For each Compose project, a thread runs
4
+ ``docker compose -p <project> logs -f --no-color steppy-worker`` and
5
+ feeds every line into the shared :class:`EventBus`. The bus parses
6
+ ``STEPPY-EVT::*`` lines into typed events; everything else falls
7
+ through to the raw-log channel (printed when ``--verbose`` is set,
8
+ always written to a per-worker file for post-mortem).
9
+
10
+ A second thread per worker captures the full-stack logs (every service
11
+ in the project, not just steppy-worker) into a separate file so the
12
+ user can dig in after a failure.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import subprocess
18
+ import threading
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from .event_bus import EventBus
24
+
25
+
26
+ @dataclass
27
+ class WorkerLogTailer:
28
+ """Background thread tailing one worker's logs into the EventBus."""
29
+
30
+ project: str
31
+ worker_id: str
32
+ bus: EventBus
33
+ app_log_file: Path # full-stack logs go here for post-mortem
34
+ _proc: Optional[subprocess.Popen] = field(default=None, init=False, repr=False)
35
+ _thread: Optional[threading.Thread] = field(default=None, init=False, repr=False)
36
+ _stop: Optional[threading.Event] = field(default=None, init=False, repr=False)
37
+
38
+ def start(self) -> None:
39
+ self._stop = threading.Event()
40
+ self._thread = threading.Thread(target=self._run, name=f"log-{self.worker_id}", daemon=True)
41
+ self._thread.start()
42
+ # Also kick off a fire-and-forget capture of the entire stack's logs
43
+ # (worker + app) into a per-worker file so users have a post-mortem
44
+ # if the run fails.
45
+ self.app_log_file.parent.mkdir(parents=True, exist_ok=True)
46
+ threading.Thread(target=self._capture_full_stack, name=f"log-full-{self.worker_id}", daemon=True).start()
47
+
48
+ def _run(self) -> None:
49
+ cmd = [
50
+ "docker",
51
+ "compose",
52
+ "-p",
53
+ self.project,
54
+ "logs",
55
+ "-f",
56
+ "--no-color",
57
+ "--no-log-prefix",
58
+ "steppy-worker",
59
+ ]
60
+ try:
61
+ self._proc = subprocess.Popen(
62
+ cmd,
63
+ stdout=subprocess.PIPE,
64
+ stderr=subprocess.STDOUT,
65
+ text=True,
66
+ encoding="utf-8",
67
+ errors="replace",
68
+ bufsize=1,
69
+ )
70
+ except FileNotFoundError:
71
+ return
72
+ assert self._proc.stdout is not None
73
+ for line in self._proc.stdout:
74
+ if self._stop is not None and self._stop.is_set():
75
+ break
76
+ self.bus.feed_line(self.worker_id, line)
77
+
78
+ def _capture_full_stack(self) -> None:
79
+ """Capture every service's logs into ``self.app_log_file`` for
80
+ post-mortem. Runs ``compose logs -f`` over the whole project (no
81
+ service filter) and writes raw output. Best-effort; if it fails
82
+ we just lose the post-mortem file, never the run."""
83
+ cmd = ["docker", "compose", "-p", self.project, "logs", "-f", "--no-color"]
84
+ try:
85
+ with self.app_log_file.open("w", encoding="utf-8") as f:
86
+ subprocess.run(
87
+ cmd, stdout=f, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", check=False
88
+ )
89
+ except (FileNotFoundError, OSError):
90
+ pass
91
+
92
+ def stop(self) -> None:
93
+ if self._stop is not None:
94
+ self._stop.set()
95
+ if self._proc is not None and self._proc.poll() is None:
96
+ try:
97
+ self._proc.terminate()
98
+ except OSError:
99
+ pass
100
+ if self._thread is not None:
101
+ self._thread.join(timeout=2)
@@ -0,0 +1,385 @@
1
+ """Filesystem work-stealing queue for parallel scenario execution.
2
+
3
+ Layout under the run's tmp dir (mounted into every worker at /steppy/coord):
4
+ queue/<task_id>.task — marker, written by the coordinator; never moved
5
+ claims/<task_id>.claim — atomic O_EXCL create by a worker; content = worker_id
6
+ results/<worker_id>/<slug>.json — per-SCENARIO results, written by workers
7
+
8
+ A task is one scenario by default, and ``task_id`` is then the bare slug and the
9
+ marker file is empty — exactly as it has always been. An **exclusion group**
10
+ (frontmatter ``exclusive: <name>``) collapses its members into a single task whose
11
+ id is the group name and whose marker carries one line of JSON naming its members;
12
+ the worker claiming it runs them sequentially, one pytest process each. That is the
13
+ whole mechanism: mutual exclusion is a property of how the queue was seeded, not a
14
+ run-time protocol, so there is no lock to leak and no worker ever waits on another.
15
+
16
+ Results, logs and JUnit stay keyed **per scenario** even for a batch, which is what
17
+ lets every downstream consumer (orphan detection, retry classification, failure
18
+ artifacts, history, the renderer) keep its per-slug key unchanged.
19
+
20
+ Claiming is atomic via ``os.open(claim_path, O_WRONLY|O_CREAT|O_EXCL)``,
21
+ which is atomic on both POSIX and Windows (battle-tested mail-spool
22
+ pattern). When two workers race for the same task, exactly one O_EXCL
23
+ succeeds and the other gets ``FileExistsError`` and tries the next
24
+ candidate.
25
+
26
+ We chose O_EXCL over the rename-claim variant because ``os.rename`` on
27
+ Windows under heavy concurrency does not provide the source-side mutual
28
+ exclusion we need (observed ~20% double-claim rate in the test).
29
+
30
+ Workers loop:
31
+
32
+ while True:
33
+ task = queue.claim_task(worker_id)
34
+ if task is None:
35
+ break # queue empty
36
+ for slug in task.slugs: # exactly one, unless it is an exclusion group
37
+ result = run_scenario(slug)
38
+ queue.write_result(result)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import os
45
+ import re
46
+ from dataclasses import dataclass
47
+ from pathlib import Path
48
+ from typing import Optional
49
+
50
+ from ..exclusive import Task
51
+
52
+
53
+ TASK_SUFFIX = ".task"
54
+ CLAIM_SUFFIX = ".claim"
55
+ RESULT_SUFFIX = ".json"
56
+
57
+ # Version stamped into a batch marker's payload. Ungrouped tasks carry no payload
58
+ # at all, so this only ever appears in a queue that declares an exclusion group.
59
+ TASK_PAYLOAD_VERSION = 1
60
+
61
+ # Worker key used to report a batch whose claim file was created but never written
62
+ # — a worker killed between the claim's ``os.open`` and its ``os.write``. Its real
63
+ # id is unrecoverable, and every member is unstarted by construction (the id is
64
+ # written before any scenario runs), so they are all orphans of nobody.
65
+ UNKNOWN_WORKER = "(unknown)"
66
+
67
+
68
+ class TaskPayloadError(Exception):
69
+ """A batch marker exists but could not be read or parsed.
70
+
71
+ The claim already succeeded when this is raised, so the task is claimed with no
72
+ results: its members surface as orphans and are retried. The worker reports it
73
+ and moves on rather than dying, because one unreadable marker must not cost the
74
+ rest of the queue.
75
+ """
76
+
77
+ def __init__(self, task_id: str, reason: str):
78
+ super().__init__(f"unreadable task payload for {task_id!r}: {reason}")
79
+ self.task_id = task_id
80
+ self.reason = reason
81
+
82
+
83
+ # Task files are named ``NNNN-<slug>.task``. Claiming walks the directory in
84
+ # sorted order, so the prefix is what makes the queue honour the order the
85
+ # coordinator seeded rather than the alphabet — which lets it put the slowest
86
+ # scenarios first (LPT) so no worker is left running a 3-minute scenario alone
87
+ # after everyone else has finished. Claim/result files stay keyed by the bare
88
+ # slug, so nothing downstream has to know about this.
89
+ _ORDER_PREFIX_RE = re.compile(r"^\d{4}-")
90
+ _ORDER_PREFIX_WIDTH = 4
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ScenarioResult:
95
+ """One scenario's outcome, written to results/<worker_id>/<slug>.json."""
96
+
97
+ slug: str
98
+ worker_id: str
99
+ exit_code: int
100
+ duration_s: float
101
+ started_at: float # epoch seconds
102
+ finished_at: float
103
+
104
+ def to_dict(self) -> dict:
105
+ return {
106
+ "slug": self.slug,
107
+ "worker_id": self.worker_id,
108
+ "exit_code": self.exit_code,
109
+ "duration_s": self.duration_s,
110
+ "started_at": self.started_at,
111
+ "finished_at": self.finished_at,
112
+ "passed": self.exit_code == 0,
113
+ }
114
+
115
+
116
+ class Queue:
117
+ """Filesystem-backed work-stealing queue.
118
+
119
+ The coordinator instantiates one Queue, calls ``seed(slugs)`` to populate
120
+ queue/, then bind-mounts the parent dir into every worker container.
121
+ Each worker instantiates a Queue against the in-container path and
122
+ loops on ``claim()``. queue/, claims/, results/ are siblings under
123
+ ``root`` on both sides — the coordinator writes to host:<tmp>/queue/,
124
+ the worker sees the same files at /steppy/coord/queue/.
125
+ """
126
+
127
+ def __init__(self, root: Path):
128
+ self.root = Path(root)
129
+ self.queue_dir = self.root / "queue"
130
+ self.claims_dir = self.root / "claims"
131
+ self.results_dir = self.root / "results"
132
+ # task_id -> member slugs, for the batches this Queue seeded. Held as
133
+ # instance state rather than passed to each reader on purpose: orphan
134
+ # detection has three call sites, and a reader that forgot the map would
135
+ # report a group NAME as a failed scenario and retry a test file that does
136
+ # not exist. State cannot be forgotten; a parameter can.
137
+ self._task_slugs: dict[str, list[str]] = {}
138
+
139
+ def init_dirs(self) -> None:
140
+ """Create the queue/claims/results directory skeleton."""
141
+ self.queue_dir.mkdir(parents=True, exist_ok=True)
142
+ self.claims_dir.mkdir(parents=True, exist_ok=True)
143
+ self.results_dir.mkdir(parents=True, exist_ok=True)
144
+
145
+ def seed(self, slugs: list[str], *, batches: Optional[dict[str, list[str]]] = None) -> None:
146
+ """Coordinator-only: write one task file per queue entry.
147
+
148
+ ``slugs`` is a list of **task ids** — bare scenario slugs unless an entry
149
+ names an exclusion group. Task files carry a zero-padded ordinal prefix so
150
+ ``claim()``'s sorted walk hands them out in **the order given**, not
151
+ alphabetically. The coordinator uses that to seed longest-first from
152
+ recorded durations; callers that don't care can keep passing any order.
153
+
154
+ ``batches`` maps a task id to its member slugs and is written into the
155
+ marker as one line of JSON. Ids absent from it keep today's empty
156
+ ``touch()``, so a run with no exclusion group produces a byte-identical
157
+ queue directory. Callers pass ``batches`` only when it is non-empty.
158
+ """
159
+ self.init_dirs()
160
+ batches = batches or {}
161
+ self._task_slugs = {task_id: list(members) for task_id, members in batches.items()}
162
+ for index, task_id in enumerate(slugs):
163
+ path = self.queue_dir / f"{index:0{_ORDER_PREFIX_WIDTH}d}-{task_id}{TASK_SUFFIX}"
164
+ members = batches.get(task_id)
165
+ if members:
166
+ payload = {"v": TASK_PAYLOAD_VERSION, "group": task_id, "slugs": list(members)}
167
+ path.write_text(json.dumps(payload), encoding="utf-8")
168
+ else:
169
+ path.touch()
170
+
171
+ @staticmethod
172
+ def _slug_from_task(name: str) -> str:
173
+ """``0007-checkout.task`` → ``checkout``. Tolerates un-prefixed names so
174
+ a queue dir seeded by an older steppy still drains.
175
+
176
+ Returns the *task id*, which is a scenario slug unless the entry is a
177
+ batch, in which case it is the exclusion group's name."""
178
+ stem = name[: -len(TASK_SUFFIX)] if name.endswith(TASK_SUFFIX) else name
179
+ return _ORDER_PREFIX_RE.sub("", stem, count=1)
180
+
181
+ def _read_task(self, entry: Path, task_id: str) -> Task:
182
+ """Turn a claimed marker file into a :class:`Task`.
183
+
184
+ Read **after** the claim succeeds: reading first would waste a read on
185
+ every lost race, and the file is written once at seed time and never
186
+ mutated, so reading it later is race-free.
187
+ """
188
+ try:
189
+ raw = entry.read_text(encoding="utf-8").strip()
190
+ except OSError as e:
191
+ raise TaskPayloadError(task_id, str(e))
192
+ if not raw:
193
+ return Task(task_id=task_id, slugs=(task_id,), group=None)
194
+ try:
195
+ payload = json.loads(raw)
196
+ members = [str(s) for s in payload["slugs"]]
197
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
198
+ raise TaskPayloadError(task_id, str(e))
199
+ if not members:
200
+ raise TaskPayloadError(task_id, "payload declares no scenarios")
201
+ return Task(task_id=task_id, slugs=tuple(members), group=str(payload.get("group") or task_id))
202
+
203
+ def claim_task(self, worker_id: str) -> Optional[Task]:
204
+ """Worker-only: atomically claim the next pending task via O_EXCL on
205
+ a per-task claim file. Returns the :class:`Task`, or None if the
206
+ queue is empty for this worker.
207
+
208
+ Tasks are seeded once by the coordinator before any worker runs and
209
+ are never removed (task files stay put for orphan detection). A
210
+ single pass through the snapshot is therefore sufficient: if every
211
+ candidate raises ``FileExistsError`` then every queued task has
212
+ already been claimed by another worker and there is nothing for us
213
+ to do — return ``None`` so the caller exits.
214
+
215
+ Raises :class:`TaskPayloadError` when a batch marker cannot be read; the
216
+ claim has already succeeded by then, so the caller reports it and moves on
217
+ and the members surface as orphans.
218
+ """
219
+ self.init_dirs()
220
+ try:
221
+ candidates = sorted(e for e in self.queue_dir.iterdir() if e.name.endswith(TASK_SUFFIX))
222
+ except FileNotFoundError:
223
+ return None
224
+ for entry in candidates:
225
+ task_id = self._slug_from_task(entry.name)
226
+ claim_path = self.claims_dir / f"{task_id}{CLAIM_SUFFIX}"
227
+ try:
228
+ fd = os.open(
229
+ str(claim_path),
230
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
231
+ )
232
+ except FileExistsError:
233
+ continue # another worker got it; try the next candidate
234
+ try:
235
+ os.write(fd, worker_id.encode("utf-8"))
236
+ finally:
237
+ os.close(fd)
238
+ return self._read_task(entry, task_id)
239
+ return None # every candidate already claimed → drained for us
240
+
241
+ def claim(self, worker_id: str) -> Optional[str]:
242
+ """Claim the next pending task and return its **task id**.
243
+
244
+ A thin wrapper over :meth:`claim_task` so there is one implementation of
245
+ the claim protocol and no drift between them.
246
+ """
247
+ task = self.claim_task(worker_id)
248
+ return task.task_id if task is not None else None
249
+
250
+ def write_result(self, result: ScenarioResult) -> None:
251
+ """Worker-only: persist the result to results/<worker_id>/<slug>.json."""
252
+ worker_results_dir = self.results_dir / result.worker_id
253
+ worker_results_dir.mkdir(parents=True, exist_ok=True)
254
+ result_path = worker_results_dir / f"{result.slug}{RESULT_SUFFIX}"
255
+ result_path.write_text(json.dumps(result.to_dict()), encoding="utf-8")
256
+
257
+ def unclaimed_tasks(self) -> set[str]:
258
+ """Task ids that were queued and never claimed by anybody.
259
+
260
+ Reachable only when every worker died with work left — but nothing else in
261
+ the system observes it: ``orphaned_running_tasks`` walks ``claims/`` and
262
+ ``collect_results`` walks ``results/``, so a task with neither file is
263
+ invisible to both. Without this the run can report success having silently
264
+ executed nothing.
265
+ """
266
+ try:
267
+ queued = {self._slug_from_task(p.name) for p in self.queue_dir.iterdir() if p.suffix == TASK_SUFFIX}
268
+ except FileNotFoundError:
269
+ return set()
270
+ try:
271
+ claimed = {p.stem for p in self.claims_dir.iterdir() if p.suffix == CLAIM_SUFFIX}
272
+ except FileNotFoundError:
273
+ claimed = set()
274
+ return queued - claimed
275
+
276
+ def _reported_slugs(self) -> set[str]:
277
+ """Every scenario that produced a result, by filename — no JSON parsing."""
278
+ if not self.results_dir.exists():
279
+ return set()
280
+ return {p.stem for p in self.results_dir.glob(f"*/*{RESULT_SUFFIX}")}
281
+
282
+ def unclaimed_slugs(self) -> list[str]:
283
+ """Scenarios with no evidence they ever ran.
284
+
285
+ :meth:`unclaimed_tasks` expanded through the batch map, minus anything
286
+ that reported a result. A result implies a claim in the real protocol
287
+ (the claim file is created first), so subtracting them changes nothing
288
+ about a live run — but it makes this incapable of a false positive, and
289
+ this set forces the run to fail.
290
+ """
291
+ reported = self._reported_slugs()
292
+ out: list[str] = []
293
+ for task_id in sorted(self.unclaimed_tasks()):
294
+ out.extend(s for s in (self._task_slugs.get(task_id) or [task_id]) if s not in reported)
295
+ return out
296
+
297
+ def is_empty(self) -> bool:
298
+ """Coordinator-only: True when no pending tasks remain (every queued
299
+ task has at least been claimed)."""
300
+ return not self.unclaimed_tasks()
301
+
302
+ def collect_results(self) -> list[ScenarioResult]:
303
+ """Coordinator-only: read all results written so far."""
304
+ out: list[ScenarioResult] = []
305
+ if not self.results_dir.exists():
306
+ return out
307
+ for worker_dir in self.results_dir.iterdir():
308
+ if not worker_dir.is_dir():
309
+ continue
310
+ for result_file in worker_dir.glob(f"*{RESULT_SUFFIX}"):
311
+ try:
312
+ data = json.loads(result_file.read_text(encoding="utf-8"))
313
+ except (json.JSONDecodeError, OSError):
314
+ continue
315
+ out.append(
316
+ ScenarioResult(
317
+ slug=data["slug"],
318
+ worker_id=data["worker_id"],
319
+ exit_code=int(data["exit_code"]),
320
+ duration_s=float(data["duration_s"]),
321
+ started_at=float(data["started_at"]),
322
+ finished_at=float(data["finished_at"]),
323
+ )
324
+ )
325
+ return out
326
+
327
+ def _assert_batch_map_present(self) -> None:
328
+ """Fail loudly if this Queue holds batch markers it cannot interpret.
329
+
330
+ A reader that expands claim stems without the map would report an exclusion
331
+ group's NAME as a failed scenario, retry a test file that does not exist,
332
+ and never retry the group's real members. That is a silent, plausible-looking
333
+ wrong answer, so turn the only way it can arise — querying a Queue instance
334
+ that did not seed this directory — into an immediate error.
335
+ """
336
+ if self._task_slugs:
337
+ return
338
+ try:
339
+ entries = [p for p in self.queue_dir.iterdir() if p.name.endswith(TASK_SUFFIX)]
340
+ except FileNotFoundError:
341
+ return
342
+ batched = sorted(self._slug_from_task(p.name) for p in entries if p.stat().st_size > 0)
343
+ if batched:
344
+ raise RuntimeError(
345
+ f"queue at {self.root} holds exclusion-group task(s) {batched} but this "
346
+ "Queue instance has no batch map. Read it through the Queue that seeded "
347
+ "it, or re-seed with `batches=`."
348
+ )
349
+
350
+ def orphaned_running_tasks(self) -> dict[str, list[str]]:
351
+ """Coordinator-only: scenarios claimed but never reported (worker crashed
352
+ before writing the result file). Returns ``{worker_id: [slug, ...]}``.
353
+
354
+ Always **scenario slugs**, never task ids: a claimed batch is expanded
355
+ through the map, so a worker that died after two of three members still
356
+ reports exactly the one that did not finish. A stem with no map entry is a
357
+ plain scenario and takes the original path verbatim.
358
+
359
+ v0.1: these are reported as failures, not requeued.
360
+ """
361
+ orphans: dict[str, list[str]] = {}
362
+ if not self.claims_dir.exists():
363
+ return orphans
364
+ self._assert_batch_map_present()
365
+ for claim_file in self.claims_dir.glob(f"*{CLAIM_SUFFIX}"):
366
+ task_id = claim_file.stem
367
+ members = self._task_slugs.get(task_id)
368
+ try:
369
+ worker_id = claim_file.read_text(encoding="utf-8").strip()
370
+ except OSError:
371
+ continue
372
+ if not worker_id:
373
+ # Killed between the claim's create and its write. For a single
374
+ # scenario that stays today's skip; for a batch, skipping would
375
+ # lose every member in silence — and the owner id is written
376
+ # before any scenario runs, so none of them started.
377
+ if members:
378
+ orphans.setdefault(UNKNOWN_WORKER, []).extend(members)
379
+ continue
380
+ for slug in members if members else [task_id]:
381
+ result_path = self.results_dir / worker_id / f"{slug}{RESULT_SUFFIX}"
382
+ if not result_path.exists():
383
+ orphans.setdefault(worker_id, []).append(slug)
384
+ # Keep slugs sorted for deterministic test output.
385
+ return {wid: sorted(slugs) for wid, slugs in orphans.items()}