verifaied 0.32.0.dev80__tar.gz → 0.33.0.dev81__tar.gz

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 (78) hide show
  1. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/PKG-INFO +62 -4
  2. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/README.md +61 -3
  3. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/pyproject.toml +1 -1
  4. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/cli.py +20 -0
  5. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/client.py +21 -0
  6. verifaied-0.33.0.dev81/src/verifaied/lanes.py +108 -0
  7. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/repo_config.py +222 -4
  8. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/testcmd.py +456 -32
  9. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_browser_settings_parity.py +29 -5
  10. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_client.py +45 -0
  11. verifaied-0.33.0.dev81/tests/test_lanes.py +118 -0
  12. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_repo_config.py +333 -0
  13. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_testcmd.py +879 -5
  14. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/uv.lock +1 -1
  15. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/.gitignore +0 -0
  16. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/LICENSE +0 -0
  17. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/__init__.py +0 -0
  18. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/__main__.py +0 -0
  19. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/agent_mcp.py +0 -0
  20. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/agent_steps.py +0 -0
  21. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/analyzer.py +0 -0
  22. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/audit.py +0 -0
  23. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/collect_browser.js +0 -0
  24. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/collect_browser.py +0 -0
  25. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/config.py +0 -0
  26. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/failure_text.py +0 -0
  27. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/instrument.py +0 -0
  28. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/instrument_snippets.py +0 -0
  29. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/instrumenter.js +0 -0
  30. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/instrumenter.py +0 -0
  31. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/prompts.py +0 -0
  32. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/proxy.py +0 -0
  33. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/record.py +0 -0
  34. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/recorder_driver.js +0 -0
  35. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/recorder_observer.js +0 -0
  36. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/recorder_picker.js +0 -0
  37. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/recorder_refs.js +0 -0
  38. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/recorder_spec.js +0 -0
  39. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/replay_harvest.ts +0 -0
  40. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/run_reporter.js +0 -0
  41. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/runner_dir.py +0 -0
  42. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/skill/SKILL.md +0 -0
  43. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/skill/reference/commands.md +0 -0
  44. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/skillcmd.py +0 -0
  45. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/source_index.py +0 -0
  46. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/stepcheck.py +0 -0
  47. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/subprocess_util.py +0 -0
  48. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/suite_report.py +0 -0
  49. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/uploader.py +0 -0
  50. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/src/verifaied/varcmd.py +0 -0
  51. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/__init__.py +0 -0
  52. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/conftest.py +0 -0
  53. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_agent_mcp.py +0 -0
  54. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_audit.py +0 -0
  55. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_caption_parity.py +0 -0
  56. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_check.py +0 -0
  57. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_check_done.py +0 -0
  58. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_clear_manual.py +0 -0
  59. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_cli.py +0 -0
  60. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_collect_browser.py +0 -0
  61. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_config.py +0 -0
  62. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_console_label_parity.py +0 -0
  63. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_instrument.py +0 -0
  64. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_instrument_detect.py +0 -0
  65. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_instrument_snippets.py +0 -0
  66. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_instrumenter.py +0 -0
  67. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_pause_parity.py +0 -0
  68. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_prompt_parity.py +0 -0
  69. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_proxy.py +0 -0
  70. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_record.py +0 -0
  71. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_replay_harvest_runner.py +0 -0
  72. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_skill_parity.py +0 -0
  73. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_skillcmd.py +0 -0
  74. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_source_index.py +0 -0
  75. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_stepcheck_parity.py +0 -0
  76. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_suite_report.py +0 -0
  77. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_uploader.py +0 -0
  78. {verifaied-0.32.0.dev80 → verifaied-0.33.0.dev81}/tests/test_varcmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifaied
3
- Version: 0.32.0.dev80
3
+ Version: 0.33.0.dev81
4
4
  Summary: Find what's untested in your code — locally, no account required
5
5
  Project-URL: Homepage, https://pypi.org/project/verifaied/
6
6
  Author: Kyle Richards
@@ -479,6 +479,10 @@ verifaied test run "Settings/API tokens" --video watch
479
479
  # On a busy CI machine: replay a failed test once more, from a fresh reset.
480
480
  verifaied test run --all --retries 1
481
481
 
482
+ # Replay four at a time — one per copy of the app the environment
483
+ # declares (see "Running several at once" below).
484
+ verifaied test run --all --parallel 4
485
+
482
486
  # What's recorded, and how each last went on this branch.
483
487
  verifaied test list
484
488
 
@@ -820,6 +824,45 @@ origin — scheme and host, no path — and a spec with a relative location
820
824
  in a repo that names none is refused up front, rather than dying on
821
825
  Playwright's "invalid URL" halfway through a run.
822
826
 
827
+ #### Running several at once
828
+
829
+ A reset before every run is what makes a flow repeatable, and it is also
830
+ what stops two runs sharing one app: each would wipe the other's data
831
+ mid-flow. So running side by side needs one copy of the app per extra
832
+ run, each with its own reset — a **lane**:
833
+
834
+ ```toml
835
+ [browser-tests.env.local]
836
+ base_url = "http://localhost:3000"
837
+ reset = "make db-reset"
838
+
839
+ # One more copy of the app, for a second run beside the first.
840
+ [[browser-tests.env.local.lanes]]
841
+ base_url = "http://localhost:3100"
842
+ reset = "make db-reset SLOT=1"
843
+ ```
844
+
845
+ That environment runs two at once. An environment with **no** reset (a
846
+ deployed one using `unique` variables) shares its one app between up to
847
+ eight runs; any other runs one at a time. Lanes are checked when the file
848
+ is read: each needs its own `base_url`, a `reset` exactly when its
849
+ environment has one, and no two may point at the same app.
850
+
851
+ How many to run is asked for separately, because it is about the
852
+ batch rather than the machine:
853
+
854
+ - **From the web**, set **Tests run at once** on the repository's
855
+ Settings page (Browser tests → Running tests), or `parallel_runs = 4`
856
+ under `[browser-tests]`. A studio then replays a Run all or a folder's
857
+ Run that many at a time, headless, up to what its environment can
858
+ isolate — and the Browser Tests page says so when it is fewer.
859
+ Recordings and single runs always take turns.
860
+ - **From the terminal**, `verifaied test run --all --parallel 4` (or
861
+ `--folder`). Only the flag does this: the web setting never changes how
862
+ a scripted run behaves. Asking for more than the environment has lanes
863
+ for runs what it can and says why; `--parallel` with a single test or
864
+ with `--proxy` is refused.
865
+
823
866
  ### Variables — the data that differs, and the data that must be fresh
824
867
 
825
868
  A login is one flow with three passwords. A "create a token called
@@ -1054,12 +1097,23 @@ cloud can stop it. A request that fails is reported with the reason and
1054
1097
  the studio carries on — one bad pre-step chain is not a reason for it to
1055
1098
  die.
1056
1099
 
1100
+ **Several at once.** With **Tests run at once** above 1 on the
1101
+ repository's Settings page and an environment that can isolate them
1102
+ (see *Running several at once* above), the studio starts each headless
1103
+ run of a batch on a lane of its own and goes straight back for the next.
1104
+ A recording, an edit or a headed run queued behind the batch waits until
1105
+ the lanes are empty, and is never overtaken by the rest of the batch.
1106
+ The pill says how many it runs at once, and the page shows the studio's
1107
+ own sentence when the machine runs fewer than asked.
1108
+
1057
1109
  **Stopping a run from the web.** While a run is replaying, the spinning
1058
1110
  Run button on the page turns into **Stop**. The studio hears it on its next
1059
1111
  beat, ends the replay's whole process group (SIGTERM, then SIGKILL), and
1060
1112
  records the run as failed with "Stopped from the web"; its video and trace
1061
- are not kept. This uses POSIX process groups (`os.killpg`), so it needs
1062
- macOS or Linux — the studio does not support Windows.
1113
+ are not kept. **Stop runs** in the page's header ends the whole batch at
1114
+ once — every run in progress, and everything still queued. This uses POSIX
1115
+ process groups (`os.killpg`), so it needs macOS or Linux — the studio does
1116
+ not support Windows.
1063
1117
 
1064
1118
  **Branches.** The branch is re-detected on every beat, so switching
1065
1119
  branches under a running studio moves what the web shows and what the next
@@ -1154,7 +1208,11 @@ run; the **last** one is the verdict, the coverage and the video and trace
1154
1208
  the test keeps (a failed attempt that is retried keeps its verdict and its
1155
1209
  failure text but uploads no video or trace). A refusal — a missing spec, a
1156
1210
  variable this machine cannot supply, a reset that failed — is never
1157
- retried, and neither is a clean run whose coverage upload failed (`2`). `studio` takes `--no-replay` and takes no test name at all —
1211
+ retried, and neither is a clean run whose coverage upload failed (`2`).
1212
+ `--parallel <n>` (1–8, default 1, `--all`/`--folder` only) replays up to
1213
+ `n` at once, one per lane of the environment — see *Running several at
1214
+ once*; retries stay in the lane that owns the test, and Ctrl-C stops every
1215
+ lane and exits `130`. `studio` takes `--no-replay` and takes no test name at all —
1158
1216
  what it records is whatever the web asks for.
1159
1217
 
1160
1218
  `sync` takes `--repo`, `--branch` (the branch a new test is stamped with),
@@ -433,6 +433,10 @@ verifaied test run "Settings/API tokens" --video watch
433
433
  # On a busy CI machine: replay a failed test once more, from a fresh reset.
434
434
  verifaied test run --all --retries 1
435
435
 
436
+ # Replay four at a time — one per copy of the app the environment
437
+ # declares (see "Running several at once" below).
438
+ verifaied test run --all --parallel 4
439
+
436
440
  # What's recorded, and how each last went on this branch.
437
441
  verifaied test list
438
442
 
@@ -774,6 +778,45 @@ origin — scheme and host, no path — and a spec with a relative location
774
778
  in a repo that names none is refused up front, rather than dying on
775
779
  Playwright's "invalid URL" halfway through a run.
776
780
 
781
+ #### Running several at once
782
+
783
+ A reset before every run is what makes a flow repeatable, and it is also
784
+ what stops two runs sharing one app: each would wipe the other's data
785
+ mid-flow. So running side by side needs one copy of the app per extra
786
+ run, each with its own reset — a **lane**:
787
+
788
+ ```toml
789
+ [browser-tests.env.local]
790
+ base_url = "http://localhost:3000"
791
+ reset = "make db-reset"
792
+
793
+ # One more copy of the app, for a second run beside the first.
794
+ [[browser-tests.env.local.lanes]]
795
+ base_url = "http://localhost:3100"
796
+ reset = "make db-reset SLOT=1"
797
+ ```
798
+
799
+ That environment runs two at once. An environment with **no** reset (a
800
+ deployed one using `unique` variables) shares its one app between up to
801
+ eight runs; any other runs one at a time. Lanes are checked when the file
802
+ is read: each needs its own `base_url`, a `reset` exactly when its
803
+ environment has one, and no two may point at the same app.
804
+
805
+ How many to run is asked for separately, because it is about the
806
+ batch rather than the machine:
807
+
808
+ - **From the web**, set **Tests run at once** on the repository's
809
+ Settings page (Browser tests → Running tests), or `parallel_runs = 4`
810
+ under `[browser-tests]`. A studio then replays a Run all or a folder's
811
+ Run that many at a time, headless, up to what its environment can
812
+ isolate — and the Browser Tests page says so when it is fewer.
813
+ Recordings and single runs always take turns.
814
+ - **From the terminal**, `verifaied test run --all --parallel 4` (or
815
+ `--folder`). Only the flag does this: the web setting never changes how
816
+ a scripted run behaves. Asking for more than the environment has lanes
817
+ for runs what it can and says why; `--parallel` with a single test or
818
+ with `--proxy` is refused.
819
+
777
820
  ### Variables — the data that differs, and the data that must be fresh
778
821
 
779
822
  A login is one flow with three passwords. A "create a token called
@@ -1008,12 +1051,23 @@ cloud can stop it. A request that fails is reported with the reason and
1008
1051
  the studio carries on — one bad pre-step chain is not a reason for it to
1009
1052
  die.
1010
1053
 
1054
+ **Several at once.** With **Tests run at once** above 1 on the
1055
+ repository's Settings page and an environment that can isolate them
1056
+ (see *Running several at once* above), the studio starts each headless
1057
+ run of a batch on a lane of its own and goes straight back for the next.
1058
+ A recording, an edit or a headed run queued behind the batch waits until
1059
+ the lanes are empty, and is never overtaken by the rest of the batch.
1060
+ The pill says how many it runs at once, and the page shows the studio's
1061
+ own sentence when the machine runs fewer than asked.
1062
+
1011
1063
  **Stopping a run from the web.** While a run is replaying, the spinning
1012
1064
  Run button on the page turns into **Stop**. The studio hears it on its next
1013
1065
  beat, ends the replay's whole process group (SIGTERM, then SIGKILL), and
1014
1066
  records the run as failed with "Stopped from the web"; its video and trace
1015
- are not kept. This uses POSIX process groups (`os.killpg`), so it needs
1016
- macOS or Linux — the studio does not support Windows.
1067
+ are not kept. **Stop runs** in the page's header ends the whole batch at
1068
+ once — every run in progress, and everything still queued. This uses POSIX
1069
+ process groups (`os.killpg`), so it needs macOS or Linux — the studio does
1070
+ not support Windows.
1017
1071
 
1018
1072
  **Branches.** The branch is re-detected on every beat, so switching
1019
1073
  branches under a running studio moves what the web shows and what the next
@@ -1108,7 +1162,11 @@ run; the **last** one is the verdict, the coverage and the video and trace
1108
1162
  the test keeps (a failed attempt that is retried keeps its verdict and its
1109
1163
  failure text but uploads no video or trace). A refusal — a missing spec, a
1110
1164
  variable this machine cannot supply, a reset that failed — is never
1111
- retried, and neither is a clean run whose coverage upload failed (`2`). `studio` takes `--no-replay` and takes no test name at all —
1165
+ retried, and neither is a clean run whose coverage upload failed (`2`).
1166
+ `--parallel <n>` (1–8, default 1, `--all`/`--folder` only) replays up to
1167
+ `n` at once, one per lane of the environment — see *Running several at
1168
+ once*; retries stay in the lane that owns the test, and Ctrl-C stops every
1169
+ lane and exits `130`. `studio` takes `--no-replay` and takes no test name at all —
1112
1170
  what it records is whatever the web asks for.
1113
1171
 
1114
1172
  `sync` takes `--repo`, `--branch` (the branch a new test is stamped with),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "verifaied"
3
- version = "0.32.0.dev80"
3
+ version = "0.33.0.dev81"
4
4
  description = "Find what's untested in your code — locally, no account required"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -48,6 +48,8 @@ from verifaied.record import DEFAULT_INTERVAL, DEFAULT_PORT, run_record
48
48
  from verifaied.repo_config import (
49
49
  CONFIG_PATH,
50
50
  ENV_KEY,
51
+ MAX_PARALLEL_RUNS,
52
+ MIN_PARALLEL_RUNS,
51
53
  SECTION,
52
54
  RepoConfigError,
53
55
  VideoProfile,
@@ -2064,6 +2066,18 @@ def test_run_command(
2064
2066
  "is the verdict and coverage recorded. Refusals are never retried."
2065
2067
  ),
2066
2068
  ),
2069
+ parallel: int = typer.Option(
2070
+ 1,
2071
+ "--parallel",
2072
+ min=MIN_PARALLEL_RUNS,
2073
+ max=MAX_PARALLEL_RUNS,
2074
+ help=(
2075
+ "Replay up to N tests at once (--all/--folder only). Needs an "
2076
+ "environment with no reset, or one that declares "
2077
+ "[[browser-tests.env.<name>.lanes]]; otherwise runs one at a time "
2078
+ "and says why."
2079
+ ),
2080
+ ),
2067
2081
  ) -> None:
2068
2082
  """Replay a recorded test, capturing video, trace and coverage.
2069
2083
 
@@ -2093,6 +2107,11 @@ def test_run_command(
2093
2107
  each from a fresh reset. The last attempt is the verdict and the
2094
2108
  coverage the test keeps; a refusal is never retried.
2095
2109
 
2110
+ ``--parallel N`` replays up to N tests of an ``--all`` or ``--folder``
2111
+ run at once, one per lane of the environment — each lane its own copy
2112
+ of the app with its own reset. Only this flag turns it on: the
2113
+ Settings page's "Tests run at once" is for the studio.
2114
+
2096
2115
  Exit codes:
2097
2116
 
2098
2117
  0 every test replayed cleanly
@@ -2125,6 +2144,7 @@ def test_run_command(
2125
2144
  proxy_port=proxy_port,
2126
2145
  video=video.value if video is not None else None,
2127
2146
  retries=retries,
2147
+ parallel=parallel,
2128
2148
  )
2129
2149
  except (RecordedTestError, UploaderError) as e:
2130
2150
  err_console.print(f"[red]VerifAIed error[/red]: {e}")
@@ -723,6 +723,10 @@ def post_recorder_heartbeat(
723
723
  environment: str | None = None,
724
724
  busy: bool = False,
725
725
  working: UUID | None = None,
726
+ working_ids: list[UUID] | None = None,
727
+ take_kinds: list[str] | None = None,
728
+ lanes: int | None = None,
729
+ lanes_note: str | None = None,
726
730
  timeout: float = 30.0,
727
731
  ) -> dict[str, Any]:
728
732
  """Say a studio is listening, and collect what to record next.
@@ -761,6 +765,15 @@ def post_recorder_heartbeat(
761
765
  becoming a status code because presence must keep working when the
762
766
  branch allowance is spent. A service that predates any of this sends
763
767
  none of the three, which reads as nothing to say.
768
+
769
+ The lane fields are a studio replaying several headless runs at once:
770
+ ``working_ids`` names every run in flight, ``take_kinds`` narrows what
771
+ this beat may be handed to another headless run, and ``lanes`` /
772
+ ``lanes_note`` say how many it runs at once and why not more. Each is
773
+ sent only when given, so a studio running one at a time beats exactly
774
+ as it always did. The answer's ``parallel_runs`` is what the
775
+ repository's Settings page asks for; a service that predates lanes
776
+ leaves the key out.
764
777
  """
765
778
  request_url = f"{api_url.rstrip('/')}/repositories/{repo_id}/recorder-heartbeat"
766
779
  body: dict[str, Any] = {"branch": branch, "closing": closing}
@@ -774,6 +787,14 @@ def post_recorder_heartbeat(
774
787
  # Which run this busy studio is on, so the service does not stop it
775
788
  # as a run whose studio went away. Sent only when named.
776
789
  body["working"] = str(working)
790
+ if working_ids:
791
+ body["working_ids"] = [str(i) for i in working_ids]
792
+ if take_kinds is not None:
793
+ body["take_kinds"] = take_kinds
794
+ if lanes is not None:
795
+ body["lanes"] = lanes
796
+ if lanes_note is not None:
797
+ body["lanes_note"] = lanes_note
777
798
  try:
778
799
  response = _http().post(
779
800
  request_url,
@@ -0,0 +1,108 @@
1
+ """Several headless replays at once, one per lane.
2
+
3
+ A lane is one copy of the app a replay can run against without another
4
+ replay's reset wiping it (``lane_capacity`` / ``lane_env`` in
5
+ ``repo_config.py`` decide how many there are and where each points).
6
+ This module is only the bookkeeping: which lane is free, which request
7
+ each lane is on, and how a stop reaches the one thread that can act on
8
+ it. It knows nothing about replays, so the studio and ``test run
9
+ --parallel`` share it and its tests need no browser.
10
+
11
+ Each lane runs on a daemon thread of its own, and removes itself from
12
+ the pool when its work returns or raises — a lane that died must never
13
+ read as busy forever.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from collections.abc import Callable, Iterable
20
+ from typing import Protocol
21
+ from uuid import UUID
22
+
23
+
24
+ class LaneStop(Protocol):
25
+ """What a lane's work is handed to be stopped by (``RunStop``)."""
26
+
27
+ def set(self) -> None: ...
28
+
29
+ def interrupt(self, message: str) -> None: ...
30
+
31
+
32
+ class _Lane:
33
+ def __init__(self, request_id: UUID, stop: LaneStop) -> None:
34
+ self.request_id = request_id
35
+ self.stop = stop
36
+ self.thread: threading.Thread | None = None
37
+
38
+
39
+ class LanePool:
40
+ """The lanes in flight, keyed by lane number."""
41
+
42
+ def __init__(self, make_stop: Callable[[], LaneStop]) -> None:
43
+ self._make_stop = make_stop
44
+ self._lanes: dict[int, _Lane] = {}
45
+ self._lock = threading.Lock()
46
+
47
+ def in_flight(self) -> list[UUID]:
48
+ """The requests being worked on, in lane order."""
49
+ with self._lock:
50
+ return [self._lanes[i].request_id for i in sorted(self._lanes)]
51
+
52
+ def free_lane(self, size: int) -> int | None:
53
+ """The lowest free lane below ``size``, or None when all are busy."""
54
+ with self._lock:
55
+ return next((i for i in range(size) if i not in self._lanes), None)
56
+
57
+ def start(
58
+ self,
59
+ request_id: UUID,
60
+ lane: int,
61
+ work: Callable[[LaneStop, int], None],
62
+ ) -> None:
63
+ """Run ``work(stop, lane)`` on a thread of its own in ``lane``."""
64
+ entry = _Lane(request_id, self._make_stop())
65
+
66
+ def run() -> None:
67
+ try:
68
+ work(entry.stop, lane)
69
+ finally:
70
+ with self._lock:
71
+ del self._lanes[lane]
72
+
73
+ with self._lock:
74
+ if lane in self._lanes:
75
+ raise ValueError(f"lane {lane} is already running")
76
+ self._lanes[lane] = entry
77
+ entry.thread = threading.Thread(
78
+ target=run, name=f"verifaied-lane-{lane}", daemon=True
79
+ )
80
+ entry.thread.start()
81
+
82
+ def stop(self, ids: Iterable[object]) -> None:
83
+ """Stop the lanes working on any of ``ids``; others are left alone."""
84
+ wanted = {str(i) for i in ids}
85
+ with self._lock:
86
+ lanes = list(self._lanes.values())
87
+ for entry in lanes:
88
+ if str(entry.request_id) in wanted:
89
+ entry.stop.set()
90
+
91
+ def interrupt_all(self, message: str) -> None:
92
+ """Stop every lane, closing each with ``message``."""
93
+ with self._lock:
94
+ lanes = list(self._lanes.values())
95
+ for entry in lanes:
96
+ entry.stop.interrupt(message)
97
+
98
+ def join_all(self, poll: float = 0.5) -> None:
99
+ """Wait for every lane in flight to finish.
100
+
101
+ In short waits rather than one, so a Ctrl-C reaches the waiting
102
+ thread at once instead of after the slowest lane.
103
+ """
104
+ with self._lock:
105
+ threads = [e.thread for e in self._lanes.values() if e.thread]
106
+ for thread in threads:
107
+ while thread.is_alive():
108
+ thread.join(poll)